From bb0a8e720cbeb8876a7e92604dab331104d4eeaa Mon Sep 17 00:00:00 2001 From: Defisym Date: Fri, 6 Nov 2026 15:44:23 +0800 Subject: [PATCH 01/23] NeoPic: allow loading an owned capture Surface without cloning --- Extensions/NeoPic/Func.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Extensions/NeoPic/Func.h b/Extensions/NeoPic/Func.h index 38043133f..c5afbf98a 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; From d8076cb52823711d2d65f326c91a9e7d9830e1fd Mon Sep 17 00:00:00 2001 From: Defisym Date: Fri, 6 Nov 2026 15:44:49 +0800 Subject: [PATCH 02/23] NeoPic: test client-area crop mapping across scale and monitor coordinates --- Extensions/NeoPic/CaptureCrop.h | 28 +++++++++++++++++++++ Extensions/NeoPic/tests/CaptureCropTest.cpp | 23 +++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 Extensions/NeoPic/CaptureCrop.h create mode 100644 Extensions/NeoPic/tests/CaptureCropTest.cpp diff --git a/Extensions/NeoPic/CaptureCrop.h b/Extensions/NeoPic/CaptureCrop.h new file mode 100644 index 000000000..7132239b5 --- /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/tests/CaptureCropTest.cpp b/Extensions/NeoPic/tests/CaptureCropTest.cpp new file mode 100644 index 000000000..d8dac7037 --- /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)); +} From 95756ffeb73f70f78dcb05b4f67ed394785fb656 Mon Sep 17 00:00:00 2001 From: Defisym Date: Fri, 6 Nov 2026 15:45:14 +0800 Subject: [PATCH 03/23] NeoPic: add opt-in Secondary Surface and WGC capture experiments --- Extensions/NeoPic/CaptureTesting.md | 119 +++++++++++++++++++ Extensions/NeoPic/FrameCapture.h | 9 +- Extensions/NeoPic/FrameCaptureDX11.h | 128 ++++++++++++++++++++ Extensions/NeoPic/Macro.h | 9 ++ Extensions/NeoPic/Main.cpp | 11 +- Extensions/NeoPic/NeoPic.vcxproj | 8 +- Extensions/NeoPic/Runtime.cpp | 9 +- Extensions/NeoPic/WgcCapture.cpp | 130 +++++++++++++++++++++ Extensions/NeoPic/WgcCapture.h | 20 ++++ Extensions/NeoPic/tests/CompileCapture.ps1 | 23 ++++ 10 files changed, 458 insertions(+), 8 deletions(-) create mode 100644 Extensions/NeoPic/CaptureTesting.md create mode 100644 Extensions/NeoPic/FrameCaptureDX11.h create mode 100644 Extensions/NeoPic/WgcCapture.cpp create mode 100644 Extensions/NeoPic/WgcCapture.h create mode 100644 Extensions/NeoPic/tests/CompileCapture.ps1 diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md new file mode 100644 index 000000000..40c6581c2 --- /dev/null +++ b/Extensions/NeoPic/CaptureTesting.md @@ -0,0 +1,119 @@ +# Experimental DX11 frame capture + +The existing **Capture Frame Area** action can test three backends. Select +`NEOPIC_CAPTURE_BACKEND` in `Macro.h` and **Rebuild the whole project** after each +change. The default remains `0`; no ACE IDs or saved object properties change. + +| Value | Source | Action behavior | +| --- | --- | --- | +| `0` | Existing window DC | Existing synchronous capture | +| `1` | `rhFrame->m_pSecondarySurface` | Request in events; GPU copy in a subsequent Handle | +| `2` | Windows Graphics Capture | Session starts at object creation; copy the latest available window frame in a subsequent Handle | + +Modes 1 and 2 require the DX11 renderer. They never fall back to DC. A failed +request leaves the current NeoPic image unchanged. Missing frames/secondary +surfaces are retried for up to 120 Handle calls; duplicate pending requests are +coalesced. Do not switch Fusion Frames or destroy the object before completion. + +The experimental image is transferred as an owned Fusion RTT, avoiding the +`CreateCloneSurface` path that can read HWA alpha back to CPU. Display/fade uses +the normal NeoPic image and ink effects. Existing save actions perform CPU +readback only when explicitly requested. These test backends are asynchronous: +a save action immediately following Capture still sees the previous image. + +## Build + +Use VS2022, Win32, and the existing Fusion SDK/dependencies. WGC additionally +needs a current Windows SDK with C++/WinRT headers (10.0.19041 or newer is a +suitable baseline) and Windows 10 1903 or newer on the test machine. +`WgcCapture.cpp` uses native `wchar_t` in isolation, while the other project +sources retain `/Zc:wchar_t-`. C++/WinRT does not cross that translation-unit ABI. +WGC links `windowsapp.lib`, `runtimeobject.lib`, `d3d11.lib`, and `dwmapi.lib`; those dependencies are excluded in +modes 0 and 1. + +From an x86 VS2022 Developer PowerShell, first check all three configurations: + +```powershell +./Extensions/NeoPic/tests/CompileCapture.ps1 +``` + +This runs MSVC syntax checks against the real `Main.cpp`, `Runtime.cpp`, and +`WgcCapture.cpp`; it does not link an MFX or prove runtime capture correctness. +Then set the selected macro in `Macro.h` and build: + +```powershell +msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 +``` + +Close Fusion, back up the installed NeoPic MFX, and install the resulting +`Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` in `Extensions/Unicode`. +For an exported EXE also build `Run_Only Unicode` and install that MFX in +`Data/Runtime/Unicode`. The source-only PR does not include new MFX binaries. + +## Minimal test scene (Fusion 296 / DX11) + +1. Create a fixed-size Frame with three visibly labelled layers (`L1`, `L2`, + `L3`), different colored Active objects, and a counter updated every loop. +2. Add a normal NeoPic with **HWA enabled**, initially blank and hidden. Keep it + in the Frame throughout the test. Press **C once** to invoke Capture Frame + Area. Avoid an Always event that repeatedly captures its own displayed image. +3. Wait a few loops (allow WGC roughly one second after startup), then press + **V** to show NeoPic. The result should include the three layer markers. Use + NeoPic's normal blend coefficient to test fade. Keep the original scene + visible until capture has completed. +4. In a separate **S** event, use the existing save-current-image action with + a writable absolute PNG path. This is the only step intended to read pixels + back and write the snapshot to disk. Save once with each backend selected. +5. Observe Visual Studio's native Debug output or DebugView for lines beginning + `NeoPic capture backend=`. `start`, `ready`, and `failed` include an HRESULT. + A missing secondary surface should eventually fail, never silently use DC. + +Repeat static and moving-counter tests under these configurations: + +- No layer/Frame effects and no transitions (a missing secondary is possible). +- An obvious layer effect plus an obvious Frame effect, with their names noted. +- A configured Frame transition; first capture while staying in the same Frame. +- WGC: partially cover Fusion with another window, resize, minimize/restore, and + move between monitors at different DPI scales. Check the client-area crop, + borders, menu exclusion and image orientation. A minimized window may time out. + +Record Windows/Fusion build, GPU/driver, backend macro, VSync/backbuffer settings, +effects, screenshots, and debug output. Use the visible counter to compare frame +age; WGC is not synchronized with a particular Fusion Display call. For timing, +compare one-shot captures at the same resolution without saving files; separate +first-use allocation from repeated captures. This PR makes no measured speed claim. + +## Expected limits + +- Secondary Surface availability, contents and inclusion of Frame effects are + deliberately under test. No runtime booleans or backbuffer counts are modified. + `m_pOldSecondarySurface` is not treated as the previous display frame. +- WGC captures the root window, then maps the Fusion child client rectangle from + physical DWM bounds to capture coordinates. Resize/DPI mapping needs real tests. + Its default OS border/cursor behavior is retained; it does not capture native + child controls into a Fusion-only scene texture separately. +- Both experimental sources are copied into independent RTTs using Fusion's DX11 + device, with format/dimension/device checks. Unsupported format conversions + fail explicitly; no staging texture, Map, LockBuffer, or pixel loop is used. +- WGC capture frames remain retained while their textures are copied. All Fusion + calls/copies happen on the runtime thread; there is no capture callback touching + Fusion. No GPU-idle wait is introduced. +- Device loss/session failure requires recreating the test object/application. + The request timeout is a loop count, not a wall-clock deadline. Capture success + is currently reported in debug output, not through a new Fusion condition. + +The client-crop arithmetic also has a portable test: + +```sh +c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureCropTest.cpp -o capture-crop-test +./capture-crop-test +``` + +## Validation status + +The development environment is Linux and cannot compile MSVC/C++/WinRT or run +Fusion/DWM. The crop tests passed locally with AddressSanitizer and UBSan (leak detection +disabled because the execution environment uses ptrace). Project XML and source +checks passed. The Windows compile +script and the manual matrix above are provided for actual validation. Keep the +PR experimental until those results are available. diff --git a/Extensions/NeoPic/FrameCapture.h b/Extensions/NeoPic/FrameCapture.h index b5171050d..ff251693f 100644 --- a/Extensions/NeoPic/FrameCapture.h +++ b/Extensions/NeoPic/FrameCapture.h @@ -1,5 +1,6 @@ #pragma once +#if NEOPIC_CAPTURE_BACKEND == 0 struct FrameCapture { LPSURFACE pCaptureSf = nullptr; HDC pCaptureDC = nullptr; @@ -10,8 +11,8 @@ struct FrameCapture { int frameWidth = -1; int frameHeight = -1; - FrameCapture(HWND hFrameWindowHandle) { - this->hFrameWindowHandle = hFrameWindowHandle; + FrameCapture(LPRDATA rdPtr) { + this->hFrameWindowHandle = rdPtr->rHo.hoAdRunHeader->rhHEditWin; } ~FrameCapture() { ReleaseDC(); @@ -75,3 +76,7 @@ struct FrameCapture { #endif // _DEBUG } }; + +#else +#include "FrameCaptureDX11.h" +#endif diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h new file mode 100644 index 000000000..610754e0e --- /dev/null +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -0,0 +1,128 @@ +#pragma once + +#include +#include +#include +#if NEOPIC_CAPTURE_BACKEND == 2 +#include "WgcCapture.h" +#endif + +// Experimental paths deliberately do not fall back to DC or read pixels to CPU. +struct FrameCapture { + std::unique_ptr snapshot; + bool pending = false; + int requestedLoop = 0; + int attempts = 0; +#if NEOPIC_CAPTURE_BACKEND == 2 + WgcCapture wgc; +#endif + + static void Report(HRESULT hr, const char* stage) { + char message[256]; + sprintf_s(message, "NeoPic capture backend=%d %s hr=0x%08lX\n", + NEOPIC_CAPTURE_BACKEND, stage, static_cast(hr)); + OutputDebugStringA(message); + } + + explicit FrameCapture(LPRDATA rdPtr) { +#if NEOPIC_CAPTURE_BACKEND == 2 + if (D3D11(rdPtr)) { + auto device = static_cast(GetD3D11Device(rdPtr)); + Report(wgc.Start(rdPtr->rHo.hoAdRunHeader->rhHEditWin, device), "start"); + } else Report(E_NOTIMPL, "DX11-required"); +#else + (void)rdPtr; +#endif + } + + void Request(LPRDATA rdPtr) { + if (pending) return; + pending = true; + attempts = 0; + requestedLoop = rdPtr->rHo.hoAdRunHeader->rhLoopCount; + } + + // Texture formats/sample counts must agree; CopyResource cannot resize or + // convert. The WGC path supplies a client-area crop in source coordinates. + HRESULT CopyTexture(LPRDATA rdPtr, ID3D11Texture2D* source, const D3D11_BOX* crop) { + if (!source) return E_POINTER; + D3D11_TEXTURE2D_DESC from{}; + source->GetDesc(&from); + if (from.MipLevels != 1 || from.ArraySize != 1) return E_NOTIMPL; + const UINT width = crop ? crop->right - crop->left : from.Width; + const UINT height = crop ? crop->bottom - crop->top : from.Height; + if (!width || !height || (crop && (crop->right > from.Width || crop->bottom > from.Height))) + return E_INVALIDARG; + auto candidate = std::unique_ptr(CreateHWASurface(rdPtr, + 32, static_cast(width), static_cast(height), ST_HWA_RTTEXTURE)); + if (!candidate || !candidate->IsValid()) return E_FAIL; + // Fusion.h matrix: RTT native render-target texture is valid only while + // rendering; its m_ppD3D11Texture must not be dereferenced. + { + RenderHelper rendering{candidate.get(), FALSE}; + const auto info = GetD3D11SurfaceInfo(candidate.get()); + if (info.m_nD3DVersion != 11 || !info.m_ppD3D11RenderTargetTexture + || !info.m_pD3D11Context) return E_NOINTERFACE; + auto target = *static_cast(info.m_ppD3D11RenderTargetTexture); + if (!target) return E_POINTER; + Microsoft::WRL::ComPtr sourceDevice; + source->GetDevice(&sourceDevice); + if (sourceDevice.Get() != info.m_pD3D11Device) return E_INVALIDARG; + D3D11_TEXTURE2D_DESC to{}; + target->GetDesc(&to); + if (from.Format != to.Format || to.MipLevels != 1 || to.ArraySize != 1 + || to.Width != width || to.Height != height || to.SampleDesc.Count != 1) + return E_NOTIMPL; + auto context = static_cast(info.m_pD3D11Context); + if (from.SampleDesc.Count > 1) { + if (crop) return E_NOTIMPL; + UINT support = 0; + const auto hr = sourceDevice->CheckFormatSupport(from.Format, &support); + if (FAILED(hr) || !(support & D3D11_FORMAT_SUPPORT_MULTISAMPLE_RESOLVE)) return E_NOTIMPL; + context->ResolveSubresource(target, 0, source, 0, from.Format); + } else { + context->CopySubresourceRegion(target, 0, 0, 0, 0, source, 0, crop); + } + } + snapshot = std::move(candidate); + return S_OK; + } + + bool Update(LPRDATA rdPtr) { + using Microsoft::WRL::ComPtr; + auto rh = rdPtr->rHo.hoAdRunHeader; + ComPtr texture; + D3D11_BOX crop{}; + HRESULT hr = E_PENDING; +#if NEOPIC_CAPTURE_BACKEND == 2 + // Drain WGC every Handle, even without a request, to avoid a startup frame + // sitting in the pool. WgcCapture retains the frame through CopyTexture. + hr = wgc.LatestTexture(texture.GetAddressOf(), &crop); +#endif + if (!pending || rh->rhLoopCount <= requestedLoop) return false; + if (!D3D11(rdPtr)) hr = E_NOTIMPL; + else { +#if NEOPIC_CAPTURE_BACKEND == 1 + // Do not use WinGetSurface as the source: it can be a partial layer. + auto sf = rh->rhFrame->m_pSecondarySurface; + if (sf && sf->IsValid() && sf->GetType() == ST_HWA_RTTEXTURE) { + { + RenderHelper rendering{sf, FALSE}; + const auto info = GetD3D11SurfaceInfo(sf); + if (info.m_nD3DVersion == 11 && info.m_ppD3D11RenderTargetTexture) + texture = *static_cast(info.m_ppD3D11RenderTargetTexture); + } + hr = CopyTexture(rdPtr, texture.Get(), nullptr); + } // A missing secondary is an expected test result; never force flags. +#else + if (SUCCEEDED(hr)) hr = CopyTexture(rdPtr, texture.Get(), &crop); +#endif + } + if (hr == E_PENDING && ++attempts < 120) return false; + pending = false; + Report(hr, SUCCEEDED(hr) ? "ready" : "failed (image unchanged)"); + return SUCCEEDED(hr); + } + + LPSURFACE TakeSurface() { return snapshot.release(); } +}; diff --git a/Extensions/NeoPic/Macro.h b/Extensions/NeoPic/Macro.h index bbdf5ae3e..3fee23d39 100644 --- a/Extensions/NeoPic/Macro.h +++ b/Extensions/NeoPic/Macro.h @@ -6,6 +6,15 @@ // Macro that used in resource file should be defined here +// Experimental capture backends: 0 = existing DC, 1 = Frame secondary, 2 = WGC. +// Set consistently for the entire project and Rebuild after changing it. +#ifndef NEOPIC_CAPTURE_BACKEND +#define NEOPIC_CAPTURE_BACKEND 0 +#endif +#if NEOPIC_CAPTURE_BACKEND < 0 || NEOPIC_CAPTURE_BACKEND > 2 +#error Invalid NEOPIC_CAPTURE_BACKEND +#endif + // only enable when needed to speed up build #define PERSPECTIVE_TRANSFORMATION diff --git a/Extensions/NeoPic/Main.cpp b/Extensions/NeoPic/Main.cpp index a5ffbf861..d713433ed 100644 --- a/Extensions/NeoPic/Main.cpp +++ b/Extensions/NeoPic/Main.cpp @@ -867,10 +867,13 @@ 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); +#if NEOPIC_CAPTURE_BACKEND == 0 + rdPtr->pFrameCapture->Capture(); + LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->pCaptureSf); +#else + // Events run after Handle. Consume the previous completed image next Handle. + rdPtr->pFrameCapture->Request(rdPtr); +#endif return 0; } diff --git a/Extensions/NeoPic/NeoPic.vcxproj b/Extensions/NeoPic/NeoPic.vcxproj index db46ea49e..5d3053b21 100644 --- a/Extensions/NeoPic/NeoPic.vcxproj +++ b/Extensions/NeoPic/NeoPic.vcxproj @@ -255,6 +255,9 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + true + @@ -305,6 +308,9 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + + @@ -319,4 +325,4 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ - \ No newline at end of file + diff --git a/Extensions/NeoPic/Runtime.cpp b/Extensions/NeoPic/Runtime.cpp index da3252c23..f76528fb3 100644 --- a/Extensions/NeoPic/Runtime.cpp +++ b/Extensions/NeoPic/Runtime.cpp @@ -146,7 +146,7 @@ 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 = new FrameCapture(rdPtr); rdPtr->pAI = new AnimationInterface(rdPtr); rdPtr->pNS = new NineSliceInterface(rdPtr); @@ -264,6 +264,13 @@ short WINAPI DLLExport HandleRunObject(LPRDATA rdPtr) At the end of the loop this code will run */ +#if NEOPIC_CAPTURE_BACKEND != 0 + if (rdPtr->pFrameCapture->Update(rdPtr)) { + LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->TakeSurface(), + LoadFromPointerFlags_TakeOwnership); + } +#endif + rdPtr->pAI->UpdateAnimation(); rdPtr->pNS->Render(); diff --git a/Extensions/NeoPic/WgcCapture.cpp b/Extensions/NeoPic/WgcCapture.cpp new file mode 100644 index 000000000..d56b847a3 --- /dev/null +++ b/Extensions/NeoPic/WgcCapture.cpp @@ -0,0 +1,130 @@ +#include "Macro.h" +#if NEOPIC_CAPTURE_BACKEND == 2 +#include "WgcCapture.h" +#include "CaptureCrop.h" +#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; + +HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device) noexcept { + try { + if (!IsWindow(frameWindow) || !device) return E_INVALIDARG; + 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 = GetAncestor(frameWindow, GA_ROOT); + auto factory = winrt::get_activation_factory(); + winrt::check_hresult(factory->CreateForWindow(next->rootWindow, + winrt::guid_of(), winrt::put_abi(next->item))); + 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(); + next->pool = Direct3D11CaptureFramePool::CreateFreeThreaded(next->device, + DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, next->size); + next->session = next->pool.CreateCaptureSession(next->item); + // Default OS capture border/cursor behavior is intentionally retained. + next->session.StartCapture(); + state = std::move(next); + return S_OK; + } catch (...) { state.reset(); return winrt::to_hresult(); } +} + +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(); } +} +#endif diff --git a/Extensions/NeoPic/WgcCapture.h b/Extensions/NeoPic/WgcCapture.h new file mode 100644 index 000000000..2db06f36b --- /dev/null +++ b/Extensions/NeoPic/WgcCapture.h @@ -0,0 +1,20 @@ +#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) noexcept; + // Returned texture remains backed by a retained capture frame until the next + // call/destruction. All methods are called on Fusion's runtime thread. + HRESULT LatestTexture(ID3D11Texture2D** texture, D3D11_BOX* crop) noexcept; +}; diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 new file mode 100644 index 000000000..03cd6b8fb --- /dev/null +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -0,0 +1,23 @@ +param([ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2)) +$ErrorActionPreference = 'Stop' +# Run in an x86 VS2022 Developer PowerShell. No proprietary SDK .lib is needed +# for this syntax-only check; building/linking the actual .mfx 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") +foreach ($backend in $Backends) { + Write-Host "Checking capture backend $backend" + # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. + & cl.exe @common '/Zc:wchar_t' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/WgcCapture.cpp" + if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend" } + foreach ($source in @('Main.cpp', 'Runtime.cpp')) { + & cl.exe @common '/Zc:wchar_t-' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/$source" + if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend" } + } +} From e44b1f928c225e5b53e33364c102b775913a02ee Mon Sep 17 00:00:00 2001 From: Defisym Date: Fri, 6 Nov 2026 17:21:17 +0800 Subject: [PATCH 04/23] NeoPic: gate capture publication and WGC frame release on GPU completion --- Extensions/NeoPic/CaptureCompletion.h | 60 +++++++++++++++++++ Extensions/NeoPic/CaptureTesting.md | 49 ++++++++++++--- Extensions/NeoPic/FrameCaptureDX11.h | 49 ++++++++++++++- Extensions/NeoPic/NeoPic.vcxproj | 1 + Extensions/NeoPic/WgcCapture.cpp | 6 ++ Extensions/NeoPic/WgcCapture.h | 6 +- .../NeoPic/tests/CaptureCompletionTest.cpp | 55 +++++++++++++++++ .../NeoPic/tests/completion-mocks/d3d11.h | 56 +++++++++++++++++ .../tests/completion-mocks/wrl/client.h | 19 ++++++ 9 files changed, 287 insertions(+), 14 deletions(-) create mode 100644 Extensions/NeoPic/CaptureCompletion.h create mode 100644 Extensions/NeoPic/tests/CaptureCompletionTest.cpp create mode 100644 Extensions/NeoPic/tests/completion-mocks/d3d11.h create mode 100644 Extensions/NeoPic/tests/completion-mocks/wrl/client.h diff --git a/Extensions/NeoPic/CaptureCompletion.h b/Extensions/NeoPic/CaptureCompletion.h new file mode 100644 index 000000000..bf3650e1f --- /dev/null +++ b/Extensions/NeoPic/CaptureCompletion.h @@ -0,0 +1,60 @@ +#pragma once +#include +#include + +// An event query marks GPU execution completion, not the validity of the pixels. +// Prepare before issuing a copy; Submit afterwards; Poll once per Handle. +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; + } + + // Object destruction cannot return a borrowed WGC frame while its copy is + // still running. This wait is ONLY for teardown, never the Handle path. + HRESULT WaitForTeardown() const { + HRESULT hr; + while ((hr = Poll()) == S_FALSE) Sleep(1); + return hr; + } + + void Reset() { + submitted = false; + query.Reset(); + context.Reset(); + } +}; diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 40c6581c2..66ef0400b 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -13,7 +13,8 @@ change. The default remains `0`; no ACE IDs or saved object properties change. Modes 1 and 2 require the DX11 renderer. They never fall back to DC. A failed request leaves the current NeoPic image unchanged. Missing frames/secondary surfaces are retried for up to 120 Handle calls; duplicate pending requests are -coalesced. Do not switch Fusion Frames or destroy the object before completion. +coalesced. GPU-completion polling is separate from the source-acquisition retry +limit. Keep the object/Frame alive until completion during normal tests. The experimental image is transferred as an owned Fusion RTT, avoiding the `CreateCloneSurface` path that can read HWA alpha back to CPU. Display/fade uses @@ -57,16 +58,19 @@ For an exported EXE also build `Run_Only Unicode` and install that MFX in 2. Add a normal NeoPic with **HWA enabled**, initially blank and hidden. Keep it in the Frame throughout the test. Press **C once** to invoke Capture Frame Area. Avoid an Always event that repeatedly captures its own displayed image. -3. Wait a few loops (allow WGC roughly one second after startup), then press - **V** to show NeoPic. The result should include the three layer markers. Use +3. Wait for debug output `ready (GPU completed)` (allow WGC roughly one second + after startup), then press **V** to show NeoPic. The result should include the three layer markers. Use NeoPic's normal blend coefficient to test fade. Keep the original scene visible until capture has completed. 4. In a separate **S** event, use the existing save-current-image action with a writable absolute PNG path. This is the only step intended to read pixels back and write the snapshot to disk. Save once with each backend selected. 5. Observe Visual Studio's native Debug output or DebugView for lines beginning - `NeoPic capture backend=`. `start`, `ready`, and `failed` include an HRESULT. - A missing secondary surface should eventually fail, never silently use DC. + `NeoPic capture backend=`. A successful request reports `submitted (GPU + pending)` first, then `ready (GPU completed)` on a later Handle. The latter + requires an event query to return `S_OK` and `done == TRUE`. Failures include + an HRESULT and preserve the previous image. A missing secondary surface + should eventually fail, never silently use DC. Repeat static and moving-counter tests under these configurations: @@ -95,9 +99,23 @@ first-use allocation from repeated captures. This PR makes no measured speed cla - Both experimental sources are copied into independent RTTs using Fusion's DX11 device, with format/dimension/device checks. Unsupported format conversions fail explicitly; no staging texture, Map, LockBuffer, or pixel loop is used. -- WGC capture frames remain retained while their textures are copied. All Fusion - calls/copies happen on the runtime thread; there is no capture callback touching - Fusion. No GPU-idle wait is introduced. +- Both backends allocate a `D3D11_QUERY_EVENT` before issuing a copy. After copy + submission they call `End` and one asynchronous `Flush`. A later Handle polls + once using `D3D11_ASYNC_GETDATA_DONOTFLUSH`; there is no spin/wait in Handle. + `S_FALSE` (or `S_OK` with `done == FALSE`) keeps the request pending. Only GPU + completion publishes the snapshot; errors discard it and keep the old image. +- WGC does not acquire/close another frame while the copy is pending. Both the + source texture and destination RTT remain owned until query completion, and + only then is the borrowed WGC frame returned. All Fusion calls/copies happen + on the runtime thread; there is no capture callback touching Fusion. +- Destruction with an outstanding copy is the one intentional blocking path: + it waits for that event (or a query/device failure) before closing WGC/releasing + the source. This can delay shutdown on a stalled GPU. Normal captures do not + wait, and no CPU bitmap conversion is used to force synchronization. +- Event completion proves prior commands finished, not that the chosen source + contains the desired frame or that a void DX11 copy call was valid. Verify + layer/effect/counter pixels separately and use the DX11 debug layer to diagnose + invalid operations when available. - Device loss/session failure requires recreating the test object/application. The request timeout is a loop count, not a wall-clock deadline. Capture success is currently reported in debug output, not through a new Fusion condition. @@ -109,10 +127,23 @@ c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureCropTest.cpp ./capture-crop-test ``` +The query control-flow regression test compiles the production completion helper +against small mock DX11 interfaces (never included in the plugin build): + +```sh +c++ -std=c++20 -Wall -Wextra -Werror -IExtensions/NeoPic/tests/completion-mocks Extensions/NeoPic/tests/CaptureCompletionTest.cpp -o capture-completion-test +./capture-completion-test +``` + +It checks query allocation failure, deferred-context rejection, single ordered +End/Flush, `S_FALSE`, `S_OK` with false/true `done`, error propagation, query/context reference +release, nonblocking normal polling, and the explicit teardown wait. It does not +simulate a GPU or establish WGC/runtime correctness. + ## Validation status The development environment is Linux and cannot compile MSVC/C++/WinRT or run -Fusion/DWM. The crop tests passed locally with AddressSanitizer and UBSan (leak detection +Fusion/DWM. The crop and mocked completion tests passed locally with AddressSanitizer and UBSan (leak detection disabled because the execution environment uses ptrace). Project XML and source checks passed. The Windows compile script and the manual matrix above are provided for actual validation. Keep the diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index 610754e0e..b22905f28 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -3,6 +3,7 @@ #include #include #include +#include "CaptureCompletion.h" #if NEOPIC_CAPTURE_BACKEND == 2 #include "WgcCapture.h" #endif @@ -10,6 +11,8 @@ // Experimental paths deliberately do not fall back to DC or read pixels to CPU. struct FrameCapture { std::unique_ptr snapshot; + Microsoft::WRL::ComPtr sourceTexture; + CaptureCompletion completion; bool pending = false; int requestedLoop = 0; int attempts = 0; @@ -35,6 +38,23 @@ struct FrameCapture { #endif } + ~FrameCapture() { + if (completion.InFlight()) { + Report(S_OK, "teardown waiting for GPU"); + const HRESULT hr = completion.WaitForTeardown(); + if (FAILED(hr)) Report(hr, "teardown GPU failure"); + } + ReleaseCopySource(); + } + + void ReleaseCopySource() { + sourceTexture.Reset(); +#if NEOPIC_CAPTURE_BACKEND == 2 + wgc.ReleaseCurrent(); +#endif + completion.Reset(); + } + void Request(LPRDATA rdPtr) { if (pending) return; pending = true; @@ -79,17 +99,38 @@ struct FrameCapture { UINT support = 0; const auto hr = sourceDevice->CheckFormatSupport(from.Format, &support); if (FAILED(hr) || !(support & D3D11_FORMAT_SUPPORT_MULTISAMPLE_RESOLVE)) return E_NOTIMPL; + } + // Query allocation can fail: do it before submitting any GPU copy. + const HRESULT prepared = completion.Prepare(sourceDevice.Get(), context); + if (FAILED(prepared)) return prepared; + if (from.SampleDesc.Count > 1) { context->ResolveSubresource(target, 0, source, 0, from.Format); } else { context->CopySubresourceRegion(target, 0, 0, 0, 0, source, 0, crop); } } snapshot = std::move(candidate); + sourceTexture = source; + completion.Submit(); + Report(S_OK, "submitted (GPU pending)"); return S_OK; } bool Update(LPRDATA rdPtr) { using Microsoft::WRL::ComPtr; + // Do not acquire another WGC frame, release the source, or publish the + // destination until the event says its GPU copy has actually finished. + if (completion.InFlight()) { + const HRESULT hr = completion.Poll(); + if (hr == S_FALSE) return false; + pending = false; + if (FAILED(hr)) { + Report(hr, "GPU completion failed (image unchanged)"); + snapshot.reset(); + } else Report(S_OK, "ready (GPU completed)"); + ReleaseCopySource(); + return SUCCEEDED(hr); + } auto rh = rdPtr->rHo.hoAdRunHeader; ComPtr texture; D3D11_BOX crop{}; @@ -118,11 +159,13 @@ struct FrameCapture { if (SUCCEEDED(hr)) hr = CopyTexture(rdPtr, texture.Get(), &crop); #endif } + if (SUCCEEDED(hr)) return false; // submitted; poll on a later Handle if (hr == E_PENDING && ++attempts < 120) return false; pending = false; - Report(hr, SUCCEEDED(hr) ? "ready" : "failed (image unchanged)"); - return SUCCEEDED(hr); + ReleaseCopySource(); + Report(hr, "failed (image unchanged)"); + return false; } - LPSURFACE TakeSurface() { return snapshot.release(); } + LPSURFACE TakeSurface() { return completion.InFlight() ? nullptr : snapshot.release(); } }; diff --git a/Extensions/NeoPic/NeoPic.vcxproj b/Extensions/NeoPic/NeoPic.vcxproj index 5d3053b21..04c1d3e2e 100644 --- a/Extensions/NeoPic/NeoPic.vcxproj +++ b/Extensions/NeoPic/NeoPic.vcxproj @@ -310,6 +310,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + diff --git a/Extensions/NeoPic/WgcCapture.cpp b/Extensions/NeoPic/WgcCapture.cpp index d56b847a3..469f478f4 100644 --- a/Extensions/NeoPic/WgcCapture.cpp +++ b/Extensions/NeoPic/WgcCapture.cpp @@ -75,6 +75,12 @@ HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device) noexcept { } catch (...) { state.reset(); return winrt::to_hresult(); } } +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; diff --git a/Extensions/NeoPic/WgcCapture.h b/Extensions/NeoPic/WgcCapture.h index 2db06f36b..05719beb6 100644 --- a/Extensions/NeoPic/WgcCapture.h +++ b/Extensions/NeoPic/WgcCapture.h @@ -14,7 +14,9 @@ class WgcCapture { WgcCapture(const WgcCapture&) = delete; WgcCapture& operator=(const WgcCapture&) = delete; HRESULT Start(HWND frameWindow, ID3D11Device* device) noexcept; - // Returned texture remains backed by a retained capture frame until the next - // call/destruction. All methods are called on Fusion's runtime thread. + // 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/CaptureCompletionTest.cpp b/Extensions/NeoPic/tests/CaptureCompletionTest.cpp new file mode 100644 index 000000000..fea957a7a --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureCompletionTest.cpp @@ -0,0 +1,55 @@ +// 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.WaitForTeardown() == S_OK); + assert(mockSleeps == 1); // the explicit teardown method is the only wait + completion.Reset(); +} diff --git a/Extensions/NeoPic/tests/completion-mocks/d3d11.h b/Extensions/NeoPic/tests/completion-mocks/d3d11.h new file mode 100644 index 000000000..75a2a424f --- /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 000000000..b98845eed --- /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; } +}; +} From 000642a8b303d1a28be37361c29248c728091658 Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 22:21:07 +0800 Subject: [PATCH 05/23] NeoPic: capture synchronously through Fusion Surface geometry --- Extensions/NeoPic/CaptureCompletion.h | 10 +- Extensions/NeoPic/FrameCaptureDX11.h | 181 +++++++++++------- Extensions/NeoPic/Main.cpp | 6 +- Extensions/NeoPic/Runtime.cpp | 7 +- .../NeoPic/tests/CaptureCompletionTest.cpp | 12 +- 5 files changed, 138 insertions(+), 78 deletions(-) diff --git a/Extensions/NeoPic/CaptureCompletion.h b/Extensions/NeoPic/CaptureCompletion.h index bf3650e1f..d1d3ce141 100644 --- a/Extensions/NeoPic/CaptureCompletion.h +++ b/Extensions/NeoPic/CaptureCompletion.h @@ -3,7 +3,7 @@ #include // An event query marks GPU execution completion, not the validity of the pixels. -// Prepare before issuing a copy; Submit afterwards; Poll once per Handle. +// Prepare before issuing GPU work; Submit afterwards; Wait for synchronous capture. class CaptureCompletion { Microsoft::WRL::ComPtr context; Microsoft::WRL::ComPtr query; @@ -44,14 +44,16 @@ class CaptureCompletion { return E_UNEXPECTED; } - // Object destruction cannot return a borrowed WGC frame while its copy is - // still running. This wait is ONLY for teardown, never the Handle path. - HRESULT WaitForTeardown() const { + // 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(); diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index b22905f28..4ea974165 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -3,6 +3,7 @@ #include #include #include +#include #include "CaptureCompletion.h" #if NEOPIC_CAPTURE_BACKEND == 2 #include "WgcCapture.h" @@ -13,9 +14,10 @@ struct FrameCapture { std::unique_ptr snapshot; Microsoft::WRL::ComPtr sourceTexture; CaptureCompletion completion; - bool pending = false; - int requestedLoop = 0; - int attempts = 0; +#if NEOPIC_CAPTURE_BACKEND == 2 + bool wgcStarted = false; + bool startAttempted = false; +#endif #if NEOPIC_CAPTURE_BACKEND == 2 WgcCapture wgc; #endif @@ -27,16 +29,7 @@ struct FrameCapture { OutputDebugStringA(message); } - explicit FrameCapture(LPRDATA rdPtr) { -#if NEOPIC_CAPTURE_BACKEND == 2 - if (D3D11(rdPtr)) { - auto device = static_cast(GetD3D11Device(rdPtr)); - Report(wgc.Start(rdPtr->rHo.hoAdRunHeader->rhHEditWin, device), "start"); - } else Report(E_NOTIMPL, "DX11-required"); -#else - (void)rdPtr; -#endif - } + explicit FrameCapture(LPRDATA rdPtr) { (void)rdPtr; } ~FrameCapture() { if (completion.InFlight()) { @@ -55,11 +48,87 @@ struct FrameCapture { completion.Reset(); } - void Request(LPRDATA rdPtr) { - if (pending) return; - pending = true; - attempts = 0; - requestedLoop = rdPtr->rHo.hoAdRunHeader->rhLoopCount; +#if NEOPIC_CAPTURE_BACKEND == 2 + HRESULT StartWgc(LPRDATA rdPtr, bool retry) { + if (wgcStarted) 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))); + wgcStarted = SUCCEEDED(hr); + Report(hr, "start"); + return hr; + } + + // Warm the asynchronous WGC producer, but never schedule a screenshot here. + void Update(LPRDATA rdPtr) { + if (FAILED(StartWgc(rdPtr, false))) return; + Microsoft::WRL::ComPtr ignored; + D3D11_BOX crop{}; + wgc.LatestTexture(ignored.GetAddressOf(), &crop); + } +#endif + + // Secondary is a Fusion Surface, not a raw texture. Let Fusion's GPU blit + // interpret its type, logical dimensions, UV/padding and pixel format. + HRESULT CopySecondary(LPRDATA rdPtr) { + auto rh = rdPtr->rHo.hoAdRunHeader; + auto frame = rh->rhFrame; + auto source = frame->m_pSecondarySurface; + if (!source || !source->IsValid()) return E_PENDING; + if (source->GetDriver() != SD_D3D11 || source->GetType() != ST_HWA_RTTEXTURE) 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); + 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; + // 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 + || sourceWidth != rh->rh3.rh3WindowSx || sourceHeight != rh->rh3.rh3WindowSy + || static_cast(width) * sourceHeight != static_cast(height) * sourceWidth) { + 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; + } + auto candidate = std::unique_ptr(CreateHWASurface(rdPtr, 32, width, height, ST_HWA_RTTEXTURE)); + if (!candidate || !candidate->IsValid()) return E_FAIL; + BOOL drawn = FALSE; + { + RenderHelper rendering{candidate.get(), TRUE, COLORREFATORGBA(0, 255)}; + const auto info = GetD3D11SurfaceInfo(candidate.get()); + if (info.m_nD3DVersion != 11 || !info.m_pD3D11Device || !info.m_pD3D11Context) return E_NOINTERFACE; + const HRESULT hr = completion.Prepare(static_cast(info.m_pD3D11Device), + static_cast(info.m_pD3D11Context)); + if (FAILED(hr)) return hr; + POINT center{0, 0}; + const DWORD stretchFlags = (rh->rhApp->m_pHdr2 + && (rh->rhApp->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH)) ? STRF_RESAMPLE : 0; + drawn = source->BlitEx(*candidate, 0.0f, 0.0f, + static_cast(width) / sourceWidth, static_cast(height) / sourceHeight, + 0, 0, sourceWidth, sourceHeight, ¢er, 0.0f, BMODE_OPAQUE, BOP_COPY, 0, stretchFlags); + } + snapshot = std::move(candidate); + completion.Submit(); // even failed blits may have submitted GPU work + return drawn ? S_OK : E_FAIL; } // Texture formats/sample counts must agree; CopyResource cannot resize or @@ -71,7 +140,8 @@ struct FrameCapture { if (from.MipLevels != 1 || from.ArraySize != 1) return E_NOTIMPL; const UINT width = crop ? crop->right - crop->left : from.Width; const UINT height = crop ? crop->bottom - crop->top : from.Height; - if (!width || !height || (crop && (crop->right > from.Width || crop->bottom > from.Height))) + if (!width || !height || (crop && (crop->left >= crop->right || crop->top >= crop->bottom + || crop->front != 0 || crop->back != 1 || crop->right > from.Width || crop->bottom > from.Height))) return E_INVALIDARG; auto candidate = std::unique_ptr(CreateHWASurface(rdPtr, 32, static_cast(width), static_cast(height), ST_HWA_RTTEXTURE)); @@ -90,12 +160,16 @@ struct FrameCapture { if (sourceDevice.Get() != info.m_pD3D11Device) return E_INVALIDARG; D3D11_TEXTURE2D_DESC to{}; target->GetDesc(&to); + char details[256]; + sprintf_s(details, "NeoPic WGC native-source=%ux%u format=%u client=%ux%u native-target=%ux%u format=%u\n", + from.Width, from.Height, unsigned(from.Format), width, height, to.Width, to.Height, unsigned(to.Format)); + OutputDebugStringA(details); if (from.Format != to.Format || to.MipLevels != 1 || to.ArraySize != 1 - || to.Width != width || to.Height != height || to.SampleDesc.Count != 1) + || to.Width < width || to.Height < height || to.SampleDesc.Count != 1) return E_NOTIMPL; auto context = static_cast(info.m_pD3D11Context); if (from.SampleDesc.Count > 1) { - if (crop) return E_NOTIMPL; + if (crop || from.Width != to.Width || from.Height != to.Height) return E_NOTIMPL; UINT support = 0; const auto hr = sourceDevice->CheckFormatSupport(from.Format, &support); if (FAILED(hr) || !(support & D3D11_FORMAT_SUPPORT_MULTISAMPLE_RESOLVE)) return E_NOTIMPL; @@ -116,55 +190,32 @@ struct FrameCapture { return S_OK; } - bool Update(LPRDATA rdPtr) { - using Microsoft::WRL::ComPtr; - // Do not acquire another WGC frame, release the source, or publish the - // destination until the event says its GPU copy has actually finished. - if (completion.InFlight()) { - const HRESULT hr = completion.Poll(); - if (hr == S_FALSE) return false; - pending = false; - if (FAILED(hr)) { - Report(hr, "GPU completion failed (image unchanged)"); - snapshot.reset(); - } else Report(S_OK, "ready (GPU completed)"); - ReleaseCopySource(); - return SUCCEEDED(hr); - } - auto rh = rdPtr->rHo.hoAdRunHeader; - ComPtr texture; - D3D11_BOX crop{}; - HRESULT hr = E_PENDING; -#if NEOPIC_CAPTURE_BACKEND == 2 - // Drain WGC every Handle, even without a request, to avoid a startup frame - // sitting in the pool. WgcCapture retains the frame through CopyTexture. - hr = wgc.LatestTexture(texture.GetAddressOf(), &crop); -#endif - if (!pending || rh->rhLoopCount <= requestedLoop) return false; - if (!D3D11(rdPtr)) hr = E_NOTIMPL; - else { + // Synchronous action contract: never defer publication to another Handle. + // A ready WGC image can still be older than the current compositor frame. + bool Capture(LPRDATA rdPtr) { + snapshot.reset(); + HRESULT hr = E_NOTIMPL; + if (D3D11(rdPtr)) { #if NEOPIC_CAPTURE_BACKEND == 1 - // Do not use WinGetSurface as the source: it can be a partial layer. - auto sf = rh->rhFrame->m_pSecondarySurface; - if (sf && sf->IsValid() && sf->GetType() == ST_HWA_RTTEXTURE) { - { - RenderHelper rendering{sf, FALSE}; - const auto info = GetD3D11SurfaceInfo(sf); - if (info.m_nD3DVersion == 11 && info.m_ppD3D11RenderTargetTexture) - texture = *static_cast(info.m_ppD3D11RenderTargetTexture); - } - hr = CopyTexture(rdPtr, texture.Get(), nullptr); - } // A missing secondary is an expected test result; never force flags. + hr = CopySecondary(rdPtr); #else - if (SUCCEEDED(hr)) hr = CopyTexture(rdPtr, texture.Get(), &crop); + hr = StartWgc(rdPtr, true); + if (SUCCEEDED(hr)) { + Microsoft::WRL::ComPtr texture; + D3D11_BOX crop{}; + hr = wgc.LatestTexture(texture.GetAddressOf(), &crop); + if (SUCCEEDED(hr)) hr = CopyTexture(rdPtr, texture.Get(), &crop); + } #endif } - if (SUCCEEDED(hr)) return false; // submitted; poll on a later Handle - if (hr == E_PENDING && ++attempts < 120) return false; - pending = false; + if (completion.InFlight()) { + const HRESULT finished = completion.Wait(); + if (FAILED(finished)) hr = finished; + } ReleaseCopySource(); - Report(hr, "failed (image unchanged)"); - return false; + if (FAILED(hr)) snapshot.reset(); + Report(hr, SUCCEEDED(hr) ? "ready (synchronous GPU completion)" : "failed (image unchanged)"); + return SUCCEEDED(hr); } LPSURFACE TakeSurface() { return completion.InFlight() ? nullptr : snapshot.release(); } diff --git a/Extensions/NeoPic/Main.cpp b/Extensions/NeoPic/Main.cpp index d713433ed..57dc15849 100644 --- a/Extensions/NeoPic/Main.cpp +++ b/Extensions/NeoPic/Main.cpp @@ -871,8 +871,10 @@ short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long rdPtr->pFrameCapture->Capture(); LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->pCaptureSf); #else - // Events run after Handle. Consume the previous completed image next Handle. - rdPtr->pFrameCapture->Request(rdPtr); + if (rdPtr->pFrameCapture->Capture(rdPtr)) { + LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->TakeSurface(), + LoadFromPointerFlags_TakeOwnership); + } #endif return 0; diff --git a/Extensions/NeoPic/Runtime.cpp b/Extensions/NeoPic/Runtime.cpp index f76528fb3..3a529aaa3 100644 --- a/Extensions/NeoPic/Runtime.cpp +++ b/Extensions/NeoPic/Runtime.cpp @@ -264,11 +264,8 @@ short WINAPI DLLExport HandleRunObject(LPRDATA rdPtr) At the end of the loop this code will run */ -#if NEOPIC_CAPTURE_BACKEND != 0 - if (rdPtr->pFrameCapture->Update(rdPtr)) { - LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->TakeSurface(), - LoadFromPointerFlags_TakeOwnership); - } +#if NEOPIC_CAPTURE_BACKEND == 2 + rdPtr->pFrameCapture->Update(rdPtr); // warm WGC only; no deferred capture #endif rdPtr->pAI->UpdateAnimation(); diff --git a/Extensions/NeoPic/tests/CaptureCompletionTest.cpp b/Extensions/NeoPic/tests/CaptureCompletionTest.cpp index fea957a7a..205a71a59 100644 --- a/Extensions/NeoPic/tests/CaptureCompletionTest.cpp +++ b/Extensions/NeoPic/tests/CaptureCompletionTest.cpp @@ -49,7 +49,15 @@ int main() { completion.Submit(); context.polls = 0; context.replies = {{S_FALSE, FALSE}, {S_OK, TRUE}}; - assert(completion.WaitForTeardown() == S_OK); - assert(mockSleeps == 1); // the explicit teardown method is the only wait + 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(); } From 286e093845edd69306267fbb471f88db4f8323ff Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 22:21:29 +0800 Subject: [PATCH 06/23] NeoPic: diagnose WGC startup failures and document capture limits --- Extensions/NeoPic/CaptureTesting.md | 241 +++++++++++++--------------- Extensions/NeoPic/WgcCapture.cpp | 39 ++++- 2 files changed, 146 insertions(+), 134 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 66ef0400b..e82ceb6d3 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -1,150 +1,133 @@ -# Experimental DX11 frame capture +# Experimental DX11 capture of Fusion's child client area -The existing **Capture Frame Area** action can test three backends. Select -`NEOPIC_CAPTURE_BACKEND` in `Macro.h` and **Rebuild the whole project** after each -change. The default remains `0`; no ACE IDs or saved object properties change. +Select `NEOPIC_CAPTURE_BACKEND` in `Macro.h` and **Rebuild the whole project**. +The default is `0`. Existing ACE IDs and saved object properties are unchanged. -| Value | Source | Action behavior | +| Value | Source | Behavior | | --- | --- | --- | -| `0` | Existing window DC | Existing synchronous capture | -| `1` | `rhFrame->m_pSecondarySurface` | Request in events; GPU copy in a subsequent Handle | -| `2` | Windows Graphics Capture | Session starts at object creation; copy the latest available window frame in a subsequent Handle | - -Modes 1 and 2 require the DX11 renderer. They never fall back to DC. A failed -request leaves the current NeoPic image unchanged. Missing frames/secondary -surfaces are retried for up to 120 Handle calls; duplicate pending requests are -coalesced. GPU-completion polling is separate from the source-acquisition retry -limit. Keep the object/Frame alive until completion during normal tests. - -The experimental image is transferred as an owned Fusion RTT, avoiding the -`CreateCloneSurface` path that can read HWA alpha back to CPU. Display/fade uses -the normal NeoPic image and ink effects. Existing save actions perform CPU -readback only when explicitly requested. These test backends are asynchronous: -a save action immediately following Capture still sees the previous image. +| `0` | Existing DC of `rhHEditWin` | Original baseline | +| `1` | Frame secondary Surface | Synchronous Fusion GPU Surface blit, with conservative final-composition checks | +| `2` | Windows Graphics Capture | Synchronous copy of an already available WGC frame, cropped to `rhHEditWin`'s client area | + +## Required contract and current limits + +A successful Capture Frame Area action must finish its GPU operation and load the +result through `LoadFromPointer(..., L"_TempCapture", ..., TakeOwnership)` **before +returning**. Show or Save may follow in the same event. No screenshot request is +deferred to another Handle. The action waits on a DX11 event query after one +asynchronous Flush; CPU bitmap conversion is not used to force completion. This +wait can cost time and is not a zero-latency/real-time deadline guarantee. + +The output area is the child client area of the same `rhHEditWin` used by the DC +baseline, not the whole top-level window. WGC must capture the root window because +of its API, then crops to that child's physical client rectangle. + +**WGC cannot guarantee the exact current Fusion Display frame.** Waiting for the +GPU copy does not make an older compositor image fresh. Its producer is inherently +asynchronous, so mode 2 remains an experiment rather than a solution to a strict +current-display-frame requirement. If no frame is already available, the action +fails immediately with `E_PENDING`; it does not wait for future frame arrival. + +Secondary is also not a documented final-window buffer. Mode 1 rejects a missing +or non-RTT source, a logical size different from the runtime viewport, nonzero +origin, an aspect ratio mismatch, Frame effects, or nonidentity Frame transforms whose final composition +cannot be established. These are explicit failures, not a fallback to DC and not +proof of support for every rendering configuration. A default scene may have no +secondary Surface at all. Results still need Windows validation. + +## Changes after the first Windows test + +The first mode 1 implementation allocated its output from a native texture's +`GetDesc()` size and directly copied its storage. That did not establish the +mapping from the Fusion Surface's logical image to its underlying allocation. +The user reported fragmented images. The exact cause is not established yet. + +Mode 1 now uses `source->GetWidth()/GetHeight()` and Fusion's own GPU `BlitEx` +pass to interpret Surface type, format, internal UV/orientation/padding and scale +the accepted viewport to the child client size. It no longer dereferences the +secondary's native texture or assumes its storage describes the image layout. +Unsupported composition is rejected rather than misrepresented as a screenshot. + +Mode 2 still copies only matching native formats. It logs source/target native +sizes and formats and validates its source crop. The destination may have a +larger padded allocation than its logical image; only the logical client region +is written. It does not implement general format conversion or YUV decoding. + +WGC initialization previously ran in CreateRunObject. A reported VS exception at +`check_hresult(CreateForWindow(...))` was caught, but session startup failed and +no image was captured. Startup is now attempted from Handle only after the child +and root are visible; an explicit action may retry. CreateForWindow failures are +returned directly and logged with the HRESULT, child/root HWNDs and visibility. +Other WinRT failures are caught and logged with their initialization stage. +A debugger can still break on first-chance exceptions inside WinRT before a catch; +continuing and reading the failure log distinguishes this from an unhandled exit. ## Build -Use VS2022, Win32, and the existing Fusion SDK/dependencies. WGC additionally -needs a current Windows SDK with C++/WinRT headers (10.0.19041 or newer is a -suitable baseline) and Windows 10 1903 or newer on the test machine. -`WgcCapture.cpp` uses native `wchar_t` in isolation, while the other project -sources retain `/Zc:wchar_t-`. C++/WinRT does not cross that translation-unit ABI. -WGC links `windowsapp.lib`, `runtimeobject.lib`, `d3d11.lib`, and `dwmapi.lib`; those dependencies are excluded in -modes 0 and 1. +Use VS2022, Win32, the existing SDK/dependencies and Fusion 296 / DX11. +WGC needs Windows 10 1903+ and a current Windows SDK with C++/WinRT headers +(10.0.19041+ is suitable). Its implementation uses native wchar_t in a separate +translation unit; the Fusion sources retain `/Zc:wchar_t-`. -From an x86 VS2022 Developer PowerShell, first check all three configurations: +From an x86 VS2022 Developer PowerShell in the repository root: ```powershell ./Extensions/NeoPic/tests/CompileCapture.ps1 -``` - -This runs MSVC syntax checks against the real `Main.cpp`, `Runtime.cpp`, and -`WgcCapture.cpp`; it does not link an MFX or prove runtime capture correctness. -Then set the selected macro in `Macro.h` and build: - -```powershell msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 ``` -Close Fusion, back up the installed NeoPic MFX, and install the resulting -`Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` in `Extensions/Unicode`. -For an exported EXE also build `Run_Only Unicode` and install that MFX in -`Data/Runtime/Unicode`. The source-only PR does not include new MFX binaries. - -## Minimal test scene (Fusion 296 / DX11) - -1. Create a fixed-size Frame with three visibly labelled layers (`L1`, `L2`, - `L3`), different colored Active objects, and a counter updated every loop. -2. Add a normal NeoPic with **HWA enabled**, initially blank and hidden. Keep it - in the Frame throughout the test. Press **C once** to invoke Capture Frame - Area. Avoid an Always event that repeatedly captures its own displayed image. -3. Wait for debug output `ready (GPU completed)` (allow WGC roughly one second - after startup), then press **V** to show NeoPic. The result should include the three layer markers. Use - NeoPic's normal blend coefficient to test fade. Keep the original scene - visible until capture has completed. -4. In a separate **S** event, use the existing save-current-image action with - a writable absolute PNG path. This is the only step intended to read pixels - back and write the snapshot to disk. Save once with each backend selected. -5. Observe Visual Studio's native Debug output or DebugView for lines beginning - `NeoPic capture backend=`. A successful request reports `submitted (GPU - pending)` first, then `ready (GPU completed)` on a later Handle. The latter - requires an event query to return `S_OK` and `done == TRUE`. Failures include - an HRESULT and preserve the previous image. A missing secondary surface - should eventually fail, never silently use DC. - -Repeat static and moving-counter tests under these configurations: - -- No layer/Frame effects and no transitions (a missing secondary is possible). -- An obvious layer effect plus an obvious Frame effect, with their names noted. -- A configured Frame transition; first capture while staying in the same Frame. -- WGC: partially cover Fusion with another window, resize, minimize/restore, and - move between monitors at different DPI scales. Check the client-area crop, - borders, menu exclusion and image orientation. A minimized window may time out. - -Record Windows/Fusion build, GPU/driver, backend macro, VSync/backbuffer settings, -effects, screenshots, and debug output. Use the visible counter to compare frame -age; WGC is not synchronized with a particular Fusion Display call. For timing, -compare one-shot captures at the same resolution without saving files; separate -first-use allocation from repeated captures. This PR makes no measured speed claim. - -## Expected limits - -- Secondary Surface availability, contents and inclusion of Frame effects are - deliberately under test. No runtime booleans or backbuffer counts are modified. - `m_pOldSecondarySurface` is not treated as the previous display frame. -- WGC captures the root window, then maps the Fusion child client rectangle from - physical DWM bounds to capture coordinates. Resize/DPI mapping needs real tests. - Its default OS border/cursor behavior is retained; it does not capture native - child controls into a Fusion-only scene texture separately. -- Both experimental sources are copied into independent RTTs using Fusion's DX11 - device, with format/dimension/device checks. Unsupported format conversions - fail explicitly; no staging texture, Map, LockBuffer, or pixel loop is used. -- Both backends allocate a `D3D11_QUERY_EVENT` before issuing a copy. After copy - submission they call `End` and one asynchronous `Flush`. A later Handle polls - once using `D3D11_ASYNC_GETDATA_DONOTFLUSH`; there is no spin/wait in Handle. - `S_FALSE` (or `S_OK` with `done == FALSE`) keeps the request pending. Only GPU - completion publishes the snapshot; errors discard it and keep the old image. -- WGC does not acquire/close another frame while the copy is pending. Both the - source texture and destination RTT remain owned until query completion, and - only then is the borrowed WGC frame returned. All Fusion calls/copies happen - on the runtime thread; there is no capture callback touching Fusion. -- Destruction with an outstanding copy is the one intentional blocking path: - it waits for that event (or a query/device failure) before closing WGC/releasing - the source. This can delay shutdown on a stalled GPU. Normal captures do not - wait, and no CPU bitmap conversion is used to force synchronization. -- Event completion proves prior commands finished, not that the chosen source - contains the desired frame or that a void DX11 copy call was valid. Verify - layer/effect/counter pixels separately and use the DX11 debug layer to diagnose - invalid operations when available. -- Device loss/session failure requires recreating the test object/application. - The request timeout is a loop count, not a wall-clock deadline. Capture success - is currently reported in debug output, not through a new Fusion condition. - -The client-crop arithmetic also has a portable test: +The first command syntax-checks all three backend configurations. After that, +set the desired macro in Macro.h and Rebuild. Close Fusion, back up the installed +NeoPic MFX, then install `Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` into +Fusion's `Extensions/Unicode`. For exported EXEs also build Run_Only Unicode and +install that MFX into `Data/Runtime/Unicode`. No new MFX is included in this PR. + +## Minimal Windows retest + +1. Keep the same application, window, resolution and effects that reproduced the + issue. Capture mode 0 once as a baseline. Test mode 1 and mode 2 separately. +2. Enable HWA on a normal NeoPic; keep its previous image hidden while capturing + to avoid recursively including it. Add visible markers near each edge of the + middle child area, plus different layer labels and an animated counter. +3. On one keypress, call Capture Frame Area followed immediately by the existing + Save-current-image action with an absolute PNG path. A successful request now + publishes `_TempCapture` during that same action. On failure the old image is + preserved; do not mistake a file of the old image for a successful screenshot. +4. Observe VS native debug output or DebugView. Success ends with + `ready (synchronous GPU completion)`. Failure includes an HRESULT. For mode 1 + send the `secondary logical=... viewport=... rhHEditWin-client=...` line. For + mode 2 send any `WGC CreateForWindow failed ...`/other stage line and the + `native-source=... format=... client=... native-target=... format=...` line. +5. Compare saved PNG content to the middle child area: no title/menu/outer frame, + no missing edges/layers, no fragmented pixels. Record image dimensions too. + When showing the image inside the same scene, global Frame effects may apply + again; use the PNG to distinguish a capture problem from a second effect pass. +6. Let the original scene continue animating. The owned snapshot must remain + unchanged and support the usual NeoPic fade. Note that an event executes + before its subsequent Display: it cannot capture changes that have not yet + been rendered. WGC additionally has compositor/capture latency. + +Return the logs, baseline/experimental PNGs, Windows version, GPU/driver and +actual window/DPI/effect settings. For a debugger exception, the HRESULT is the +error code (e.g. 0x80070057); the KernelBase instruction address is not the HRESULT. + +## Regression tests and validation boundaries ```sh c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureCropTest.cpp -o capture-crop-test ./capture-crop-test -``` - -The query control-flow regression test compiles the production completion helper -against small mock DX11 interfaces (never included in the plugin build): - -```sh c++ -std=c++20 -Wall -Wextra -Werror -IExtensions/NeoPic/tests/completion-mocks Extensions/NeoPic/tests/CaptureCompletionTest.cpp -o capture-completion-test ./capture-completion-test ``` -It checks query allocation failure, deferred-context rejection, single ordered -End/Flush, `S_FALSE`, `S_OK` with false/true `done`, error propagation, query/context reference -release, nonblocking normal polling, and the explicit teardown wait. It does not -simulate a GPU or establish WGC/runtime correctness. - -## Validation status - -The development environment is Linux and cannot compile MSVC/C++/WinRT or run -Fusion/DWM. The crop and mocked completion tests passed locally with AddressSanitizer and UBSan (leak detection -disabled because the execution environment uses ptrace). Project XML and source -checks passed. The Windows compile -script and the manual matrix above are provided for actual validation. Keep the -PR experimental until those results are available. +Crop tests cover client mapping, scaling and negative monitor coordinates. +Completion tests exercise the production helper against small mock interfaces: +query allocation, immediate-context checks, ordered End/Flush, incomplete/complete +results, synchronous waiting, teardown and errors. Mocks are never on the plugin +include path and do not emulate a real GPU. + +The Linux development environment cannot compile/link MSVC or run Fusion/WGC. +Portable tests pass with GCC, AddressSanitizer and UBSan (leak detection disabled +under ptrace). Windows syntax checks, real pixels, initialization and performance +remain unverified. The reported fragmentation and startup failure are not claimed +fixed until a real Windows retest succeeds. No speed improvement is claimed. diff --git a/Extensions/NeoPic/WgcCapture.cpp b/Extensions/NeoPic/WgcCapture.cpp index 469f478f4..a001488d2 100644 --- a/Extensions/NeoPic/WgcCapture.cpp +++ b/Extensions/NeoPic/WgcCapture.cpp @@ -3,6 +3,7 @@ #include "WgcCapture.h" #include "CaptureCrop.h" #include +#include #include #include #include @@ -45,19 +46,39 @@ struct WgcCapture::State { 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) noexcept { + const HWND root = GetAncestor(frameWindow, GA_ROOT); + const char* stage = "initialize"; try { - if (!IsWindow(frameWindow) || !device) return E_INVALIDARG; + 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 = GetAncestor(frameWindow, GA_ROOT); + next->rootWindow = root; + stage = "GraphicsCaptureItem factory"; auto factory = winrt::get_activation_factory(); - winrt::check_hresult(factory->CreateForWindow(next->rootWindow, - winrt::guid_of(), winrt::put_abi(next->item))); + 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)) { + ReportWgcFailure(created, stage, frameWindow, root); + return created; + } + stage = "DXGI device interop"; auto dxgi = winrt::com_ptr{}; dxgi.copy_from(device); auto dxgiDevice = dxgi.as(); @@ -65,14 +86,22 @@ HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device) noexcept { 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 (...) { state.reset(); return winrt::to_hresult(); } + } catch (...) { + const HRESULT hr = winrt::to_hresult(); + ReportWgcFailure(hr, stage, frameWindow, root); + state.reset(); + return hr; + } } void WgcCapture::ReleaseCurrent() noexcept { From 620fef6a0b05ae13d27b0bd94d9adea79850ec84 Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 22:31:53 +0800 Subject: [PATCH 07/23] NeoPic: fix RGBAREF narrowing and isolate capture backend compilation --- Extensions/NeoPic/CaptureTesting.md | 5 ++++- Extensions/NeoPic/FrameCaptureDX11.h | 19 ++++++++++++++++++- .../NeoPic/tests/CaptureHeaderCompile.cpp | 3 +++ Extensions/NeoPic/tests/CompileCapture.ps1 | 2 +- 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 Extensions/NeoPic/tests/CaptureHeaderCompile.cpp diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index e82ceb6d3..94a057bc1 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -76,7 +76,10 @@ From an x86 VS2022 Developer PowerShell in the repository root: msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 ``` -The first command syntax-checks all three backend configurations. After that, +The first command syntax-checks all three backend configurations, including a +standalone capture-header translation unit to verify the Fusion preamble is +available. Secondary-only implementation is excluded from the WGC configuration, +and the opaque clear value is explicitly typed as RGBAREF to avoid C2398. After that, set the desired macro in Macro.h and Rebuild. Close Fusion, back up the installed NeoPic MFX, then install `Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` into Fusion's `Extensions/Unicode`. For exported EXEs also build Run_Only Unicode and diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index 4ea974165..a5db7519e 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -1,5 +1,11 @@ #pragma once +// The capture adapter uses the NeoPic/Fusion SDK preamble. Keep WgcCapture.cpp +// independent: it includes WgcCapture.h, never this legacy-wchar_t adapter. +#include "Common.h" + +#if NEOPIC_CAPTURE_BACKEND != 0 + #include #include #include @@ -72,6 +78,7 @@ struct FrameCapture { } #endif +#if NEOPIC_CAPTURE_BACKEND == 1 // Secondary is a Fusion Surface, not a raw texture. Let Fusion's GPU blit // interpret its type, logical dimensions, UV/padding and pixel format. HRESULT CopySecondary(LPRDATA rdPtr) { @@ -113,7 +120,10 @@ struct FrameCapture { if (!candidate || !candidate->IsValid()) return E_FAIL; BOOL drawn = FALSE; { - RenderHelper rendering{candidate.get(), TRUE, COLORREFATORGBA(0, 255)}; + // COLORREFATORGBA(0, 255) is a signed int expression in the SDK; + // brace initialization into RGBAREF rejects its narrowing conversion. + 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) return E_NOINTERFACE; const HRESULT hr = completion.Prepare(static_cast(info.m_pD3D11Device), @@ -131,6 +141,9 @@ struct FrameCapture { return drawn ? S_OK : E_FAIL; } +#endif + +#if NEOPIC_CAPTURE_BACKEND == 2 // Texture formats/sample counts must agree; CopyResource cannot resize or // convert. The WGC path supplies a client-area crop in source coordinates. HRESULT CopyTexture(LPRDATA rdPtr, ID3D11Texture2D* source, const D3D11_BOX* crop) { @@ -190,6 +203,8 @@ struct FrameCapture { return S_OK; } +#endif + // Synchronous action contract: never defer publication to another Handle. // A ready WGC image can still be older than the current compositor frame. bool Capture(LPRDATA rdPtr) { @@ -220,3 +235,5 @@ struct FrameCapture { LPSURFACE TakeSurface() { return completion.InFlight() ? nullptr : snapshot.release(); } }; + +#endif // NEOPIC_CAPTURE_BACKEND != 0 diff --git a/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp b/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp new file mode 100644 index 000000000..d7d374fdd --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp @@ -0,0 +1,3 @@ +// Compile without a prior Common.h include. This catches missing SDK preamble +// dependencies and backend-only code leaking into the other configuration. +#include "../FrameCaptureDX11.h" diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 index 03cd6b8fb..3545b117f 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -16,7 +16,7 @@ foreach ($backend in $Backends) { # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. & cl.exe @common '/Zc:wchar_t' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/WgcCapture.cpp" if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend" } - foreach ($source in @('Main.cpp', 'Runtime.cpp')) { + foreach ($source in @('Main.cpp', 'Runtime.cpp', 'tests/CaptureHeaderCompile.cpp')) { & cl.exe @common '/Zc:wchar_t-' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/$source" if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend" } } From 6ca7aa337019cc5436489aaf2959692adf0b66f3 Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:01:20 +0800 Subject: [PATCH 08/23] NeoPic: add opaque RGB normalization pass and WARP pixel regression --- Extensions/NeoPic/CaptureBlit.h | 193 ++++++++++++++++++ .../NeoPic/tests/CaptureBlitWarpTest.cpp | 94 +++++++++ 2 files changed, 287 insertions(+) create mode 100644 Extensions/NeoPic/CaptureBlit.h create mode 100644 Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp diff --git a/Extensions/NeoPic/CaptureBlit.h b/Extensions/NeoPic/CaptureBlit.h new file mode 100644 index 000000000..c99fbc4c2 --- /dev/null +++ b/Extensions/NeoPic/CaptureBlit.h @@ -0,0 +1,193 @@ +#pragma once +#include +#include +#include +#include +#include "D3DUtilities/ShaderCompiler.h" + +// 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; + + HRESULT Initialize(ID3D11Device* device) { + if (owner.Get() == device && scratch) return S_OK; + owner.Reset(); + scratch.Reset(); + 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; + 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; + hr = device->CreatePixelShader(ps->GetBufferPointer(), ps->GetBufferSize(), nullptr, pixel.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + 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; + sampler.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + hr = device->CreateSamplerState(&sampler, linearSampler.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + 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; + 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; + 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; + 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: + 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; + 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; + 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; + 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; + 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/tests/CaptureBlitWarpTest.cpp b/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp new file mode 100644 index 000000000..b96ab317c --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp @@ -0,0 +1,94 @@ +// 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"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} From 11d29d1354e522ccca86e0c4f5a72f4b9cd46b3d Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:01:34 +0800 Subject: [PATCH 09/23] NeoPic: output capture in scene units and document WGC border --- Extensions/NeoPic/CaptureTesting.md | 66 ++++++++++++---- Extensions/NeoPic/FrameCaptureDX11.h | 114 ++++++++++----------------- Extensions/NeoPic/NeoPic.vcxproj | 1 + 3 files changed, 91 insertions(+), 90 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 94a057bc1..3d7e62914 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -6,7 +6,7 @@ The default is `0`. Existing ACE IDs and saved object properties are unchanged. | Value | Source | Behavior | | --- | --- | --- | | `0` | Existing DC of `rhHEditWin` | Original baseline | -| `1` | Frame secondary Surface | Synchronous Fusion GPU Surface blit, with conservative final-composition checks | +| `1` | Frame secondary Surface | Synchronous RGB normalization pass, with conservative final-composition checks | | `2` | Windows Graphics Capture | Synchronous copy of an already available WGC frame, cropped to `rhHEditWin`'s client area | ## Required contract and current limits @@ -19,7 +19,10 @@ asynchronous Flush; CPU bitmap conversion is not used to force completion. This wait can cost time and is not a zero-latency/real-time deadline guarantee. The output area is the child client area of the same `rhHEditWin` used by the DC -baseline, not the whole top-level window. WGC must capture the root window because +baseline, not the whole top-level window. The image's **logical size** is the +runtime viewport (`rh3WindowSx/rh3WindowSy`), not the physical client pixel size. +Putting a physical-pixel image into a scaled Fusion scene caused the oversized +mode 2 result reported in Windows testing. WGC must capture the root window because of its API, then crops to that child's physical client rectangle. **WGC cannot guarantee the exact current Fusion Display frame.** Waiting for the @@ -42,16 +45,37 @@ The first mode 1 implementation allocated its output from a native texture's mapping from the Fusion Surface's logical image to its underlying allocation. The user reported fragmented images. The exact cause is not established yet. -Mode 1 now uses `source->GetWidth()/GetHeight()` and Fusion's own GPU `BlitEx` -pass to interpret Surface type, format, internal UV/orientation/padding and scale -the accepted viewport to the child client size. It no longer dereferences the -secondary's native texture or assumes its storage describes the image layout. -Unsupported composition is rejected rather than misrepresented as a screenshot. - -Mode 2 still copies only matching native formats. It logs source/target native -sizes and formats and validates its source crop. The destination may have a -larger padded allocation than its logical image; only the logical client region -is written. It does not implement general format conversion or YUV decoding. +The subsequent Surface BlitEx experiment produced a dark/black result in Windows +testing. The cause (including whether source RGB itself is empty) remains to be +verified. Both backends now share an explicit GPU normalization pass, modeled on +the existing FFMpeg rendering approach and reusing D3DUtilities/ShaderCompiler.h: + +- Retain the native source before activating/clearing a new destination RTT. +- Copy only the valid source region into a shader-readable texture, excluding + allocation padding. WGC uses the child-client crop; Secondary uses its logical + Surface region, with the existing conservative composition checks. +- Render to a viewport of the Fusion **logical** dimensions. Point/linear + filtering follows the application's resize-filter option. +- Write RGB with **alpha = 1**, with blending disabled, to avoid inheriting zero + or partial capture alpha and darkening the visible scene. +- Preserve/restore the entire pipeline with a DX11.1 context-state object. A + missing DX11.1 interface fails explicitly instead of corrupting Fusion state. + +BGRA/RGBA 8-bit RGB channel conversion is handled by SRV sampling and the target +RTV. Unsupported YUV/HDR formats are rejected rather than interpreted as RGB. +Windows/Fusion pixel validation is still required; an empty secondary's RGB cannot +be repaired by an opaque output pass. Native/source crop/output dimensions are +logged for comparison. + +### Yellow capture border + +The yellow border is Windows' WGC capture indicator, not a Fusion window border +or part of the generated RTT. This prototype keeps a WGC session warm so a frame +may already exist when a synchronous action is called, hence the indicator can +remain visible while the object is alive. Disabling it requires supported OS +APIs and borderless-capture access; the prototype does **not** silently disable +it or request additional consent. WGC therefore does not provide an invisible, +exact-frame-synchronous replacement for the DC baseline. WGC initialization previously ran in CreateRunObject. A reported VS exception at `check_hresult(CreateForWindow(...))` was caught, but session startup failed and @@ -72,14 +96,21 @@ translation unit; the Fusion sources retain `/Zc:wchar_t-`. From an x86 VS2022 Developer PowerShell in the repository root: ```powershell -./Extensions/NeoPic/tests/CompileCapture.ps1 +./Extensions/NeoPic/tests/CompileCapture.ps1 -RunGpuSmokeTest msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 ``` The first command syntax-checks all three backend configurations, including a -standalone capture-header translation unit to verify the Fusion preamble is -available. Secondary-only implementation is excluded from the WGC configuration, -and the opaque clear value is explicitly typed as RGBAREF to avoid C2398. After that, +standalone capture-header translation unit. It also compiles/runs a native WARP +pixel test with the production normalization pass: an 8x8 padded RGBA texture +contains only a 4x4 image with zero alpha, which must become a correctly oriented +2x2 opaque BGRA image inside a padded target. The test verifies restored viewport +and topology too. It needs no Fusion runtime or capture permission. The test's +staging readback is outside production capture code. + +The same command runs on Windows in the scoped PR workflow +`.github/workflows/neopic-capture-compile.yml`. These are syntax/pixel-pass checks, +not an MFX link or a successful WGC session/Fusion capture. After that, set the desired macro in Macro.h and Rebuild. Close Fusion, back up the installed NeoPic MFX, then install `Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` into Fusion's `Extensions/Unicode`. For exported EXEs also build Run_Only Unicode and @@ -130,7 +161,8 @@ results, synchronous waiting, teardown and errors. Mocks are never on the plugin include path and do not emulate a real GPU. The Linux development environment cannot compile/link MSVC or run Fusion/WGC. -Portable tests pass with GCC, AddressSanitizer and UBSan (leak detection disabled +The Windows workflow is provided for actual compiler/WARP evidence; its outcome +must be checked before claiming those checks passed. Portable tests pass with GCC, AddressSanitizer and UBSan (leak detection disabled under ptrace). Windows syntax checks, real pixels, initialization and performance remain unverified. The reported fragmentation and startup failure are not claimed fixed until a real Windows retest succeeds. No speed improvement is claimed. diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index a5db7519e..7affb1ebc 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -11,6 +11,7 @@ #include #include #include "CaptureCompletion.h" +#include "CaptureBlit.h" #if NEOPIC_CAPTURE_BACKEND == 2 #include "WgcCapture.h" #endif @@ -20,6 +21,7 @@ struct FrameCapture { std::unique_ptr snapshot; Microsoft::WRL::ComPtr sourceTexture; CaptureCompletion completion; + CaptureBlit blit; #if NEOPIC_CAPTURE_BACKEND == 2 bool wgcStarted = false; bool startAttempted = false; @@ -116,95 +118,61 @@ struct FrameCapture { Report(E_NOTIMPL, "secondary cannot establish final child-client composition"); return E_NOTIMPL; } - auto candidate = std::unique_ptr(CreateHWASurface(rdPtr, 32, width, height, ST_HWA_RTTEXTURE)); - if (!candidate || !candidate->IsValid()) return E_FAIL; - BOOL drawn = FALSE; + // 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. + Microsoft::WRL::ComPtr native; { - // COLORREFATORGBA(0, 255) is a signed int expression in the SDK; - // brace initialization into RGBAREF rejects its narrowing conversion. - 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) return E_NOINTERFACE; - const HRESULT hr = completion.Prepare(static_cast(info.m_pD3D11Device), - static_cast(info.m_pD3D11Context)); - if (FAILED(hr)) return hr; - POINT center{0, 0}; - const DWORD stretchFlags = (rh->rhApp->m_pHdr2 - && (rh->rhApp->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH)) ? STRF_RESAMPLE : 0; - drawn = source->BlitEx(*candidate, 0.0f, 0.0f, - static_cast(width) / sourceWidth, static_cast(height) / sourceHeight, - 0, 0, sourceWidth, sourceHeight, ¢er, 0.0f, BMODE_OPAQUE, BOP_COPY, 0, stretchFlags); + 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); } - snapshot = std::move(candidate); - completion.Submit(); // even failed blits may have submitted GPU work - return drawn ? S_OK : E_FAIL; + if (!native) return E_POINTER; + const D3D11_BOX region{0, 0, 0, static_cast(sourceWidth), static_cast(sourceHeight), 1}; + return Normalize(rdPtr, native.Get(), region); } - #endif -#if NEOPIC_CAPTURE_BACKEND == 2 - // Texture formats/sample counts must agree; CopyResource cannot resize or - // convert. The WGC path supplies a client-area crop in source coordinates. - HRESULT CopyTexture(LPRDATA rdPtr, ID3D11Texture2D* source, const D3D11_BOX* crop) { + HRESULT Normalize(LPRDATA rdPtr, ID3D11Texture2D* source, const D3D11_BOX& region) { if (!source) return E_POINTER; - D3D11_TEXTURE2D_DESC from{}; - source->GetDesc(&from); - if (from.MipLevels != 1 || from.ArraySize != 1) return E_NOTIMPL; - const UINT width = crop ? crop->right - crop->left : from.Width; - const UINT height = crop ? crop->bottom - crop->top : from.Height; - if (!width || !height || (crop && (crop->left >= crop->right || crop->top >= crop->bottom - || crop->front != 0 || crop->back != 1 || crop->right > from.Width || crop->bottom > from.Height))) - return E_INVALIDARG; - auto candidate = std::unique_ptr(CreateHWASurface(rdPtr, - 32, static_cast(width), static_cast(height), ST_HWA_RTTEXTURE)); + auto rh = rdPtr->rHo.hoAdRunHeader; + // Client pixels identify the capture AREA, not NeoPic's scene-unit size. + const int width = rh->rh3.rh3WindowSx, height = rh->rh3.rh3WindowSy; + if (width <= 0 || height <= 0) return E_INVALIDARG; + 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; - // Fusion.h matrix: RTT native render-target texture is valid only while - // rendering; its m_ppD3D11Texture must not be dereferenced. + // 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; { - RenderHelper rendering{candidate.get(), FALSE}; + 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_ppD3D11RenderTargetTexture - || !info.m_pD3D11Context) return E_NOINTERFACE; - auto target = *static_cast(info.m_ppD3D11RenderTargetTexture); + 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; - Microsoft::WRL::ComPtr sourceDevice; - source->GetDevice(&sourceDevice); - if (sourceDevice.Get() != info.m_pD3D11Device) return E_INVALIDARG; - D3D11_TEXTURE2D_DESC to{}; - target->GetDesc(&to); - char details[256]; - sprintf_s(details, "NeoPic WGC native-source=%ux%u format=%u client=%ux%u native-target=%ux%u format=%u\n", - from.Width, from.Height, unsigned(from.Format), width, height, to.Width, to.Height, unsigned(to.Format)); - OutputDebugStringA(details); - if (from.Format != to.Format || to.MipLevels != 1 || to.ArraySize != 1 - || to.Width < width || to.Height < height || to.SampleDesc.Count != 1) - return E_NOTIMPL; auto context = static_cast(info.m_pD3D11Context); - if (from.SampleDesc.Count > 1) { - if (crop || from.Width != to.Width || from.Height != to.Height) return E_NOTIMPL; - UINT support = 0; - const auto hr = sourceDevice->CheckFormatSupport(from.Format, &support); - if (FAILED(hr) || !(support & D3D11_FORMAT_SUPPORT_MULTISAMPLE_RESOLVE)) return E_NOTIMPL; - } - // Query allocation can fail: do it before submitting any GPU copy. - const HRESULT prepared = completion.Prepare(sourceDevice.Get(), context); + const HRESULT prepared = completion.Prepare(static_cast(info.m_pD3D11Device), context); if (FAILED(prepared)) return prepared; - if (from.SampleDesc.Count > 1) { - context->ResolveSubresource(target, 0, source, 0, from.Format); - } else { - context->CopySubresourceRegion(target, 0, 0, 0, 0, source, 0, crop); - } + 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); } snapshot = std::move(candidate); sourceTexture = source; - completion.Submit(); - Report(S_OK, "submitted (GPU pending)"); - return S_OK; + completion.Submit(); // Drain even a failed pass's intermediate GPU work. + return rendered; } -#endif - // Synchronous action contract: never defer publication to another Handle. // A ready WGC image can still be older than the current compositor frame. bool Capture(LPRDATA rdPtr) { @@ -219,7 +187,7 @@ struct FrameCapture { Microsoft::WRL::ComPtr texture; D3D11_BOX crop{}; hr = wgc.LatestTexture(texture.GetAddressOf(), &crop); - if (SUCCEEDED(hr)) hr = CopyTexture(rdPtr, texture.Get(), &crop); + if (SUCCEEDED(hr)) hr = Normalize(rdPtr, texture.Get(), crop); } #endif } diff --git a/Extensions/NeoPic/NeoPic.vcxproj b/Extensions/NeoPic/NeoPic.vcxproj index 04c1d3e2e..b778f3fca 100644 --- a/Extensions/NeoPic/NeoPic.vcxproj +++ b/Extensions/NeoPic/NeoPic.vcxproj @@ -311,6 +311,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + From 4588e565a039720f2635d732500de6f53ff1fb83 Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:01:49 +0800 Subject: [PATCH 10/23] NeoPic: check capture backends and normalization on Windows --- .github/workflows/neopic-capture-compile.yml | 26 ++++++++++++++++++++ Extensions/NeoPic/tests/CompileCapture.ps1 | 17 ++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/neopic-capture-compile.yml diff --git a/.github/workflows/neopic-capture-compile.yml b/.github/workflows/neopic-capture-compile.yml new file mode 100644 index 000000000..f170d533e --- /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: Compile all backends and run WARP pixel checks + 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 -RunGpuSmokeTest diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 index 3545b117f..a44c6364d 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -1,4 +1,4 @@ -param([ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2)) +param([ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2), [switch]$RunGpuSmokeTest) $ErrorActionPreference = 'Stop' # Run in an x86 VS2022 Developer PowerShell. No proprietary SDK .lib is needed # for this syntax-only check; building/linking the actual .mfx remains separate. @@ -21,3 +21,18 @@ foreach ($backend in $Backends) { if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend" } } } + +if ($RunGpuSmokeTest) { + $temp = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temp | Out-Null + try { + $exe = Join-Path $temp 'CaptureBlitWarpTest.exe' + $obj = Join-Path $temp 'CaptureBlitWarpTest.obj' + & cl.exe '/nologo' '/std:c++20' '/EHsc' '/Zc:wchar_t' "/I$repo/Extensions/_3rdLib" "/Fe$exe" "/Fo$obj" "$neo/tests/CaptureBlitWarpTest.cpp" + if ($LASTEXITCODE -ne 0) { throw 'WARP test compilation failed' } + & $exe + if ($LASTEXITCODE -ne 0) { throw 'WARP capture-normalization pixel test failed' } + } finally { + Remove-Item -Recurse -Force $temp + } +} From 70d3c6708f3b73b61185a101ac26f62ebd06e3e8 Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:04:42 +0800 Subject: [PATCH 11/23] NeoPic: scope public Windows checks without proprietary Fusion SDK --- .github/workflows/neopic-capture-compile.yml | 4 ++-- Extensions/NeoPic/CaptureTesting.md | 9 ++++++--- Extensions/NeoPic/tests/CompileCapture.ps1 | 9 ++++++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/neopic-capture-compile.yml b/.github/workflows/neopic-capture-compile.yml index f170d533e..c4a55496a 100644 --- a/.github/workflows/neopic-capture-compile.yml +++ b/.github/workflows/neopic-capture-compile.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Compile all backends and run WARP pixel checks + - name: Compile independent WGC component and run WARP pixel checks shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" @@ -23,4 +23,4 @@ jobs: 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 -RunGpuSmokeTest + ./Extensions/NeoPic/tests/CompileCapture.ps1 -NativeOnly -RunGpuSmokeTest diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 3d7e62914..0531bc201 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -108,9 +108,12 @@ contains only a 4x4 image with zero alpha, which must become a correctly oriente and topology too. It needs no Fusion runtime or capture permission. The test's staging readback is outside production capture code. -The same command runs on Windows in the scoped PR workflow -`.github/workflows/neopic-capture-compile.yml`. These are syntax/pixel-pass checks, -not an MFX link or a successful WGC session/Fusion capture. After that, +The public Windows PR workflow uses `-NativeOnly -RunGpuSmokeTest`: it compiles +the independent WGC translation unit under all three macro values and tests the +production pixel pass. It does NOT select Fusion Main/Runtime/header compilation, +since the proprietary SDK headers are intentionally absent from the repository. +The default local command above requires your Inc/ SDK headers and fails if they +are missing. Neither scope proves an MFX link or a successful WGC/Fusion capture. After that, set the desired macro in Macro.h and Rebuild. Close Fusion, back up the installed NeoPic MFX, then install `Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` into Fusion's `Extensions/Unicode`. For exported EXEs also build Run_Only Unicode and diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 index a44c6364d..16b7d1eed 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -1,4 +1,4 @@ -param([ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2), [switch]$RunGpuSmokeTest) +param([ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2), [switch]$RunGpuSmokeTest, [switch]$NativeOnly) $ErrorActionPreference = 'Stop' # Run in an x86 VS2022 Developer PowerShell. No proprietary SDK .lib is needed # for this syntax-only check; building/linking the actual .mfx remains separate. @@ -11,11 +11,18 @@ $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 full plugin syntax checks. -NativeOnly explicitly checks only the independent WGC/GPU components.' +} foreach ($backend in $Backends) { Write-Host "Checking capture backend $backend" # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. & cl.exe @common '/Zc:wchar_t' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/WgcCapture.cpp" if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend" } + if ($NativeOnly) { + Write-Host 'Native-only scope: Fusion Main/Runtime/header syntax checks are NOT selected.' + continue + } foreach ($source in @('Main.cpp', 'Runtime.cpp', 'tests/CaptureHeaderCompile.cpp')) { & cl.exe @common '/Zc:wchar_t-' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/$source" if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend" } From 9ac7451e2285aaeb00da46d19fe67b3ea0423283 Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:35:57 +0800 Subject: [PATCH 12/23] NeoPic: add deterministic capture benchmark timing and statistics --- Extensions/NeoPic/CaptureBenchmark.h | 66 +++++++++++++++++++ .../NeoPic/tests/CaptureBenchmarkTest.cpp | 57 ++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 Extensions/NeoPic/CaptureBenchmark.h create mode 100644 Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp diff --git a/Extensions/NeoPic/CaptureBenchmark.h b/Extensions/NeoPic/CaptureBenchmark.h new file mode 100644 index 000000000..7d1faaceb --- /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/tests/CaptureBenchmarkTest.cpp b/Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp new file mode 100644 index 000000000..31cbc8f2f --- /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"; +} From 2324cf27bdf01c5b15d5ddb4c1b76877f51e608f Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:36:11 +0800 Subject: [PATCH 13/23] NeoPic: benchmark capture and _TempCapture handoff inside the action --- Extensions/NeoPic/CaptureTesting.md | 96 ++++++++++++++++++++----- Extensions/NeoPic/FrameCapture.h | 58 ++++++++++----- Extensions/NeoPic/FrameCaptureDX11.h | 59 +++++++++------ Extensions/NeoPic/Macro.h | 15 ++++ Extensions/NeoPic/Main.cpp | 104 +++++++++++++++++++++++++-- Extensions/NeoPic/NeoPic.vcxproj | 1 + Extensions/NeoPic/WgcCapture.cpp | 6 +- Extensions/NeoPic/WgcCapture.h | 2 +- 8 files changed, 277 insertions(+), 64 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 0531bc201..99c4f6e1d 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -36,7 +36,9 @@ or non-RTT source, a logical size different from the runtime viewport, nonzero origin, an aspect ratio mismatch, Frame effects, or nonidentity Frame transforms whose final composition cannot be established. These are explicit failures, not a fallback to DC and not proof of support for every rendering configuration. A default scene may have no -secondary Surface at all. Results still need Windows validation. +secondary Surface at all. The author confirmed both experimental backends now +produce the correct image in the tested Fusion 296 / DX11 application; other +rendering configurations still require validation. ## Changes after the first Windows test @@ -46,8 +48,7 @@ mapping from the Fusion Surface's logical image to its underlying allocation. The user reported fragmented images. The exact cause is not established yet. The subsequent Surface BlitEx experiment produced a dark/black result in Windows -testing. The cause (including whether source RGB itself is empty) remains to be -verified. Both backends now share an explicit GPU normalization pass, modeled on +testing. Both backends now share an explicit GPU normalization pass, modeled on the existing FFMpeg rendering approach and reusing D3DUtilities/ShaderCompiler.h: - Retain the native source before activating/clearing a new destination RTT. @@ -63,9 +64,9 @@ the existing FFMpeg rendering approach and reusing D3DUtilities/ShaderCompiler.h BGRA/RGBA 8-bit RGB channel conversion is handled by SRV sampling and the target RTV. Unsupported YUV/HDR formats are rejected rather than interpreted as RGB. -Windows/Fusion pixel validation is still required; an empty secondary's RGB cannot -be repaired by an opaque output pass. Native/source crop/output dimensions are -logged for comparison. +The author confirmed the corrected output for both backends in their application. +An empty secondary's RGB cannot be repaired by an opaque output pass. Native/source +crop/output dimensions are logged for comparison when the benchmark is disabled. ### Yellow capture border @@ -100,8 +101,10 @@ From an x86 VS2022 Developer PowerShell in the repository root: msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 ``` -The first command syntax-checks all three backend configurations, including a -standalone capture-header translation unit. It also compiles/runs a native WARP +The first command syntax-checks all three backend configurations with the +benchmark disabled and enabled (0 and 100 iterations), including a standalone +capture-header translation unit. It also compiles/runs the portable benchmark +statistics tests with MSVC and a native WARP pixel test with the production normalization pass: an 8x8 padded RGBA texture contains only a 4x4 image with zero alpha, which must become a correctly oriented 2x2 opaque BGRA image inside a padded target. The test verifies restored viewport @@ -109,8 +112,9 @@ and topology too. It needs no Fusion runtime or capture permission. The test's staging readback is outside production capture code. The public Windows PR workflow uses `-NativeOnly -RunGpuSmokeTest`: it compiles -the independent WGC translation unit under all three macro values and tests the -production pixel pass. It does NOT select Fusion Main/Runtime/header compilation, +the independent WGC translation unit under all three backend macro values, with +the benchmark disabled/enabled, and tests the production pixel pass and benchmark +statistics. It does NOT select Fusion Main/Runtime/header compilation, since the proprietary SDK headers are intentionally absent from the repository. The default local command above requires your Inc/ SDK headers and fails if they are missing. Neither scope proves an MFX link or a successful WGC/Fusion capture. After that, @@ -148,6 +152,61 @@ Return the logs, baseline/experimental PNGs, Windows version, GPU/driver and actual window/DPI/effect settings. For a debugger exception, the HRESULT is the error code (e.g. 0x80070057); the KernelBase instruction address is not the HRESULT. +## Benchmark inside the capture action + +In `Macro.h`, select one backend and change the benchmark definitions, then +**Rebuild** and install the matching MFX as above: + +```cpp +#define NEOPIC_CAPTURE_BACKEND 0 // Repeat separately with 1 and 2. +#define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 100 +#define NEOPIC_CAPTURE_BENCHMARK_WARMUP 10 +``` + +The existing guarded defaults allow project-wide compiler definitions instead. +`ITERATIONS=0` (the default) restores the ordinary single-capture action with no +benchmark timer. No ACE IDs or MFA changes are needed. + +1. Use the same application, logical viewport, client size/DPI, resize filter, + effects, GPU, NeoPic HWA/library settings and Release build for all three runs. + Keep the NeoPic's displayed screenshot hidden to avoid recursion. Start with + 100 iterations, since the action deliberately blocks events and Display. +2. Let the scene render first; for WGC, allow roughly a second for its session to + warm up. Press a key to call **Capture Frame Area once**. The loop runs inside + that action; no Fusion fast loop or Always event is needed. +3. Copy the `NeoPic capture benchmark ...` summary from VS debug output or + DebugView. Repeat for backends 0, 1 and 2. Compare only runs with matching + `viewport`, `output`, HWA/library and filter settings, and `status=ok`. +4. The last successful image remains available as `_TempCapture`. Show/save it + after the action if desired; writing a file is outside the benchmark. + +Each measured iteration includes Capture, allocation/normalization, synchronous +GPU completion, and the ordinary `LoadFromPointer(..., L"_TempCapture", ...)` +handoff (including replacement of the previous snapshot). For fair output sizes, +the **benchmark-only** DC path scales to the same logical viewport as modes 1/2; +it also flushes GDI and, with NeoPic HWA under DX11, waits for the handoff's GPU +upload. The normal non-benchmark DC action retains its original output sizing. +This compares the complete action path, not just a GPU copy command. + +Normal per-capture debug messages are suppressed during the loop. The summary +reports warmup successes/failures, first-call wall time, measured successes and +failures, measured batch wall time, successful-sample mean/min/median/P95/max, +and completed copies per second. Warmup and summary output are excluded from the +measured batch; `first_ms` is the first call of this batch, not necessarily a cold +process start. P95 uses nearest rank. Failed attempts have separate timing and +never enter successful percentiles. If every warmup attempt fails, measurement +is skipped (`status=no_ready_source`); this is a failed run, not a speed result. + +**There is no new Fusion Display between iterations.** In benchmark mode WGC +retains the latest valid frame until the loop finishes, refreshing it if another +frame is already queued. It can therefore repeatedly copy the same image rather +than report mostly fast `E_PENDING` failures. A successful benchmark measures +snapshot processing throughput, not distinct captured frames per second, source +freshness or display FPS. Timing uses QPC wall time, including CPU work and GPU +waiting, rather than GPU timestamps. CaptureCompletion's existing 1 ms polling +interval can affect these synchronous wall times. No speedup is assumed before +comparing actual logs from the same machine. + ## Regression tests and validation boundaries ```sh @@ -155,17 +214,22 @@ c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureCropTest.cpp ./capture-crop-test c++ -std=c++20 -Wall -Wextra -Werror -IExtensions/NeoPic/tests/completion-mocks Extensions/NeoPic/tests/CaptureCompletionTest.cpp -o capture-completion-test ./capture-completion-test +c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp -o capture-benchmark-test +./capture-benchmark-test ``` Crop tests cover client mapping, scaling and negative monitor coordinates. Completion tests exercise the production helper against small mock interfaces: query allocation, immediate-context checks, ordered End/Flush, incomplete/complete results, synchronous waiting, teardown and errors. Mocks are never on the plugin -include path and do not emulate a real GPU. +include path and do not emulate a real GPU. Benchmark tests use an injected clock +and capture callback to check warmup exclusion, failed-sample separation, +percentiles, unavailable sources and input validation. The Linux development environment cannot compile/link MSVC or run Fusion/WGC. -The Windows workflow is provided for actual compiler/WARP evidence; its outcome -must be checked before claiming those checks passed. Portable tests pass with GCC, AddressSanitizer and UBSan (leak detection disabled -under ptrace). Windows syntax checks, real pixels, initialization and performance -remain unverified. The reported fragmentation and startup failure are not claimed -fixed until a real Windows retest succeeds. No speed improvement is claimed. +Public Windows checks cover the independent components and WARP as described +above, not the proprietary Fusion adapter or an MFX link. The author confirmed +correct images for both experimental backends in their Windows application. +Portable tests also run with GCC, AddressSanitizer and UBSan (leak detection +disabled under ptrace). The benchmark itself still needs application performance +measurements; no speed improvement is claimed. diff --git a/Extensions/NeoPic/FrameCapture.h b/Extensions/NeoPic/FrameCapture.h index ff251693f..e8f888261 100644 --- a/Extensions/NeoPic/FrameCapture.h +++ b/Extensions/NeoPic/FrameCapture.h @@ -10,6 +10,9 @@ struct FrameCapture { int frameWidth = -1; int frameHeight = -1; + HRESULT lastResult = S_OK; + void SetBenchmarking(bool) {} + HRESULT GetLastResult() const { return lastResult; } FrameCapture(LPRDATA rdPtr) { this->hFrameWindowHandle = rdPtr->rHo.hoAdRunHeader->rhHEditWin; @@ -45,36 +48,57 @@ struct FrameCapture { pCaptureSf = nullptr; } - inline void NewSurface() { + inline bool NewSurface(LPRDATA rdPtr) { RECT frameRect; - ::GetWindowRect(hFrameWindowHandle, &frameRect); + if (!::GetWindowRect(hFrameWindowHandle, &frameRect)) return false; frameWidth = frameRect.right - frameRect.left; frameHeight = frameRect.bottom - frameRect.top; + int outputWidth = frameWidth, outputHeight = frameHeight; +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 + // Compare the same area at the same logical output size as GPU backends. + outputWidth = rdPtr->rHo.hoAdRunHeader->rh3.rh3WindowSx; + outputHeight = rdPtr->rHo.hoAdRunHeader->rh3.rh3WindowSy; +#else + (void)rdPtr; +#endif + if (frameWidth <= 0 || frameHeight <= 0 || outputWidth <= 0 || outputHeight <= 0) return false; if (pCaptureSf == nullptr - || pCaptureSf->GetWidth() != frameWidth - || pCaptureSf->GetHeight() != frameHeight) { + || pCaptureSf->GetWidth() != outputWidth + || pCaptureSf->GetHeight() != outputHeight) { ReleaseSurface(); - pCaptureSf = CreateSurface(24, frameWidth, frameHeight); + pCaptureSf = CreateSurface(24, outputWidth, outputHeight); + if (!pCaptureSf || !pCaptureSf->IsValid()) return false; _AddAlpha(pCaptureSf); pCaptureDC = pCaptureSf->GetDC(); +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 + const auto app = rdPtr->rHo.hoAdRunHeader->rhApp; + const bool resample = app->m_pHdr2 && (app->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH); + if (pCaptureDC) { + SetStretchBltMode(pCaptureDC, resample ? HALFTONE : COLORONCOLOR); + SetBrushOrgEx(pCaptureDC, 0, 0, nullptr); + } +#endif } + return pCaptureDC != nullptr; } - 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 - } + bool Capture(LPRDATA rdPtr) { + NewDC(); + bool ok = hdcWindow && NewSurface(rdPtr); + if (ok) ok = StretchBlt(pCaptureDC, 0, 0, pCaptureSf->GetWidth(), pCaptureSf->GetHeight(), + hdcWindow, 0, 0, frameWidth, frameHeight, SRCCOPY) != FALSE; +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 + // GDI batching must not look faster than an already GPU-complete result. + if (ok) ok = GdiFlush() != FALSE; +#endif + const DWORD error = ok ? ERROR_SUCCESS : GetLastError(); + lastResult = ok ? S_OK : (error ? HRESULT_FROM_WIN32(error) : E_FAIL); + return ok; + } + }; #else diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index 7affb1ebc..ec28274f5 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -22,6 +22,8 @@ struct FrameCapture { Microsoft::WRL::ComPtr sourceTexture; CaptureCompletion completion; CaptureBlit blit; + bool benchmarking = false; + HRESULT lastResult = S_OK; #if NEOPIC_CAPTURE_BACKEND == 2 bool wgcStarted = false; bool startAttempted = false; @@ -30,7 +32,8 @@ struct FrameCapture { WgcCapture wgc; #endif - static void Report(HRESULT hr, const char* stage) { + void Report(HRESULT hr, const char* stage) { + if (benchmarking) return; char message[256]; sprintf_s(message, "NeoPic capture backend=%d %s hr=0x%08lX\n", NEOPIC_CAPTURE_BACKEND, stage, static_cast(hr)); @@ -51,11 +54,20 @@ struct FrameCapture { void ReleaseCopySource() { sourceTexture.Reset(); #if NEOPIC_CAPTURE_BACKEND == 2 - wgc.ReleaseCurrent(); + if (!benchmarking) wgc.ReleaseCurrent(); #endif completion.Reset(); } + void SetBenchmarking(bool enabled) { + if (!enabled && benchmarking) { + if (completion.InFlight()) completion.Wait(); + benchmarking = false; + ReleaseCopySource(); + } else benchmarking = enabled; + } + HRESULT GetLastResult() const { return lastResult; } + #if NEOPIC_CAPTURE_BACKEND == 2 HRESULT StartWgc(LPRDATA rdPtr, bool retry) { if (wgcStarted) return S_OK; @@ -65,7 +77,7 @@ struct FrameCapture { 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))); + const HRESULT hr = wgc.Start(frame, static_cast(GetD3D11Device(rdPtr)), !benchmarking); wgcStarted = SUCCEEDED(hr); Report(hr, "start"); return hr; @@ -95,11 +107,13 @@ struct FrameCapture { const int sourceWidth = source->GetWidth(), sourceHeight = source->GetHeight(); POINT origin{}; source->GetOrigin(origin); - 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 (!benchmarking) { + 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; // A pre-effect/offscreen scene is not necessarily what rhHEditWin shows. // Refuse cases whose final composition cannot be reconstructed here. @@ -109,12 +123,14 @@ struct FrameCapture { || origin.x != 0 || origin.y != 0 || sourceWidth != rh->rh3.rh3WindowSx || sourceHeight != rh->rh3.rh3WindowSy || static_cast(width) * sourceHeight != static_cast(height) * sourceWidth) { - 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); + if (!benchmarking) { + 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; } @@ -140,12 +156,14 @@ struct FrameCapture { // Client pixels identify the capture AREA, not NeoPic's scene-unit size. const int width = rh->rh3.rh3WindowSx, height = rh->rh3.rh3WindowSy; if (width <= 0 || height <= 0) return E_INVALIDARG; - 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); + if (!benchmarking) { + 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; @@ -197,6 +215,7 @@ struct FrameCapture { } ReleaseCopySource(); if (FAILED(hr)) snapshot.reset(); + lastResult = hr; Report(hr, SUCCEEDED(hr) ? "ready (synchronous GPU completion)" : "failed (image unchanged)"); return SUCCEEDED(hr); } diff --git a/Extensions/NeoPic/Macro.h b/Extensions/NeoPic/Macro.h index 3fee23d39..fc21f83c6 100644 --- a/Extensions/NeoPic/Macro.h +++ b/Extensions/NeoPic/Macro.h @@ -15,6 +15,21 @@ #error Invalid NEOPIC_CAPTURE_BACKEND #endif +// 0 keeps the normal single capture action. Set to e.g. 100 for a loop inside +// ONE action invocation. Each sample includes capture, synchronization and load. +#ifndef NEOPIC_CAPTURE_BENCHMARK_ITERATIONS +#define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 0 +#endif +#ifndef NEOPIC_CAPTURE_BENCHMARK_WARMUP +#define NEOPIC_CAPTURE_BENCHMARK_WARMUP 10 +#endif +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS < 0 || NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 100000 +#error Invalid NEOPIC_CAPTURE_BENCHMARK_ITERATIONS +#endif +#if NEOPIC_CAPTURE_BENCHMARK_WARMUP < 0 || NEOPIC_CAPTURE_BENCHMARK_WARMUP > 10000 +#error Invalid NEOPIC_CAPTURE_BENCHMARK_WARMUP +#endif + // only enable when needed to speed up build #define PERSPECTIVE_TRANSFORMATION diff --git a/Extensions/NeoPic/Main.cpp b/Extensions/NeoPic/Main.cpp index 57dc15849..309b99b43 100644 --- a/Extensions/NeoPic/Main.cpp +++ b/Extensions/NeoPic/Main.cpp @@ -8,6 +8,10 @@ // Common Include #include "common.h" +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 +#include "CaptureBenchmark.h" +#include "CaptureCompletion.h" +#endif // Quick memo: content of the eventInformations arrays // --------------------------------------------------- @@ -867,17 +871,103 @@ short WINAPI DLLExport Action_SaveToFile(LPRDATA rdPtr, long param1, long param2 } short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long param2) { + int outputWidth = 0, outputHeight = 0; + HRESULT iterationError = S_OK; + auto captureOnce = [&]() { + iterationError = S_OK; + if (!rdPtr->pFrameCapture->Capture(rdPtr)) { + iterationError = rdPtr->pFrameCapture->GetLastResult(); + return false; + } #if NEOPIC_CAPTURE_BACKEND == 0 - rdPtr->pFrameCapture->Capture(); - LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->pCaptureSf); + const auto surface = rdPtr->pFrameCapture->pCaptureSf; #else - if (rdPtr->pFrameCapture->Capture(rdPtr)) { - LoadFromPointer(rdPtr, L"_TempCapture", rdPtr->pFrameCapture->TakeSurface(), - LoadFromPointerFlags_TakeOwnership); + const auto surface = rdPtr->pFrameCapture->TakeSurface(); +#endif + if (!surface || !surface->IsValid()) { +#if NEOPIC_CAPTURE_BACKEND != 0 + delete surface; // TakeSurface transferred ownership even on an invalid result. +#endif + iterationError = E_FAIL; + return false; + } + outputWidth = surface->GetWidth(); + outputHeight = surface->GetHeight(); +#if NEOPIC_CAPTURE_BACKEND == 0 + LoadFromPointer(rdPtr, L"_TempCapture", surface); +#else + LoadFromPointer(rdPtr, L"_TempCapture", surface, LoadFromPointerFlags_TakeOwnership); +#endif +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 && NEOPIC_CAPTURE_BACKEND == 0 + // Include the default path's CPU->GPU upload, not just the DIB copy. + if (rdPtr->HWA && D3D11(rdPtr)) { + const auto info = GetD3D11Info(rdPtr); + CaptureCompletion uploaded; + iterationError = uploaded.Prepare(static_cast(info.m_pD3D11Device), + static_cast(info.m_pD3D11Context)); + if (FAILED(iterationError)) return false; + uploaded.Submit(); + iterationError = uploaded.Wait(); + if (FAILED(iterationError)) return false; + } +#endif + return true; + }; +#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 + LARGE_INTEGER frequency{}; + if (!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { + OutputDebugStringA("NeoPic capture benchmark: QPC unavailable; doing one normal capture\n"); + captureOnce(); + return 0; + } + auto now = []() { LARGE_INTEGER ticks{}; QueryPerformanceCounter(&ticks); return ticks.QuadPart; }; + HRESULT firstFailure = S_OK; + auto measuredCapture = [&]() { + const bool ok = captureOnce(); + if (!ok && firstFailure == S_OK) firstFailure = FAILED(iterationError) ? iterationError : E_FAIL; + return ok; + }; + neopic_capture::BenchmarkResult result; + { + struct Scope { + FrameCapture* capture; + explicit Scope(FrameCapture* p) : capture(p) { capture->SetBenchmarking(true); } + ~Scope() { capture->SetBenchmarking(false); } + } scope(rdPtr->pFrameCapture); + result = neopic_capture::RunBenchmark(NEOPIC_CAPTURE_BENCHMARK_ITERATIONS, + NEOPIC_CAPTURE_BENCHMARK_WARMUP, measuredCapture, now, frequency.QuadPart); + } + 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=%d iterations=%d warmup=%d HWA=%d library=%d viewport=%dx%d client=%ldx%ld output=%dx%d 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", + NEOPIC_CAPTURE_BACKEND, NEOPIC_CAPTURE_BENCHMARK_ITERATIONS, NEOPIC_CAPTURE_BENCHMARK_WARMUP, + rdPtr->HWA ? 1 : 0, rdPtr->isLib ? 1 : 0, rh->rh3.rh3WindowSx, rh->rh3.rh3WindowSy, + client.right - client.left, client.bottom - client.top, outputWidth, outputHeight, 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 ? 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); } +#else + captureOnce(); #endif - - return 0; + return 0; } short WINAPI DLLExport Action_SaveToFileWithStretch(LPRDATA rdPtr, long param1, long param2) { diff --git a/Extensions/NeoPic/NeoPic.vcxproj b/Extensions/NeoPic/NeoPic.vcxproj index b778f3fca..d62a90e8f 100644 --- a/Extensions/NeoPic/NeoPic.vcxproj +++ b/Extensions/NeoPic/NeoPic.vcxproj @@ -311,6 +311,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + diff --git a/Extensions/NeoPic/WgcCapture.cpp b/Extensions/NeoPic/WgcCapture.cpp index a001488d2..7cd279243 100644 --- a/Extensions/NeoPic/WgcCapture.cpp +++ b/Extensions/NeoPic/WgcCapture.cpp @@ -54,7 +54,7 @@ static void ReportWgcFailure(HRESULT hr, const char* stage, HWND frame, HWND roo OutputDebugStringA(text); } -HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device) noexcept { +HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device, bool diagnostics) noexcept { const HWND root = GetAncestor(frameWindow, GA_ROOT); const char* stage = "initialize"; try { @@ -75,7 +75,7 @@ HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device) noexcept { const HRESULT created = factory->CreateForWindow(next->rootWindow, winrt::guid_of(), winrt::put_abi(next->item)); if (FAILED(created)) { - ReportWgcFailure(created, stage, frameWindow, root); + if (diagnostics) ReportWgcFailure(created, stage, frameWindow, root); return created; } stage = "DXGI device interop"; @@ -98,7 +98,7 @@ HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device) noexcept { return S_OK; } catch (...) { const HRESULT hr = winrt::to_hresult(); - ReportWgcFailure(hr, stage, frameWindow, root); + if (diagnostics) ReportWgcFailure(hr, stage, frameWindow, root); state.reset(); return hr; } diff --git a/Extensions/NeoPic/WgcCapture.h b/Extensions/NeoPic/WgcCapture.h index 05719beb6..c0da0d7ce 100644 --- a/Extensions/NeoPic/WgcCapture.h +++ b/Extensions/NeoPic/WgcCapture.h @@ -13,7 +13,7 @@ class WgcCapture { ~WgcCapture(); WgcCapture(const WgcCapture&) = delete; WgcCapture& operator=(const WgcCapture&) = delete; - HRESULT Start(HWND frameWindow, ID3D11Device* device) noexcept; + 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. From 2eb1aad63a417e9dd2cc2c7eefd5f799a0399e2e Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:36:24 +0800 Subject: [PATCH 14/23] NeoPic: check benchmark configurations and statistics on Windows --- .github/workflows/neopic-capture-compile.yml | 2 +- Extensions/NeoPic/tests/CompileCapture.ps1 | 38 +++++++++++++------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/.github/workflows/neopic-capture-compile.yml b/.github/workflows/neopic-capture-compile.yml index c4a55496a..57e162e1a 100644 --- a/.github/workflows/neopic-capture-compile.yml +++ b/.github/workflows/neopic-capture-compile.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Compile independent WGC component and run WARP pixel checks + - name: Compile independent WGC component; test pixels and benchmark statistics shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 index 16b7d1eed..cfeeccb1d 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -1,4 +1,9 @@ -param([ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2), [switch]$RunGpuSmokeTest, [switch]$NativeOnly) +param( + [ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2), + [ValidateRange(0, 100000)][int[]]$BenchmarkIterations = @(0, 100), + [switch]$RunGpuSmokeTest, + [switch]$NativeOnly +) $ErrorActionPreference = 'Stop' # Run in an x86 VS2022 Developer PowerShell. No proprietary SDK .lib is needed # for this syntax-only check; building/linking the actual .mfx remains separate. @@ -15,17 +20,20 @@ if (-not $NativeOnly -and -not (Test-Path "$repo/Inc/ccxhdr.h")) { throw 'Fusion SDK headers are required in Inc/ for full plugin syntax checks. -NativeOnly explicitly checks only the independent WGC/GPU components.' } foreach ($backend in $Backends) { - Write-Host "Checking capture backend $backend" - # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. - & cl.exe @common '/Zc:wchar_t' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/WgcCapture.cpp" - if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend" } - if ($NativeOnly) { - Write-Host 'Native-only scope: Fusion Main/Runtime/header syntax checks are NOT selected.' - continue - } - foreach ($source in @('Main.cpp', 'Runtime.cpp', 'tests/CaptureHeaderCompile.cpp')) { - & cl.exe @common '/Zc:wchar_t-' "/DNEOPIC_CAPTURE_BACKEND=$backend" "$neo/$source" - if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend" } + foreach ($iterations in $BenchmarkIterations) { + Write-Host "Checking capture backend $backend benchmark iterations=$iterations" + $defines = @("/DNEOPIC_CAPTURE_BACKEND=$backend", "/DNEOPIC_CAPTURE_BENCHMARK_ITERATIONS=$iterations") + # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. + & cl.exe @common '/Zc:wchar_t' @defines "$neo/WgcCapture.cpp" + if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend benchmark=$iterations" } + if ($NativeOnly) { + Write-Host 'Native-only scope: Fusion Main/Runtime/header syntax checks are NOT selected.' + continue + } + foreach ($source in @('Main.cpp', 'Runtime.cpp', 'tests/CaptureHeaderCompile.cpp')) { + & cl.exe @common '/Zc:wchar_t-' @defines "$neo/$source" + if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend benchmark=$iterations" } + } } } @@ -39,6 +47,12 @@ if ($RunGpuSmokeTest) { if ($LASTEXITCODE -ne 0) { throw 'WARP test compilation failed' } & $exe if ($LASTEXITCODE -ne 0) { throw 'WARP capture-normalization pixel test failed' } + $exe = Join-Path $temp 'CaptureBenchmarkTest.exe' + $obj = Join-Path $temp 'CaptureBenchmarkTest.obj' + & cl.exe '/nologo' '/std:c++20' '/EHsc' '/W4' '/WX' "/Fe$exe" "/Fo$obj" "$neo/tests/CaptureBenchmarkTest.cpp" + if ($LASTEXITCODE -ne 0) { throw 'Benchmark statistics test compilation failed' } + & $exe + if ($LASTEXITCODE -ne 0) { throw 'Benchmark statistics test failed' } } finally { Remove-Item -Recurse -Force $temp } From 2780a101549cd9474057092e9619463c6c6c560b Mon Sep 17 00:00:00 2001 From: Defisym Date: Wed, 11 Nov 2026 23:50:18 +0800 Subject: [PATCH 15/23] NeoPic: compile benchmark instrumentation out behind a disabled master switch --- Extensions/NeoPic/CaptureTesting.md | 8 ++++-- Extensions/NeoPic/FrameCapture.h | 10 ++++--- Extensions/NeoPic/FrameCaptureDX11.h | 32 +++++++++++++++++++--- Extensions/NeoPic/Macro.h | 15 ++++++++-- Extensions/NeoPic/Main.cpp | 16 +++++++++-- Extensions/NeoPic/tests/CompileCapture.ps1 | 5 ++-- 6 files changed, 68 insertions(+), 18 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 99c4f6e1d..2ac955a8a 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -159,13 +159,17 @@ In `Macro.h`, select one backend and change the benchmark definitions, then ```cpp #define NEOPIC_CAPTURE_BACKEND 0 // Repeat separately with 1 and 2. +#define NEOPIC_CAPTURE_BENCHMARK 1 // Default is 0: normal single capture. #define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 100 #define NEOPIC_CAPTURE_BENCHMARK_WARMUP 10 ``` The existing guarded defaults allow project-wide compiler definitions instead. -`ITERATIONS=0` (the default) restores the ordinary single-capture action with no -benchmark timer. No ACE IDs or MFA changes are needed. +`NEOPIC_CAPTURE_BENCHMARK=0` (the default) compiles out the benchmark loop, +timer, statistics and benchmark-only capture state. The action captures once, +even if `ITERATIONS` remains 100 and `WARMUP` remains 10. Set the master switch to +1 to enable measurement; `ITERATIONS=0` also keeps the ordinary action. +Rebuild after changing the switch. No ACE IDs or MFA changes are needed. 1. Use the same application, logical viewport, client size/DPI, resize filter, effects, GPU, NeoPic HWA/library settings and Release build for all three runs. diff --git a/Extensions/NeoPic/FrameCapture.h b/Extensions/NeoPic/FrameCapture.h index e8f888261..eb8bb09d0 100644 --- a/Extensions/NeoPic/FrameCapture.h +++ b/Extensions/NeoPic/FrameCapture.h @@ -10,9 +10,11 @@ struct FrameCapture { int frameWidth = -1; int frameHeight = -1; +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED HRESULT lastResult = S_OK; void SetBenchmarking(bool) {} HRESULT GetLastResult() const { return lastResult; } +#endif FrameCapture(LPRDATA rdPtr) { this->hFrameWindowHandle = rdPtr->rHo.hoAdRunHeader->rhHEditWin; @@ -55,7 +57,7 @@ struct FrameCapture { frameHeight = frameRect.bottom - frameRect.top; int outputWidth = frameWidth, outputHeight = frameHeight; -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED // Compare the same area at the same logical output size as GPU backends. outputWidth = rdPtr->rHo.hoAdRunHeader->rh3.rh3WindowSx; outputHeight = rdPtr->rHo.hoAdRunHeader->rh3.rh3WindowSy; @@ -73,7 +75,7 @@ struct FrameCapture { _AddAlpha(pCaptureSf); pCaptureDC = pCaptureSf->GetDC(); -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED const auto app = rdPtr->rHo.hoAdRunHeader->rhApp; const bool resample = app->m_pHdr2 && (app->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH); if (pCaptureDC) { @@ -90,12 +92,12 @@ struct FrameCapture { bool ok = hdcWindow && NewSurface(rdPtr); if (ok) ok = StretchBlt(pCaptureDC, 0, 0, pCaptureSf->GetWidth(), pCaptureSf->GetHeight(), hdcWindow, 0, 0, frameWidth, frameHeight, SRCCOPY) != FALSE; -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED // GDI batching must not look faster than an already GPU-complete result. if (ok) ok = GdiFlush() != FALSE; -#endif const DWORD error = ok ? ERROR_SUCCESS : GetLastError(); lastResult = ok ? S_OK : (error ? HRESULT_FROM_WIN32(error) : E_FAIL); +#endif return ok; } diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index ec28274f5..2e999fd8c 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -22,8 +22,10 @@ struct FrameCapture { Microsoft::WRL::ComPtr sourceTexture; CaptureCompletion completion; CaptureBlit blit; +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED bool benchmarking = false; HRESULT lastResult = S_OK; +#endif #if NEOPIC_CAPTURE_BACKEND == 2 bool wgcStarted = false; bool startAttempted = false; @@ -33,7 +35,9 @@ struct FrameCapture { #endif void Report(HRESULT hr, const char* stage) { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED if (benchmarking) return; +#endif char message[256]; sprintf_s(message, "NeoPic capture backend=%d %s hr=0x%08lX\n", NEOPIC_CAPTURE_BACKEND, stage, static_cast(hr)); @@ -54,11 +58,15 @@ struct FrameCapture { void ReleaseCopySource() { sourceTexture.Reset(); #if NEOPIC_CAPTURE_BACKEND == 2 - if (!benchmarking) wgc.ReleaseCurrent(); +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED + if (!benchmarking) +#endif + wgc.ReleaseCurrent(); #endif completion.Reset(); } +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED void SetBenchmarking(bool enabled) { if (!enabled && benchmarking) { if (completion.InFlight()) completion.Wait(); @@ -67,6 +75,7 @@ struct FrameCapture { } else benchmarking = enabled; } HRESULT GetLastResult() const { return lastResult; } +#endif #if NEOPIC_CAPTURE_BACKEND == 2 HRESULT StartWgc(LPRDATA rdPtr, bool retry) { @@ -77,7 +86,11 @@ struct FrameCapture { if (!IsWindow(frame) || !IsWindowVisible(frame) || !IsWindowVisible(root)) return E_PENDING; if (startAttempted && !retry) return E_PENDING; startAttempted = true; +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED const HRESULT hr = wgc.Start(frame, static_cast(GetD3D11Device(rdPtr)), !benchmarking); +#else + const HRESULT hr = wgc.Start(frame, static_cast(GetD3D11Device(rdPtr))); +#endif wgcStarted = SUCCEEDED(hr); Report(hr, "start"); return hr; @@ -107,7 +120,10 @@ struct FrameCapture { const int sourceWidth = source->GetWidth(), sourceHeight = source->GetHeight(); POINT origin{}; source->GetOrigin(origin); - if (!benchmarking) { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED + if (!benchmarking) +#endif + { 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, @@ -123,7 +139,10 @@ struct FrameCapture { || origin.x != 0 || origin.y != 0 || sourceWidth != rh->rh3.rh3WindowSx || sourceHeight != rh->rh3.rh3WindowSy || static_cast(width) * sourceHeight != static_cast(height) * sourceWidth) { - if (!benchmarking) { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED + if (!benchmarking) +#endif + { 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, @@ -156,7 +175,10 @@ struct FrameCapture { // Client pixels identify the capture AREA, not NeoPic's scene-unit size. const int width = rh->rh3.rh3WindowSx, height = rh->rh3.rh3WindowSy; if (width <= 0 || height <= 0) return E_INVALIDARG; - if (!benchmarking) { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED + if (!benchmarking) +#endif + { D3D11_TEXTURE2D_DESC input{}; source->GetDesc(&input); char details[320]; @@ -215,7 +237,9 @@ struct FrameCapture { } ReleaseCopySource(); if (FAILED(hr)) snapshot.reset(); +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED lastResult = hr; +#endif Report(hr, SUCCEEDED(hr) ? "ready (synchronous GPU completion)" : "failed (image unchanged)"); return SUCCEEDED(hr); } diff --git a/Extensions/NeoPic/Macro.h b/Extensions/NeoPic/Macro.h index fc21f83c6..f98495544 100644 --- a/Extensions/NeoPic/Macro.h +++ b/Extensions/NeoPic/Macro.h @@ -15,10 +15,17 @@ #error Invalid NEOPIC_CAPTURE_BACKEND #endif -// 0 keeps the normal single capture action. Set to e.g. 100 for a loop inside -// ONE action invocation. Each sample includes capture, synchronization and load. +// Master switch: 0 compiles out benchmark instrumentation and captures once. +// Set to 1 for a loop inside ONE action invocation. Rebuild the whole project. +#ifndef NEOPIC_CAPTURE_BENCHMARK +#define NEOPIC_CAPTURE_BENCHMARK 0 +#endif +#if NEOPIC_CAPTURE_BENCHMARK != 0 && NEOPIC_CAPTURE_BENCHMARK != 1 +#error Invalid NEOPIC_CAPTURE_BENCHMARK +#endif +// Counts are retained while the master switch is off. #ifndef NEOPIC_CAPTURE_BENCHMARK_ITERATIONS -#define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 0 +#define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 100 #endif #ifndef NEOPIC_CAPTURE_BENCHMARK_WARMUP #define NEOPIC_CAPTURE_BENCHMARK_WARMUP 10 @@ -29,6 +36,8 @@ #if NEOPIC_CAPTURE_BENCHMARK_WARMUP < 0 || NEOPIC_CAPTURE_BENCHMARK_WARMUP > 10000 #error Invalid NEOPIC_CAPTURE_BENCHMARK_WARMUP #endif +// Derived condition; a zero iteration count also keeps the ordinary action. +#define NEOPIC_CAPTURE_BENCHMARK_ENABLED (NEOPIC_CAPTURE_BENCHMARK && NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0) // only enable when needed to speed up build #define PERSPECTIVE_TRANSFORMATION diff --git a/Extensions/NeoPic/Main.cpp b/Extensions/NeoPic/Main.cpp index 309b99b43..a5e39b4bf 100644 --- a/Extensions/NeoPic/Main.cpp +++ b/Extensions/NeoPic/Main.cpp @@ -8,7 +8,7 @@ // Common Include #include "common.h" -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED #include "CaptureBenchmark.h" #include "CaptureCompletion.h" #endif @@ -871,12 +871,18 @@ short WINAPI DLLExport Action_SaveToFile(LPRDATA rdPtr, long param1, long param2 } short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long param2) { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED int outputWidth = 0, outputHeight = 0; HRESULT iterationError = S_OK; +#endif auto captureOnce = [&]() { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED iterationError = S_OK; +#endif if (!rdPtr->pFrameCapture->Capture(rdPtr)) { +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED iterationError = rdPtr->pFrameCapture->GetLastResult(); +#endif return false; } #if NEOPIC_CAPTURE_BACKEND == 0 @@ -888,17 +894,21 @@ short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long #if NEOPIC_CAPTURE_BACKEND != 0 delete surface; // TakeSurface transferred ownership even on an invalid result. #endif +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED iterationError = E_FAIL; +#endif return false; } +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED outputWidth = surface->GetWidth(); outputHeight = surface->GetHeight(); +#endif #if NEOPIC_CAPTURE_BACKEND == 0 LoadFromPointer(rdPtr, L"_TempCapture", surface); #else LoadFromPointer(rdPtr, L"_TempCapture", surface, LoadFromPointerFlags_TakeOwnership); #endif -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 && NEOPIC_CAPTURE_BACKEND == 0 +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED && NEOPIC_CAPTURE_BACKEND == 0 // Include the default path's CPU->GPU upload, not just the DIB copy. if (rdPtr->HWA && D3D11(rdPtr)) { const auto info = GetD3D11Info(rdPtr); @@ -913,7 +923,7 @@ short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long #endif return true; }; -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0 +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED LARGE_INTEGER frequency{}; if (!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { OutputDebugStringA("NeoPic capture benchmark: QPC unavailable; doing one normal capture\n"); diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 index cfeeccb1d..3f029fb99 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -21,8 +21,9 @@ if (-not $NativeOnly -and -not (Test-Path "$repo/Inc/ccxhdr.h")) { } foreach ($backend in $Backends) { foreach ($iterations in $BenchmarkIterations) { - Write-Host "Checking capture backend $backend benchmark iterations=$iterations" - $defines = @("/DNEOPIC_CAPTURE_BACKEND=$backend", "/DNEOPIC_CAPTURE_BENCHMARK_ITERATIONS=$iterations") + $benchmark = [int]($iterations -gt 0) + Write-Host "Checking capture backend $backend benchmark=$benchmark iterations=$iterations" + $defines = @("/DNEOPIC_CAPTURE_BACKEND=$backend", "/DNEOPIC_CAPTURE_BENCHMARK=$benchmark", "/DNEOPIC_CAPTURE_BENCHMARK_ITERATIONS=$iterations") # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. & cl.exe @common '/Zc:wchar_t' @defines "$neo/WgcCapture.cpp" if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend benchmark=$iterations" } From 41afcbd1af7a11ddcd3fcbdb16779498906b2ce7 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 00:07:32 +0800 Subject: [PATCH 16/23] NeoPic: diagnose conditional secondary sources and document blank complex scenes --- Extensions/NeoPic/CaptureTesting.md | 22 ++++++++++++++++++++++ Extensions/NeoPic/FrameCaptureDX11.h | 26 ++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 2ac955a8a..8b6bfc4b4 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -40,6 +40,28 @@ secondary Surface at all. The author confirmed both experimental backends now produce the correct image in the tested Fusion 296 / DX11 application; other rendering configurations still require validation. +**Complex-project regression:** the author subsequently reported a blank mode 1 +image in a complex project. Mode 1 is therefore not established as a general +capture solution. The SDK describes `m_pSecondarySurface` as the render target +used for background or Frame effects, rather than a guaranteed last-presented +image. `m_bAlwaysUseSecondarySurface` exists on both the app and frame; its state +is logged for diagnosis, not used as proof of valid final pixels. The app's +`m_pOldSecondarySurface` is for frame transitions and is not substituted as a +previous-display-frame buffer. + +For this report, first distinguish `failed (image unchanged)` from `ready`: +Frame effects/transforms and source/viewport mismatches are rejected by the +current code, so an initially empty NeoPic can remain blank without any pixels +being copied. `ready` only establishes completion of the GPU operation; it does +not prove the source contained a complete scene. Send the `secondary state`, +`secondary logical`, `secondary rejected` (if present), and final `ready`/`failed` +lines from the same action with the benchmark disabled. The new state line logs +both always-secondary flags, the current/old-transition surface identities, +layer-effect-data presence and whether app/frame pointers agree. A non-null +layer-effect-data pointer does not establish that a layer effect is active. +Do not enable always-secondary flags or fall back to an unrelated canvas merely +to hide this failure. No black-pixel heuristic is used: a black frame can be valid. + ## Changes after the first Windows test The first mode 1 implementation allocated its output from a native texture's diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index 2e999fd8c..602db7bea 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -111,9 +111,31 @@ struct FrameCapture { HRESULT CopySecondary(LPRDATA rdPtr) { auto rh = rdPtr->rHo.hoAdRunHeader; auto frame = rh->rhFrame; + if (!frame) return E_PENDING; auto source = frame->m_pSecondarySurface; - if (!source || !source->IsValid()) return E_PENDING; - if (source->GetDriver() != SD_D3D11 || source->GetType() != ST_HWA_RTTEXTURE) return E_NOTIMPL; +#if NEOPIC_CAPTURE_BENCHMARK_ENABLED + if (!benchmarking) +#endif + { + 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; From 67b06fa1ee9cb19799765c3b99f5a9a6cd837165 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 00:20:21 +0800 Subject: [PATCH 17/23] NeoPic: stretch independent capture source dimensions to output like DC --- Extensions/NeoPic/CaptureTesting.md | 54 ++++++++++++++++--- Extensions/NeoPic/FrameCaptureDX11.h | 11 ++-- .../NeoPic/tests/CaptureBlitWarpTest.cpp | 29 ++++++++++ 3 files changed, 81 insertions(+), 13 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 8b6bfc4b4..3899c806b 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -32,17 +32,21 @@ current-display-frame requirement. If no frame is already available, the action fails immediately with `E_PENDING`; it does not wait for future frame arrival. Secondary is also not a documented final-window buffer. Mode 1 rejects a missing -or non-RTT source, a logical size different from the runtime viewport, nonzero -origin, an aspect ratio mismatch, Frame effects, or nonidentity Frame transforms whose final composition +or non-RTT source, nonzero origin, Frame effects, or nonidentity Frame transforms whose final composition cannot be established. These are explicit failures, not a fallback to DC and not proof of support for every rendering configuration. A default scene may have no secondary Surface at all. The author confirmed both experimental backends now produce the correct image in the tested Fusion 296 / DX11 application; other rendering configurations still require validation. -**Complex-project regression:** the author subsequently reported a blank mode 1 -image in a complex project. Mode 1 is therefore not established as a general -capture solution. The SDK describes `m_pSecondarySurface` as the render target +**Resize regression:** after a temporary recovery on reloading, the author +identified a repeatable trigger: fixed resolution presets work, but dragging a +ratio-preserving window causes failure while the game still renders at 1080p. +The old checks required an exact client/source aspect ratio and identical +source/viewport dimensions. These constraints are now removed; source and output +sizes are independent, matching the DC path's full-rectangle stretch operation. +The Windows application retest after this change is still pending. Mode 1 is not +yet established as a general final-composition capture solution. The SDK describes `m_pSecondarySurface` as the render target used for background or Frame effects, rather than a guaranteed last-presented image. `m_bAlwaysUseSecondarySurface` exists on both the app and frame; its state is logged for diagnosis, not used as proof of valid final pixels. The app's @@ -50,8 +54,8 @@ is logged for diagnosis, not used as proof of valid final pixels. The app's previous-display-frame buffer. For this report, first distinguish `failed (image unchanged)` from `ready`: -Frame effects/transforms and source/viewport mismatches are rejected by the -current code, so an initially empty NeoPic can remain blank without any pixels +Frame effects/transforms, invalid sources and nonzero origins are rejected by +the current code, so an initially empty NeoPic can remain blank without any pixels being copied. `ready` only establishes completion of the GPU operation; it does not prove the source contained a complete scene. Send the `secondary state`, `secondary logical`, `secondary rejected` (if present), and final `ready`/`failed` @@ -62,6 +66,38 @@ layer-effect-data pointer does not establish that a layer effect is active. Do not enable always-secondary flags or fall back to an unrelated canvas merely to hide this failure. No black-pixel heuristic is used: a black frame can be valid. +## Window resizing and DC-style output scaling + +The full valid source rectangle is scaled to the requested output Surface size. +Client size, source Surface size, native allocation size and output size are not +interchangeable: + +- Secondary reads `[0, source->GetWidth()) x [0, source->GetHeight())`, excluding + native texture padding. Its logical source size may differ from the viewport. +- WGC reads the complete cropped `rhHEditWin` client area, as before. +- Both GPU paths stretch the full source rectangle to the output's logical + viewport dimensions, with the existing point/linear filter preference. They + do not crop the image to force a matching aspect ratio or allocate output from + the current client size/native padding. GPU completion and `_TempCapture` + ownership handoff remain synchronous. + +For example, a 1920x1080 source remains a 1920x1080 output when a user drags the +window to 1281x720, 1365x768 or another client size. A differently sized valid +Secondary Surface is scaled to the same output size. Neither different dimensions +nor a different aspect ratio alone causes capture to fail. No one-pixel aspect +tolerance is needed; the behavior follows `StretchBlt`'s independent source and +destination rectangles. Genuine unsupported effects/transforms, invalid sources, +out-of-allocation source regions and unsupported formats still fail explicitly. +This size handling does not establish that every Secondary contains the final +composited client image, including any post-composition borders or effects. + +Retest with the benchmark disabled: capture immediately after selecting each +resolution preset, then after dragging both smaller and larger, and finally after +returning to a preset. Save `_TempCapture` in the same event. Compare the full +scene and its edge markers to the DC reference at the same output size; verify +that the image remains at the logical output dimensions and has no clipped edge +or stale snapshot. Include a drag size with an inexact ratio (e.g. 1281x720). + ## Changes after the first Windows test The first mode 1 implementation allocated its output from a native texture's @@ -130,7 +166,9 @@ statistics tests with MSVC and a native WARP pixel test with the production normalization pass: an 8x8 padded RGBA texture contains only a 4x4 image with zero alpha, which must become a correctly oriented 2x2 opaque BGRA image inside a padded target. The test verifies restored viewport -and topology too. It needs no Fusion runtime or capture permission. The test's +and topology too. The same pass/target is then reused at 6x3, 2x6 and 2x2 outputs +to validate independent source/output aspect ratios, up/down scaling and repeated +size changes. It needs no Fusion runtime or capture permission. The test's staging readback is outside production capture code. The public Windows PR workflow uses `-NativeOnly -RunGpuSmokeTest`: it compiles diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index 602db7bea..90e3e9353 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -106,8 +106,8 @@ struct FrameCapture { #endif #if NEOPIC_CAPTURE_BACKEND == 1 - // Secondary is a Fusion Surface, not a raw texture. Let Fusion's GPU blit - // interpret its type, logical dimensions, UV/padding and pixel format. + // A Surface's logical dimensions describe the valid image independently + // of its native allocation padding and the physical window dimensions. HRESULT CopySecondary(LPRDATA rdPtr) { auto rh = rdPtr->rHo.hoAdRunHeader; auto frame = rh->rhFrame; @@ -153,14 +153,15 @@ struct FrameCapture { 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 - || sourceWidth != rh->rh3.rh3WindowSx || sourceHeight != rh->rh3.rh3WindowSy - || static_cast(width) * sourceHeight != static_cast(height) * sourceWidth) { + || origin.x != 0 || origin.y != 0) { #if NEOPIC_CAPTURE_BENCHMARK_ENABLED if (!benchmarking) #endif diff --git a/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp b/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp index b96ab317c..d86463cd8 100644 --- a/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp +++ b/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp @@ -86,6 +86,35 @@ int main() { 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'; From bbc3acdfbc798857e38fb365d8f409aa7f9b1409 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 09:31:17 +0800 Subject: [PATCH 18/23] NeoPic: select capture backends per object and isolate benchmark subclasses --- Extensions/NeoPic/CaptureBackend.h | 14 ++ Extensions/NeoPic/Common.h | 2 +- Extensions/NeoPic/EditData.h | 71 ++++++ Extensions/NeoPic/Edittime.cpp | 22 ++ Extensions/NeoPic/Ext.rc | 7 + Extensions/NeoPic/FrameCapture.cpp | 38 +++ Extensions/NeoPic/FrameCapture.h | 127 ++-------- Extensions/NeoPic/FrameCaptureBenchmark.h | 133 ++++++++++ Extensions/NeoPic/FrameCaptureDC.h | 64 +++++ Extensions/NeoPic/FrameCaptureDX11.h | 285 +++++++++------------- Extensions/NeoPic/General.cpp | 8 +- Extensions/NeoPic/Macro.h | 33 --- Extensions/NeoPic/Main.cpp | 111 +-------- Extensions/NeoPic/Main.h | 34 +-- Extensions/NeoPic/NeoPic.vcxproj | 5 + Extensions/NeoPic/Resource.h | 7 + Extensions/NeoPic/Runtime.cpp | 7 +- Extensions/NeoPic/WgcCapture.cpp | 3 - 18 files changed, 507 insertions(+), 464 deletions(-) create mode 100644 Extensions/NeoPic/CaptureBackend.h create mode 100644 Extensions/NeoPic/EditData.h create mode 100644 Extensions/NeoPic/FrameCapture.cpp create mode 100644 Extensions/NeoPic/FrameCaptureBenchmark.h create mode 100644 Extensions/NeoPic/FrameCaptureDC.h diff --git a/Extensions/NeoPic/CaptureBackend.h b/Extensions/NeoPic/CaptureBackend.h new file mode 100644 index 000000000..09122f2ae --- /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/Common.h b/Extensions/NeoPic/Common.h index e9a230194..456579a9a 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 000000000..526b59b48 --- /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 66e2dd38e..8ae3d4520 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 33edfee27..fea8bc288 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+). 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 000000000..9ef38f981 --- /dev/null +++ b/Extensions/NeoPic/FrameCapture.cpp @@ -0,0 +1,38 @@ +#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; +} + +std::unique_ptr CreateFrameCapture(LPRDATA rdPtr, CaptureBackend backend) { + switch (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 eb8bb09d0..4ffad2c61 100644 --- a/Extensions/NeoPic/FrameCapture.h +++ b/Extensions/NeoPic/FrameCapture.h @@ -1,108 +1,25 @@ #pragma once - -#if NEOPIC_CAPTURE_BACKEND == 0 -struct FrameCapture { - LPSURFACE pCaptureSf = nullptr; - HDC pCaptureDC = nullptr; - - HWND hFrameWindowHandle = nullptr; - HDC hdcWindow = nullptr; - - int frameWidth = -1; - int frameHeight = -1; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - HRESULT lastResult = S_OK; - void SetBenchmarking(bool) {} - HRESULT GetLastResult() const { return lastResult; } -#endif - - FrameCapture(LPRDATA rdPtr) { - this->hFrameWindowHandle = rdPtr->rHo.hoAdRunHeader->rhHEditWin; - } - ~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 bool NewSurface(LPRDATA rdPtr) { - RECT frameRect; - if (!::GetWindowRect(hFrameWindowHandle, &frameRect)) return false; - frameWidth = frameRect.right - frameRect.left; - frameHeight = frameRect.bottom - frameRect.top; - - int outputWidth = frameWidth, outputHeight = frameHeight; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - // Compare the same area at the same logical output size as GPU backends. - outputWidth = rdPtr->rHo.hoAdRunHeader->rh3.rh3WindowSx; - outputHeight = rdPtr->rHo.hoAdRunHeader->rh3.rh3WindowSy; -#else - (void)rdPtr; -#endif - if (frameWidth <= 0 || frameHeight <= 0 || outputWidth <= 0 || outputHeight <= 0) return false; - if (pCaptureSf == nullptr - || pCaptureSf->GetWidth() != outputWidth - || pCaptureSf->GetHeight() != outputHeight) { - ReleaseSurface(); - - pCaptureSf = CreateSurface(24, outputWidth, outputHeight); - if (!pCaptureSf || !pCaptureSf->IsValid()) return false; - _AddAlpha(pCaptureSf); - - pCaptureDC = pCaptureSf->GetDC(); -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - const auto app = rdPtr->rHo.hoAdRunHeader->rhApp; - const bool resample = app->m_pHdr2 && (app->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH); - if (pCaptureDC) { - SetStretchBltMode(pCaptureDC, resample ? HALFTONE : COLORONCOLOR); - SetBrushOrgEx(pCaptureDC, 0, 0, nullptr); - } -#endif - } - return pCaptureDC != nullptr; - } - - bool Capture(LPRDATA rdPtr) { - NewDC(); - bool ok = hdcWindow && NewSurface(rdPtr); - if (ok) ok = StretchBlt(pCaptureDC, 0, 0, pCaptureSf->GetWidth(), pCaptureSf->GetHeight(), - hdcWindow, 0, 0, frameWidth, frameHeight, SRCCOPY) != FALSE; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - // GDI batching must not look faster than an already GPU-complete result. - if (ok) ok = GdiFlush() != FALSE; - const DWORD error = ok ? ERROR_SUCCESS : GetLastError(); - lastResult = ok ? S_OK : (error ? HRESULT_FROM_WIN32(error) : E_FAIL); -#endif - return ok; - } - +#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); }; -#else -#include "FrameCaptureDX11.h" -#endif +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 000000000..5fcaf968e --- /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 (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 000000000..9648d9412 --- /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 index 90e3e9353..f2f7423ba 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -1,121 +1,110 @@ #pragma once -// The capture adapter uses the NeoPic/Fusion SDK preamble. Keep WgcCapture.cpp -// independent: it includes WgcCapture.h, never this legacy-wchar_t adapter. +// Native WGC implementation remains isolated in its native-wchar_t .cpp. #include "Common.h" - -#if NEOPIC_CAPTURE_BACKEND != 0 - #include #include -#include -#include #include "CaptureCompletion.h" #include "CaptureBlit.h" -#if NEOPIC_CAPTURE_BACKEND == 2 #include "WgcCapture.h" -#endif -// Experimental paths deliberately do not fall back to DC or read pixels to CPU. -struct FrameCapture { - std::unique_ptr snapshot; +class Dx11FrameCapture : public FrameCapture { Microsoft::WRL::ComPtr sourceTexture; CaptureCompletion completion; CaptureBlit blit; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - bool benchmarking = false; - HRESULT lastResult = S_OK; -#endif -#if NEOPIC_CAPTURE_BACKEND == 2 - bool wgcStarted = false; - bool startAttempted = false; -#endif -#if NEOPIC_CAPTURE_BACKEND == 2 - WgcCapture wgc; -#endif +protected: + using Texture = Microsoft::WRL::ComPtr; + explicit Dx11FrameCapture(CaptureBackend backend) : FrameCapture(backend) {} + virtual HRESULT ReadSource(LPRDATA rdPtr, Texture& texture, D3D11_BOX& region) = 0; - void Report(HRESULT hr, const char* stage) { -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - if (benchmarking) return; -#endif - char message[256]; - sprintf_s(message, "NeoPic capture backend=%d %s hr=0x%08lX\n", - NEOPIC_CAPTURE_BACKEND, stage, static_cast(hr)); - OutputDebugStringA(message); - } - - explicit FrameCapture(LPRDATA rdPtr) { (void)rdPtr; } - - ~FrameCapture() { + // Borrowed source frames must outlive their submitted GPU work. + void Drain() { if (completion.InFlight()) { - Report(S_OK, "teardown waiting for GPU"); const HRESULT hr = completion.WaitForTeardown(); if (FAILED(hr)) Report(hr, "teardown GPU failure"); } - ReleaseCopySource(); - } - - void ReleaseCopySource() { sourceTexture.Reset(); -#if NEOPIC_CAPTURE_BACKEND == 2 -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - if (!benchmarking) -#endif - wgc.ReleaseCurrent(); -#endif completion.Reset(); + ReleaseInput(); } -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - void SetBenchmarking(bool enabled) { - if (!enabled && benchmarking) { - if (completion.InFlight()) completion.Wait(); - benchmarking = false; - ReleaseCopySource(); - } else benchmarking = enabled; - } - HRESULT GetLastResult() const { return lastResult; } -#endif - -#if NEOPIC_CAPTURE_BACKEND == 2 - HRESULT StartWgc(LPRDATA rdPtr, bool retry) { - if (wgcStarted) 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; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - const HRESULT hr = wgc.Start(frame, static_cast(GetD3D11Device(rdPtr)), !benchmarking); -#else - const HRESULT hr = wgc.Start(frame, static_cast(GetD3D11Device(rdPtr))); -#endif - wgcStarted = SUCCEEDED(hr); - Report(hr, "start"); + 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; } - // Warm the asynchronous WGC producer, but never schedule a screenshot here. - void Update(LPRDATA rdPtr) { - if (FAILED(StartWgc(rdPtr, false))) return; - Microsoft::WRL::ComPtr ignored; - D3D11_BOX crop{}; - wgc.LatestTexture(ignored.GetAddressOf(), &crop); + 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; } -#endif -#if NEOPIC_CAPTURE_BACKEND == 1 +public: + ~Dx11FrameCapture() override { Drain(); } +}; + +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 CopySecondary(LPRDATA rdPtr) { + 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 NEOPIC_CAPTURE_BENCHMARK_ENABLED - if (!benchmarking) -#endif + if (DiagnosticsEnabled()) { const auto app = rh->rhApp; char state[384]; @@ -142,9 +131,7 @@ struct FrameCapture { const int sourceWidth = source->GetWidth(), sourceHeight = source->GetHeight(); POINT origin{}; source->GetOrigin(origin); -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - if (!benchmarking) -#endif + 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", @@ -162,9 +149,7 @@ struct FrameCapture { || 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 NEOPIC_CAPTURE_BENCHMARK_ENABLED - if (!benchmarking) -#endif + 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", @@ -179,7 +164,6 @@ struct FrameCapture { // 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. - Microsoft::WRL::ComPtr native; { RenderHelper reading{source, FALSE}; const auto info = GetD3D11SurfaceInfo(source); @@ -187,87 +171,44 @@ struct FrameCapture { native = *static_cast(info.m_ppD3D11RenderTargetTexture); } if (!native) return E_POINTER; - const D3D11_BOX region{0, 0, 0, static_cast(sourceWidth), static_cast(sourceHeight), 1}; - return Normalize(rdPtr, native.Get(), region); + region = {0, 0, 0, static_cast(sourceWidth), static_cast(sourceHeight), 1}; + return S_OK; } -#endif +public: + explicit SecondaryFrameCapture(LPRDATA rdPtr) : Dx11FrameCapture(CaptureBackend::Secondary) { (void)rdPtr; } +}; - HRESULT Normalize(LPRDATA rdPtr, ID3D11Texture2D* source, const D3D11_BOX& region) { - if (!source) return E_POINTER; - auto rh = rdPtr->rHo.hoAdRunHeader; - // Client pixels identify the capture AREA, not NeoPic's scene-unit size. - const int width = rh->rh3.rh3WindowSx, height = rh->rh3.rh3WindowSy; - if (width <= 0 || height <= 0) return E_INVALIDARG; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - if (!benchmarking) -#endif - { - 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); - } - snapshot = std::move(candidate); - sourceTexture = source; - completion.Submit(); // Drain even a failed pass's intermediate GPU work. - return rendered; - } +class WgcFrameCapture : public Dx11FrameCapture { + bool started = false; + bool startAttempted = false; + WgcCapture wgc; - // Synchronous action contract: never defer publication to another Handle. - // A ready WGC image can still be older than the current compositor frame. - bool Capture(LPRDATA rdPtr) { - snapshot.reset(); - HRESULT hr = E_NOTIMPL; - if (D3D11(rdPtr)) { -#if NEOPIC_CAPTURE_BACKEND == 1 - hr = CopySecondary(rdPtr); -#else - hr = StartWgc(rdPtr, true); - if (SUCCEEDED(hr)) { - Microsoft::WRL::ComPtr texture; - D3D11_BOX crop{}; - hr = wgc.LatestTexture(texture.GetAddressOf(), &crop); - if (SUCCEEDED(hr)) hr = Normalize(rdPtr, texture.Get(), crop); - } -#endif - } - if (completion.InFlight()) { - const HRESULT finished = completion.Wait(); - if (FAILED(finished)) hr = finished; - } - ReleaseCopySource(); - if (FAILED(hr)) snapshot.reset(); -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - lastResult = hr; -#endif - Report(hr, SUCCEEDED(hr) ? "ready (synchronous GPU completion)" : "failed (image unchanged)"); - return SUCCEEDED(hr); + 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); } - - LPSURFACE TakeSurface() { return completion.InFlight() ? nullptr : snapshot.release(); } }; - -#endif // NEOPIC_CAPTURE_BACKEND != 0 diff --git a/Extensions/NeoPic/General.cpp b/Extensions/NeoPic/General.cpp index 774fc87d4..1d8d82765 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/Macro.h b/Extensions/NeoPic/Macro.h index f98495544..bbdf5ae3e 100644 --- a/Extensions/NeoPic/Macro.h +++ b/Extensions/NeoPic/Macro.h @@ -6,39 +6,6 @@ // Macro that used in resource file should be defined here -// Experimental capture backends: 0 = existing DC, 1 = Frame secondary, 2 = WGC. -// Set consistently for the entire project and Rebuild after changing it. -#ifndef NEOPIC_CAPTURE_BACKEND -#define NEOPIC_CAPTURE_BACKEND 0 -#endif -#if NEOPIC_CAPTURE_BACKEND < 0 || NEOPIC_CAPTURE_BACKEND > 2 -#error Invalid NEOPIC_CAPTURE_BACKEND -#endif - -// Master switch: 0 compiles out benchmark instrumentation and captures once. -// Set to 1 for a loop inside ONE action invocation. Rebuild the whole project. -#ifndef NEOPIC_CAPTURE_BENCHMARK -#define NEOPIC_CAPTURE_BENCHMARK 0 -#endif -#if NEOPIC_CAPTURE_BENCHMARK != 0 && NEOPIC_CAPTURE_BENCHMARK != 1 -#error Invalid NEOPIC_CAPTURE_BENCHMARK -#endif -// Counts are retained while the master switch is off. -#ifndef NEOPIC_CAPTURE_BENCHMARK_ITERATIONS -#define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 100 -#endif -#ifndef NEOPIC_CAPTURE_BENCHMARK_WARMUP -#define NEOPIC_CAPTURE_BENCHMARK_WARMUP 10 -#endif -#if NEOPIC_CAPTURE_BENCHMARK_ITERATIONS < 0 || NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 100000 -#error Invalid NEOPIC_CAPTURE_BENCHMARK_ITERATIONS -#endif -#if NEOPIC_CAPTURE_BENCHMARK_WARMUP < 0 || NEOPIC_CAPTURE_BENCHMARK_WARMUP > 10000 -#error Invalid NEOPIC_CAPTURE_BENCHMARK_WARMUP -#endif -// Derived condition; a zero iteration count also keeps the ordinary action. -#define NEOPIC_CAPTURE_BENCHMARK_ENABLED (NEOPIC_CAPTURE_BENCHMARK && NEOPIC_CAPTURE_BENCHMARK_ITERATIONS > 0) - // only enable when needed to speed up build #define PERSPECTIVE_TRANSFORMATION diff --git a/Extensions/NeoPic/Main.cpp b/Extensions/NeoPic/Main.cpp index a5e39b4bf..e36317bbf 100644 --- a/Extensions/NeoPic/Main.cpp +++ b/Extensions/NeoPic/Main.cpp @@ -8,10 +8,6 @@ // Common Include #include "common.h" -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED -#include "CaptureBenchmark.h" -#include "CaptureCompletion.h" -#endif // Quick memo: content of the eventInformations arrays // --------------------------------------------------- @@ -871,112 +867,7 @@ short WINAPI DLLExport Action_SaveToFile(LPRDATA rdPtr, long param1, long param2 } short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long param2) { -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - int outputWidth = 0, outputHeight = 0; - HRESULT iterationError = S_OK; -#endif - auto captureOnce = [&]() { -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - iterationError = S_OK; -#endif - if (!rdPtr->pFrameCapture->Capture(rdPtr)) { -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - iterationError = rdPtr->pFrameCapture->GetLastResult(); -#endif - return false; - } -#if NEOPIC_CAPTURE_BACKEND == 0 - const auto surface = rdPtr->pFrameCapture->pCaptureSf; -#else - const auto surface = rdPtr->pFrameCapture->TakeSurface(); -#endif - if (!surface || !surface->IsValid()) { -#if NEOPIC_CAPTURE_BACKEND != 0 - delete surface; // TakeSurface transferred ownership even on an invalid result. -#endif -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - iterationError = E_FAIL; -#endif - return false; - } -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - outputWidth = surface->GetWidth(); - outputHeight = surface->GetHeight(); -#endif -#if NEOPIC_CAPTURE_BACKEND == 0 - LoadFromPointer(rdPtr, L"_TempCapture", surface); -#else - LoadFromPointer(rdPtr, L"_TempCapture", surface, LoadFromPointerFlags_TakeOwnership); -#endif -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED && NEOPIC_CAPTURE_BACKEND == 0 - // Include the default path's CPU->GPU upload, not just the DIB copy. - if (rdPtr->HWA && D3D11(rdPtr)) { - const auto info = GetD3D11Info(rdPtr); - CaptureCompletion uploaded; - iterationError = uploaded.Prepare(static_cast(info.m_pD3D11Device), - static_cast(info.m_pD3D11Context)); - if (FAILED(iterationError)) return false; - uploaded.Submit(); - iterationError = uploaded.Wait(); - if (FAILED(iterationError)) return false; - } -#endif - return true; - }; -#if NEOPIC_CAPTURE_BENCHMARK_ENABLED - LARGE_INTEGER frequency{}; - if (!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { - OutputDebugStringA("NeoPic capture benchmark: QPC unavailable; doing one normal capture\n"); - captureOnce(); - return 0; - } - auto now = []() { LARGE_INTEGER ticks{}; QueryPerformanceCounter(&ticks); return ticks.QuadPart; }; - HRESULT firstFailure = S_OK; - auto measuredCapture = [&]() { - const bool ok = captureOnce(); - if (!ok && firstFailure == S_OK) firstFailure = FAILED(iterationError) ? iterationError : E_FAIL; - return ok; - }; - neopic_capture::BenchmarkResult result; - { - struct Scope { - FrameCapture* capture; - explicit Scope(FrameCapture* p) : capture(p) { capture->SetBenchmarking(true); } - ~Scope() { capture->SetBenchmarking(false); } - } scope(rdPtr->pFrameCapture); - result = neopic_capture::RunBenchmark(NEOPIC_CAPTURE_BENCHMARK_ITERATIONS, - NEOPIC_CAPTURE_BENCHMARK_WARMUP, measuredCapture, now, frequency.QuadPart); - } - 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=%d iterations=%d warmup=%d HWA=%d library=%d viewport=%dx%d client=%ldx%ld output=%dx%d 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", - NEOPIC_CAPTURE_BACKEND, NEOPIC_CAPTURE_BENCHMARK_ITERATIONS, NEOPIC_CAPTURE_BENCHMARK_WARMUP, - rdPtr->HWA ? 1 : 0, rdPtr->isLib ? 1 : 0, rh->rh3.rh3WindowSx, rh->rh3.rh3WindowSy, - client.right - client.left, client.bottom - client.top, outputWidth, outputHeight, 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 ? 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); - } -#else - captureOnce(); -#endif + rdPtr->pFrameCapture->CaptureAndLoad(rdPtr); return 0; } diff --git a/Extensions/NeoPic/Main.h b/Extensions/NeoPic/Main.h index 666ba33d6..e4c032bf9 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 d62a90e8f..a143db052 100644 --- a/Extensions/NeoPic/NeoPic.vcxproj +++ b/Extensions/NeoPic/NeoPic.vcxproj @@ -254,6 +254,7 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + true @@ -308,6 +309,10 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + + + diff --git a/Extensions/NeoPic/Resource.h b/Extensions/NeoPic/Resource.h index 41b982a53..a0bd81efa 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 3a529aaa3..cde622538 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); + rdPtr->pFrameCapture = CreateFrameCapture(rdPtr, + NormalizeCaptureBackend(static_cast(edPtr->captureBackend))).release(); rdPtr->pAI = new AnimationInterface(rdPtr); rdPtr->pNS = new NineSliceInterface(rdPtr); @@ -264,9 +265,7 @@ short WINAPI DLLExport HandleRunObject(LPRDATA rdPtr) At the end of the loop this code will run */ -#if NEOPIC_CAPTURE_BACKEND == 2 - rdPtr->pFrameCapture->Update(rdPtr); // warm WGC only; no deferred capture -#endif + 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 index 7cd279243..42dae485a 100644 --- a/Extensions/NeoPic/WgcCapture.cpp +++ b/Extensions/NeoPic/WgcCapture.cpp @@ -1,5 +1,3 @@ -#include "Macro.h" -#if NEOPIC_CAPTURE_BACKEND == 2 #include "WgcCapture.h" #include "CaptureCrop.h" #include @@ -162,4 +160,3 @@ HRESULT WgcCapture::LatestTexture(ID3D11Texture2D** texture, D3D11_BOX* crop) no return S_OK; } catch (...) { return winrt::to_hresult(); } } -#endif From a26885de58a5e5b6485939b733c535b262ceaca2 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 09:31:29 +0800 Subject: [PATCH 19/23] NeoPic: verify capture inheritance and edit-data migration --- .github/workflows/neopic-capture-compile.yml | 2 +- Extensions/NeoPic/CaptureTesting.md | 446 +++++++----------- Extensions/NeoPic/tests/CaptureAdapterMocks.h | 87 ++++ .../NeoPic/tests/CaptureHeaderCompile.cpp | 16 +- .../NeoPic/tests/CaptureInheritanceTest.cpp | 90 ++++ Extensions/NeoPic/tests/CompileCapture.ps1 | 64 +-- .../NeoPic/tests/EditDataUpgradeTest.cpp | 48 ++ 7 files changed, 432 insertions(+), 321 deletions(-) create mode 100644 Extensions/NeoPic/tests/CaptureAdapterMocks.h create mode 100644 Extensions/NeoPic/tests/CaptureInheritanceTest.cpp create mode 100644 Extensions/NeoPic/tests/EditDataUpgradeTest.cpp diff --git a/.github/workflows/neopic-capture-compile.yml b/.github/workflows/neopic-capture-compile.yml index 57e162e1a..821f26047 100644 --- a/.github/workflows/neopic-capture-compile.yml +++ b/.github/workflows/neopic-capture-compile.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Compile independent WGC component; test pixels and benchmark statistics + - name: Check WGC, GPU pixels, capture inheritance and edit-data migration shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 3899c806b..64b5cda0a 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -1,299 +1,171 @@ -# Experimental DX11 capture of Fusion's child client area +# NeoPic frame capture -Select `NEOPIC_CAPTURE_BACKEND` in `Macro.h` and **Rebuild the whole project**. -The default is `0`. Existing ACE IDs and saved object properties are unchanged. +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. -| Value | Source | Behavior | +| Property value | Backend | Source | | --- | --- | --- | -| `0` | Existing DC of `rhHEditWin` | Original baseline | -| `1` | Frame secondary Surface | Synchronous RGB normalization pass, with conservative final-composition checks | -| `2` | Windows Graphics Capture | Synchronous copy of an already available WGC frame, cropped to `rhHEditWin`'s client area | - -## Required contract and current limits - -A successful Capture Frame Area action must finish its GPU operation and load the -result through `LoadFromPointer(..., L"_TempCapture", ..., TakeOwnership)` **before -returning**. Show or Save may follow in the same event. No screenshot request is -deferred to another Handle. The action waits on a DX11 event query after one -asynchronous Flush; CPU bitmap conversion is not used to force completion. This -wait can cost time and is not a zero-latency/real-time deadline guarantee. - -The output area is the child client area of the same `rhHEditWin` used by the DC -baseline, not the whole top-level window. The image's **logical size** is the -runtime viewport (`rh3WindowSx/rh3WindowSy`), not the physical client pixel size. -Putting a physical-pixel image into a scaled Fusion scene caused the oversized -mode 2 result reported in Windows testing. WGC must capture the root window because -of its API, then crops to that child's physical client rectangle. - -**WGC cannot guarantee the exact current Fusion Display frame.** Waiting for the -GPU copy does not make an older compositor image fresh. Its producer is inherently -asynchronous, so mode 2 remains an experiment rather than a solution to a strict -current-display-frame requirement. If no frame is already available, the action -fails immediately with `E_PENDING`; it does not wait for future frame arrival. - -Secondary is also not a documented final-window buffer. Mode 1 rejects a missing -or non-RTT source, nonzero origin, Frame effects, or nonidentity Frame transforms whose final composition -cannot be established. These are explicit failures, not a fallback to DC and not -proof of support for every rendering configuration. A default scene may have no -secondary Surface at all. The author confirmed both experimental backends now -produce the correct image in the tested Fusion 296 / DX11 application; other -rendering configurations still require validation. - -**Resize regression:** after a temporary recovery on reloading, the author -identified a repeatable trigger: fixed resolution presets work, but dragging a -ratio-preserving window causes failure while the game still renders at 1080p. -The old checks required an exact client/source aspect ratio and identical -source/viewport dimensions. These constraints are now removed; source and output -sizes are independent, matching the DC path's full-rectangle stretch operation. -The Windows application retest after this change is still pending. Mode 1 is not -yet established as a general final-composition capture solution. The SDK describes `m_pSecondarySurface` as the render target -used for background or Frame effects, rather than a guaranteed last-presented -image. `m_bAlwaysUseSecondarySurface` exists on both the app and frame; its state -is logged for diagnosis, not used as proof of valid final pixels. The app's -`m_pOldSecondarySurface` is for frame transitions and is not substituted as a -previous-display-frame buffer. - -For this report, first distinguish `failed (image unchanged)` from `ready`: -Frame effects/transforms, invalid sources and nonzero origins are rejected by -the current code, so an initially empty NeoPic can remain blank without any pixels -being copied. `ready` only establishes completion of the GPU operation; it does -not prove the source contained a complete scene. Send the `secondary state`, -`secondary logical`, `secondary rejected` (if present), and final `ready`/`failed` -lines from the same action with the benchmark disabled. The new state line logs -both always-secondary flags, the current/old-transition surface identities, -layer-effect-data presence and whether app/frame pointers agree. A non-null -layer-effect-data pointer does not establish that a layer effect is active. -Do not enable always-secondary flags or fall back to an unrelated canvas merely -to hide this failure. No black-pixel heuristic is used: a black frame can be valid. - -## Window resizing and DC-style output scaling - -The full valid source rectangle is scaled to the requested output Surface size. -Client size, source Surface size, native allocation size and output size are not -interchangeable: - -- Secondary reads `[0, source->GetWidth()) x [0, source->GetHeight())`, excluding - native texture padding. Its logical source size may differ from the viewport. -- WGC reads the complete cropped `rhHEditWin` client area, as before. -- Both GPU paths stretch the full source rectangle to the output's logical - viewport dimensions, with the existing point/linear filter preference. They - do not crop the image to force a matching aspect ratio or allocate output from - the current client size/native padding. GPU completion and `_TempCapture` - ownership handoff remain synchronous. - -For example, a 1920x1080 source remains a 1920x1080 output when a user drags the -window to 1281x720, 1365x768 or another client size. A differently sized valid -Secondary Surface is scaled to the same output size. Neither different dimensions -nor a different aspect ratio alone causes capture to fail. No one-pixel aspect -tolerance is needed; the behavior follows `StretchBlt`'s independent source and -destination rectangles. Genuine unsupported effects/transforms, invalid sources, -out-of-allocation source regions and unsupported formats still fail explicitly. -This size handling does not establish that every Secondary contains the final -composited client image, including any post-composition borders or effects. - -Retest with the benchmark disabled: capture immediately after selecting each -resolution preset, then after dragging both smaller and larger, and finally after -returning to a preset. Save `_TempCapture` in the same event. Compare the full -scene and its edge markers to the DC reference at the same output size; verify -that the image remains at the logical output dimensions and has no clipped edge -or stale snapshot. Include a drag size with an inexact ratio (e.g. 1281x720). - -## Changes after the first Windows test - -The first mode 1 implementation allocated its output from a native texture's -`GetDesc()` size and directly copied its storage. That did not establish the -mapping from the Fusion Surface's logical image to its underlying allocation. -The user reported fragmented images. The exact cause is not established yet. - -The subsequent Surface BlitEx experiment produced a dark/black result in Windows -testing. Both backends now share an explicit GPU normalization pass, modeled on -the existing FFMpeg rendering approach and reusing D3DUtilities/ShaderCompiler.h: - -- Retain the native source before activating/clearing a new destination RTT. -- Copy only the valid source region into a shader-readable texture, excluding - allocation padding. WGC uses the child-client crop; Secondary uses its logical - Surface region, with the existing conservative composition checks. -- Render to a viewport of the Fusion **logical** dimensions. Point/linear - filtering follows the application's resize-filter option. -- Write RGB with **alpha = 1**, with blending disabled, to avoid inheriting zero - or partial capture alpha and darkening the visible scene. -- Preserve/restore the entire pipeline with a DX11.1 context-state object. A - missing DX11.1 interface fails explicitly instead of corrupting Fusion state. - -BGRA/RGBA 8-bit RGB channel conversion is handled by SRV sampling and the target -RTV. Unsupported YUV/HDR formats are rejected rather than interpreted as RGB. -The author confirmed the corrected output for both backends in their application. -An empty secondary's RGB cannot be repaired by an opaque output pass. Native/source -crop/output dimensions are logged for comparison when the benchmark is disabled. - -### Yellow capture border - -The yellow border is Windows' WGC capture indicator, not a Fusion window border -or part of the generated RTT. This prototype keeps a WGC session warm so a frame -may already exist when a synchronous action is called, hence the indicator can -remain visible while the object is alive. Disabling it requires supported OS -APIs and borderless-capture access; the prototype does **not** silently disable -it or request additional consent. WGC therefore does not provide an invisible, -exact-frame-synchronous replacement for the DC baseline. - -WGC initialization previously ran in CreateRunObject. A reported VS exception at -`check_hresult(CreateForWindow(...))` was caught, but session startup failed and -no image was captured. Startup is now attempted from Handle only after the child -and root are visible; an explicit action may retry. CreateForWindow failures are -returned directly and logged with the HRESULT, child/root HWNDs and visibility. -Other WinRT failures are caught and logged with their initialization stage. -A debugger can still break on first-chance exceptions inside WinRT before a catch; -continuing and reading the failure log distinguishes this from an unhandled exit. - -## Build - -Use VS2022, Win32, the existing SDK/dependencies and Fusion 296 / DX11. -WGC needs Windows 10 1903+ and a current Windows SDK with C++/WinRT headers -(10.0.19041+ is suitable). Its implementation uses native wchar_t in a separate -translation unit; the Fusion sources retain `/Zc:wchar_t-`. - -From an x86 VS2022 Developer PowerShell in the repository root: +| 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. There is no silent +fallback after a selected GPU backend fails. + +## 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: -```powershell -./Extensions/NeoPic/tests/CompileCapture.ps1 -RunGpuSmokeTest -msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 +```cpp +#include "FrameCaptureBenchmark.h" ``` -The first command syntax-checks all three backend configurations with the -benchmark disabled and enabled (0 and 100 iterations), including a standalone -capture-header translation unit. It also compiles/runs the portable benchmark -statistics tests with MSVC and a native WARP -pixel test with the production normalization pass: an 8x8 padded RGBA texture -contains only a 4x4 image with zero alpha, which must become a correctly oriented -2x2 opaque BGRA image inside a padded target. The test verifies restored viewport -and topology too. The same pass/target is then reused at 6x3, 2x6 and 2x2 outputs -to validate independent source/output aspect ratios, up/down scaling and repeated -size changes. It needs no Fusion runtime or capture permission. The test's -staging readback is outside production capture code. - -The public Windows PR workflow uses `-NativeOnly -RunGpuSmokeTest`: it compiles -the independent WGC translation unit under all three backend macro values, with -the benchmark disabled/enabled, and tests the production pixel pass and benchmark -statistics. It does NOT select Fusion Main/Runtime/header compilation, -since the proprietary SDK headers are intentionally absent from the repository. -The default local command above requires your Inc/ SDK headers and fails if they -are missing. Neither scope proves an MFX link or a successful WGC/Fusion capture. After that, -set the desired macro in Macro.h and Rebuild. Close Fusion, back up the installed -NeoPic MFX, then install `Extensions/NeoPic/Obj/Unicode/Release/NeoPic.mfx` into -Fusion's `Extensions/Unicode`. For exported EXEs also build Run_Only Unicode and -install that MFX into `Data/Runtime/Unicode`. No new MFX is included in this PR. - -## Minimal Windows retest - -1. Keep the same application, window, resolution and effects that reproduced the - issue. Capture mode 0 once as a baseline. Test mode 1 and mode 2 separately. -2. Enable HWA on a normal NeoPic; keep its previous image hidden while capturing - to avoid recursively including it. Add visible markers near each edge of the - middle child area, plus different layer labels and an animated counter. -3. On one keypress, call Capture Frame Area followed immediately by the existing - Save-current-image action with an absolute PNG path. A successful request now - publishes `_TempCapture` during that same action. On failure the old image is - preserved; do not mistake a file of the old image for a successful screenshot. -4. Observe VS native debug output or DebugView. Success ends with - `ready (synchronous GPU completion)`. Failure includes an HRESULT. For mode 1 - send the `secondary logical=... viewport=... rhHEditWin-client=...` line. For - mode 2 send any `WGC CreateForWindow failed ...`/other stage line and the - `native-source=... format=... client=... native-target=... format=...` line. -5. Compare saved PNG content to the middle child area: no title/menu/outer frame, - no missing edges/layers, no fragmented pixels. Record image dimensions too. - When showing the image inside the same scene, global Frame effects may apply - again; use the PNG to distinguish a capture problem from a second effect pass. -6. Let the original scene continue animating. The owned snapshot must remain - unchanged and support the usual NeoPic fade. Note that an event executes - before its subsequent Display: it cannot capture changes that have not yet - been rendered. WGC additionally has compositor/capture latency. - -Return the logs, baseline/experimental PNGs, Windows version, GPU/driver and -actual window/DPI/effect settings. For a debugger exception, the HRESULT is the -error code (e.g. 0x80070057); the KernelBase instruction address is not the HRESULT. - -## Benchmark inside the capture action - -In `Macro.h`, select one backend and change the benchmark definitions, then -**Rebuild** and install the matching MFX as above: +Replace the normal factory call in CreateRunObject with: ```cpp -#define NEOPIC_CAPTURE_BACKEND 0 // Repeat separately with 1 and 2. -#define NEOPIC_CAPTURE_BENCHMARK 1 // Default is 0: normal single capture. -#define NEOPIC_CAPTURE_BENCHMARK_ITERATIONS 100 -#define NEOPIC_CAPTURE_BENCHMARK_WARMUP 10 +rdPtr->pFrameCapture = CreateFrameCaptureBenchmark(rdPtr, + NormalizeCaptureBackend(static_cast(edPtr->captureBackend)), + {100, 10}).release(); // Measured iterations, warmup calls. ``` -The existing guarded defaults allow project-wide compiler definitions instead. -`NEOPIC_CAPTURE_BENCHMARK=0` (the default) compiles out the benchmark loop, -timer, statistics and benchmark-only capture state. The action captures once, -even if `ITERATIONS` remains 100 and `WARMUP` remains 10. Set the master switch to -1 to enable measurement; `ITERATIONS=0` also keeps the ordinary action. -Rebuild after changing the switch. No ACE IDs or MFA changes are needed. - -1. Use the same application, logical viewport, client size/DPI, resize filter, - effects, GPU, NeoPic HWA/library settings and Release build for all three runs. - Keep the NeoPic's displayed screenshot hidden to avoid recursion. Start with - 100 iterations, since the action deliberately blocks events and Display. -2. Let the scene render first; for WGC, allow roughly a second for its session to - warm up. Press a key to call **Capture Frame Area once**. The loop runs inside - that action; no Fusion fast loop or Always event is needed. -3. Copy the `NeoPic capture benchmark ...` summary from VS debug output or - DebugView. Repeat for backends 0, 1 and 2. Compare only runs with matching - `viewport`, `output`, HWA/library and filter settings, and `status=ok`. -4. The last successful image remains available as `_TempCapture`. Show/save it - after the action if desired; writing a file is outside the benchmark. - -Each measured iteration includes Capture, allocation/normalization, synchronous -GPU completion, and the ordinary `LoadFromPointer(..., L"_TempCapture", ...)` -handoff (including replacement of the previous snapshot). For fair output sizes, -the **benchmark-only** DC path scales to the same logical viewport as modes 1/2; -it also flushes GDI and, with NeoPic HWA under DX11, waits for the handoff's GPU -upload. The normal non-benchmark DC action retains its original output sizing. -This compares the complete action path, not just a GPU copy command. - -Normal per-capture debug messages are suppressed during the loop. The summary -reports warmup successes/failures, first-call wall time, measured successes and -failures, measured batch wall time, successful-sample mean/min/median/P95/max, -and completed copies per second. Warmup and summary output are excluded from the -measured batch; `first_ms` is the first call of this batch, not necessarily a cold -process start. P95 uses nearest rank. Failed attempts have separate timing and -never enter successful percentiles. If every warmup attempt fails, measurement -is skipped (`status=no_ready_source`); this is a failed run, not a speed result. - -**There is no new Fusion Display between iterations.** In benchmark mode WGC -retains the latest valid frame until the loop finishes, refreshing it if another -frame is already queued. It can therefore repeatedly copy the same image rather -than report mostly fast `E_PENDING` failures. A successful benchmark measures -snapshot processing throughput, not distinct captured frames per second, source -freshness or display FPS. Timing uses QPC wall time, including CPU work and GPU -waiting, rather than GPU timestamps. CaptureCompletion's existing 1 ms polling -interval can affect these synchronous wall times. No speedup is assumed before -comparing actual logs from the same machine. - -## Regression tests and validation boundaries - -```sh -c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureCropTest.cpp -o capture-crop-test -./capture-crop-test -c++ -std=c++20 -Wall -Wextra -Werror -IExtensions/NeoPic/tests/completion-mocks Extensions/NeoPic/tests/CaptureCompletionTest.cpp -o capture-completion-test -./capture-completion-test -c++ -std=c++20 -Wall -Wextra -Werror Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp -o capture-benchmark-test -./capture-benchmark-test +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 ``` -Crop tests cover client mapping, scaling and negative monitor coordinates. -Completion tests exercise the production helper against small mock interfaces: -query allocation, immediate-context checks, ordered End/Flush, incomplete/complete -results, synchronous waiting, teardown and errors. Mocks are never on the plugin -include path and do not emulate a real GPU. Benchmark tests use an injected clock -and capture callback to check warmup exclusion, failed-sample separation, -percentiles, unavailable sources and input validation. - -The Linux development environment cannot compile/link MSVC or run Fusion/WGC. -Public Windows checks cover the independent components and WARP as described -above, not the proprietary Fusion adapter or an MFX link. The author confirmed -correct images for both experimental backends in their Windows application. -Portable tests also run with GCC, AddressSanitizer and UBSan (leak detection -disabled under ptrace). The benchmark itself still needs application performance -measurements; no speed improvement is claimed. +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. +- 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 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. diff --git a/Extensions/NeoPic/tests/CaptureAdapterMocks.h b/Extensions/NeoPic/tests/CaptureAdapterMocks.h new file mode 100644 index 000000000..2c82906de --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureAdapterMocks.h @@ -0,0 +1,87 @@ +#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_D3D11 = 11, ST_HWA_RTTEXTURE = 5; +constexpr DWORD AH2OPT_RESAMPLESTRETCH = 1; +inline int mockSurfaceCount = 0; +class cSurface { + int width, height; +public: + 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 SD_D3D11; } + 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 { + 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; +}; +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 bool D3D11(LPRDATA) { return true; } +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/CaptureHeaderCompile.cpp b/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp index d7d374fdd..89460bf8f 100644 --- a/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp +++ b/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp @@ -1,3 +1,13 @@ -// Compile without a prior Common.h include. This catches missing SDK preamble -// dependencies and backend-only code leaking into the other configuration. -#include "../FrameCaptureDX11.h" +// 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 000000000..e6c66eab1 --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp @@ -0,0 +1,90 @@ +#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 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/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 index 3f029fb99..5ede302a3 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -1,12 +1,6 @@ -param( - [ValidateSet(0, 1, 2)][int[]]$Backends = @(0, 1, 2), - [ValidateRange(0, 100000)][int[]]$BenchmarkIterations = @(0, 100), - [switch]$RunGpuSmokeTest, - [switch]$NativeOnly -) +param([switch]$RunGpuSmokeTest, [switch]$NativeOnly) $ErrorActionPreference = 'Stop' -# Run in an x86 VS2022 Developer PowerShell. No proprietary SDK .lib is needed -# for this syntax-only check; building/linking the actual .mfx remains separate. +# 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.' } @@ -17,24 +11,26 @@ $common = @('/nologo', '/std:c++20', '/EHsc', '/Zs', '/DWIN32', '/D_WINDOWS', "/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 full plugin syntax checks. -NativeOnly explicitly checks only the independent WGC/GPU components.' + throw 'Fusion SDK headers are required in Inc/ for real SDK syntax checks. -NativeOnly explicitly selects independent native components and test-double coverage.' } -foreach ($backend in $Backends) { - foreach ($iterations in $BenchmarkIterations) { - $benchmark = [int]($iterations -gt 0) - Write-Host "Checking capture backend $backend benchmark=$benchmark iterations=$iterations" - $defines = @("/DNEOPIC_CAPTURE_BACKEND=$backend", "/DNEOPIC_CAPTURE_BENCHMARK=$benchmark", "/DNEOPIC_CAPTURE_BENCHMARK_ITERATIONS=$iterations") - # The PIMPL implementation has native wchar_t; Fusion keeps its existing ABI. - & cl.exe @common '/Zc:wchar_t' @defines "$neo/WgcCapture.cpp" - if ($LASTEXITCODE -ne 0) { throw "WGC translation unit failed for backend $backend benchmark=$iterations" } - if ($NativeOnly) { - Write-Host 'Native-only scope: Fusion Main/Runtime/header syntax checks are NOT selected.' - continue - } - foreach ($source in @('Main.cpp', 'Runtime.cpp', 'tests/CaptureHeaderCompile.cpp')) { - & cl.exe @common '/Zc:wchar_t-' @defines "$neo/$source" - if ($LASTEXITCODE -ne 0) { throw "$source failed for backend $backend benchmark=$iterations" } - } +# 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 } } } @@ -48,12 +44,20 @@ if ($RunGpuSmokeTest) { if ($LASTEXITCODE -ne 0) { throw 'WARP test compilation failed' } & $exe if ($LASTEXITCODE -ne 0) { throw 'WARP capture-normalization pixel test failed' } - $exe = Join-Path $temp 'CaptureBenchmarkTest.exe' - $obj = Join-Path $temp 'CaptureBenchmarkTest.obj' - & cl.exe '/nologo' '/std:c++20' '/EHsc' '/W4' '/WX' "/Fe$exe" "/Fo$obj" "$neo/tests/CaptureBenchmarkTest.cpp" - if ($LASTEXITCODE -ne 0) { throw 'Benchmark statistics test compilation 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 'Benchmark statistics test failed' } + 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 000000000..1198966f0 --- /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"; +} From 84c66de57de44e2bf9680fdd6dcff6e6bd38d87c Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 09:43:13 +0800 Subject: [PATCH 20/23] NeoPic: name capture resources and check real D3D debug-layer lifetimes --- .github/workflows/neopic-capture-compile.yml | 2 +- Extensions/NeoPic/CaptureBlit.h | 25 +++++ Extensions/NeoPic/CaptureTesting.md | 32 ++++++ .../NeoPic/tests/CaptureLifetimeTest.cpp | 97 +++++++++++++++++++ Extensions/NeoPic/tests/CompileCapture.ps1 | 14 +-- 5 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 Extensions/NeoPic/tests/CaptureLifetimeTest.cpp diff --git a/.github/workflows/neopic-capture-compile.yml b/.github/workflows/neopic-capture-compile.yml index 821f26047..f768b9e24 100644 --- a/.github/workflows/neopic-capture-compile.yml +++ b/.github/workflows/neopic-capture-compile.yml @@ -15,7 +15,7 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@v4 - - name: Check WGC, GPU pixels, capture inheritance and edit-data migration + - name: Check WGC, GPU pixels/lifetime, inheritance and edit-data migration shell: pwsh run: | $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" diff --git a/Extensions/NeoPic/CaptureBlit.h b/Extensions/NeoPic/CaptureBlit.h index c99fbc4c2..11edf575f 100644 --- a/Extensions/NeoPic/CaptureBlit.h +++ b/Extensions/NeoPic/CaptureBlit.h @@ -1,9 +1,11 @@ #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. @@ -20,11 +22,21 @@ class CaptureBlit { 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; owner.Reset(); scratch.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; @@ -34,6 +46,7 @@ class CaptureBlit { 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) { @@ -53,8 +66,10 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, } 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; @@ -62,13 +77,16 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 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; @@ -77,6 +95,7 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 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; @@ -86,6 +105,7 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 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; @@ -137,6 +157,8 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 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; @@ -147,6 +169,7 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 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; @@ -157,12 +180,14 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 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; diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 64b5cda0a..a39b05123 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -150,6 +150,11 @@ Public Windows CI uses `-NativeOnly -RunGpuSmokeTest`, with explicit scope: - 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. @@ -169,3 +174,30 @@ layers and image size against the intended child area. 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. 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. diff --git a/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp new file mode 100644 index 000000000..22d92a6b8 --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp @@ -0,0 +1,97 @@ +// 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") + +using 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) { + 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; + ComPtr source, output; + ComPtr 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"); +} + +int main() { + try { + ComPtr device; + ComPtr 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"); + ComPtr debug; + ComPtr 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) { + Batch(device.Get(), context.Get(), debug.Get(), queue.Get(), count); + // 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); + verify(32); + for (unsigned i = 0; i < 8; ++i) verify(2); + queue->PopRetrievalFilter(); + queue->PopStorageFilter(); + std::cout << "PASS: real debug-layer capture objects released after 1/32 captures 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 index 5ede302a3..de22b2192 100644 --- a/Extensions/NeoPic/tests/CompileCapture.ps1 +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -38,12 +38,14 @@ if ($RunGpuSmokeTest) { $temp = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $temp | Out-Null try { - $exe = Join-Path $temp 'CaptureBlitWarpTest.exe' - $obj = Join-Path $temp 'CaptureBlitWarpTest.obj' - & cl.exe '/nologo' '/std:c++20' '/EHsc' '/Zc:wchar_t' "/I$repo/Extensions/_3rdLib" "/Fe$exe" "/Fo$obj" "$neo/tests/CaptureBlitWarpTest.cpp" - if ($LASTEXITCODE -ne 0) { throw 'WARP test compilation failed' } - & $exe - if ($LASTEXITCODE -ne 0) { throw 'WARP capture-normalization pixel test failed' } + 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" From e0ce5e4b58ee0f6bfa432670d52ef584403f522d Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 09:45:20 +0800 Subject: [PATCH 21/23] NeoPic: avoid a shared ComPtr alias collision in the lifetime test --- Extensions/NeoPic/tests/CaptureLifetimeTest.cpp | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp index 22d92a6b8..4f921aca3 100644 --- a/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp +++ b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp @@ -7,7 +7,7 @@ #include #pragma comment(lib, "d3d11.lib") -using Microsoft::WRL::ComPtr; +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)); } @@ -37,8 +37,8 @@ static void Batch(ID3D11Device* device, ID3D11DeviceContext* context, desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; desc.Usage = D3D11_USAGE_DEFAULT; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; - ComPtr source, output; - ComPtr view; + 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"); @@ -57,8 +57,8 @@ static void Batch(ID3D11Device* device, ID3D11DeviceContext* context, int main() { try { - ComPtr device; - ComPtr context; + 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()); @@ -67,8 +67,8 @@ int main() { return 0; } Check(created, "debug WARP device"); - ComPtr debug; - ComPtr queue; + LifetimePtr debug; + LifetimePtr queue; Check(device.As(&debug), "debug interface"); Check(device.As(&queue), "info queue"); Check(queue->PushEmptyStorageFilter(), "empty storage filter"); From 6337af2204ad2260a79acdc63e32c1df629a31dd Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 09:51:46 +0800 Subject: [PATCH 22/23] NeoPic: explicitly release native capture caches and trace teardown --- Extensions/NeoPic/CaptureBlit.h | 23 ++++++++++++++-- Extensions/NeoPic/CaptureTesting.md | 14 +++++++++- Extensions/NeoPic/FrameCaptureDX11.h | 7 ++++- .../NeoPic/tests/CaptureLifetimeTest.cpp | 27 ++++++++++++++----- 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/Extensions/NeoPic/CaptureBlit.h b/Extensions/NeoPic/CaptureBlit.h index 11edf575f..5642326f2 100644 --- a/Extensions/NeoPic/CaptureBlit.h +++ b/Extensions/NeoPic/CaptureBlit.h @@ -31,8 +31,7 @@ class CaptureBlit { HRESULT Initialize(ID3D11Device* device) { if (owner.Get() == device && scratch) return S_OK; - owner.Reset(); - scratch.Reset(); + Reset(); { Ptr debug; debugNames = SUCCEEDED(device->QueryInterface(IID_PPV_ARGS(debug.GetAddressOf()))); @@ -126,6 +125,26 @@ float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, } } 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) { diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index a39b05123..1c2d5d52e 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -185,7 +185,10 @@ 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. Production code never calls +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 @@ -201,3 +204,12 @@ 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/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h index f2f7423ba..228db98b6 100644 --- a/Extensions/NeoPic/FrameCaptureDX11.h +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -92,7 +92,12 @@ class Dx11FrameCapture : public FrameCapture { } public: - ~Dx11FrameCapture() override { Drain(); } + ~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 { diff --git a/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp index 4f921aca3..e0e0654f3 100644 --- a/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp +++ b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp @@ -30,7 +30,7 @@ static std::vector CaptureObjects(ID3D11Debug* debug, ID3D11InfoQue } static void Batch(ID3D11Device* device, ID3D11DeviceContext* context, - ID3D11Debug* debug, ID3D11InfoQueue* queue, unsigned captures) { + 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; @@ -53,6 +53,18 @@ static void Batch(ID3D11Device* device, ID3D11DeviceContext* context, } 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() { @@ -73,8 +85,8 @@ int main() { Check(device.As(&queue), "info queue"); Check(queue->PushEmptyStorageFilter(), "empty storage filter"); Check(queue->PushEmptyRetrievalFilter(), "empty retrieval filter"); - auto verify = [&](unsigned count) { - Batch(device.Get(), context.Get(), debug.Get(), queue.Get(), count); + 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(); @@ -84,12 +96,13 @@ int main() { throw std::runtime_error("capture objects still have external references after destruction"); } }; - verify(1); - verify(32); - for (unsigned i = 0; i < 8; ++i) verify(2); + 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 and repeated recreation, without ClearState\n"; + 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; From d2f140f2493bf197fde1deae88b935a39713d023 Mon Sep 17 00:00:00 2001 From: Defisym Date: Thu, 12 Nov 2026 10:21:35 +0800 Subject: [PATCH 23/23] NeoPic: fall back to DC for DX11 capture choices on Fusion DX9 --- Extensions/NeoPic/CaptureTesting.md | 17 +++++++++--- Extensions/NeoPic/Ext.rc | 2 +- Extensions/NeoPic/FrameCapture.cpp | 22 ++++++++++++++- Extensions/NeoPic/FrameCapture.h | 1 + Extensions/NeoPic/FrameCaptureBenchmark.h | 2 +- Extensions/NeoPic/tests/CaptureAdapterMocks.h | 14 +++++++--- .../NeoPic/tests/CaptureInheritanceTest.cpp | 27 +++++++++++++++++++ 7 files changed, 76 insertions(+), 9 deletions(-) diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md index 1c2d5d52e..9e4d8375e 100644 --- a/Extensions/NeoPic/CaptureTesting.md +++ b/Extensions/NeoPic/CaptureTesting.md @@ -12,8 +12,18 @@ publishes `_TempCapture` synchronously. | 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. There is no silent -fallback after a selected GPU backend fails. +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 @@ -170,7 +180,8 @@ and edit-data tests; native/compiler/GPU evidence comes from the Windows workflo 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 fixed resolution +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. diff --git a/Extensions/NeoPic/Ext.rc b/Extensions/NeoPic/Ext.rc index fea8bc288..a5d34f44f 100644 --- a/Extensions/NeoPic/Ext.rc +++ b/Extensions/NeoPic/Ext.rc @@ -669,7 +669,7 @@ BEGIN 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+). WGC shows the Windows capture indicator." + 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" diff --git a/Extensions/NeoPic/FrameCapture.cpp b/Extensions/NeoPic/FrameCapture.cpp index 9ef38f981..7085e11a5 100644 --- a/Extensions/NeoPic/FrameCapture.cpp +++ b/Extensions/NeoPic/FrameCapture.cpp @@ -29,8 +29,28 @@ HRESULT FrameCapture::CaptureAndLoad(LPRDATA rdPtr, SIZE* outputSize) { 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 (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 4ffad2c61..4ea584cf9 100644 --- a/Extensions/NeoPic/FrameCapture.h +++ b/Extensions/NeoPic/FrameCapture.h @@ -22,4 +22,5 @@ class FrameCapture { 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 index 5fcaf968e..2f8b8c2a9 100644 --- a/Extensions/NeoPic/FrameCaptureBenchmark.h +++ b/Extensions/NeoPic/FrameCaptureBenchmark.h @@ -125,7 +125,7 @@ class FrameCaptureBenchmark : public Base { inline std::unique_ptr CreateFrameCaptureBenchmark(LPRDATA rdPtr, CaptureBackend backend, CaptureBenchmarkOptions options = {}) { - switch (backend) { + 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/tests/CaptureAdapterMocks.h b/Extensions/NeoPic/tests/CaptureAdapterMocks.h index 2c82906de..6e667c7e9 100644 --- a/Extensions/NeoPic/tests/CaptureAdapterMocks.h +++ b/Extensions/NeoPic/tests/CaptureAdapterMocks.h @@ -7,12 +7,13 @@ #include #include -constexpr int SD_D3D11 = 11, ST_HWA_RTTEXTURE = 5; +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; } @@ -20,7 +21,7 @@ class cSurface { int GetWidth() const { return width; } int GetHeight() const { return height; } int GetDepth() const { return 32; } - int GetDriver() const { return SD_D3D11; } + int GetDriver() const { return driver; } int GetType() const { return ST_HWA_RTTEXTURE; } void GetOrigin(POINT& p) const { p = {}; } void CreateAlpha() {} @@ -40,6 +41,7 @@ struct MockFrame { }; struct MockHeader2 { DWORD dwOptions = 0; }; struct MockApp { + cSurface* m_protoLogScreen = nullptr; MockHeader2* m_pHdr2 = nullptr; bool m_bAlwaysUseSecondarySurface = false; cSurface* m_pOldSecondarySurface = nullptr; @@ -54,6 +56,11 @@ struct MockRunHeader { 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 { @@ -63,7 +70,8 @@ struct MockSurfaceInfo { void* m_ppD3D11RenderTargetTexture = nullptr; void* m_ppD3D11RenderTargetView = nullptr; }; -inline bool D3D11(LPRDATA) { return true; } +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 {}; } diff --git a/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp b/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp index e6c66eab1..efd57f3c1 100644 --- a/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp +++ b/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp @@ -48,7 +48,34 @@ int main() { 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();