From 24d66020f6985139367d7239fbc743feac64011a Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sat, 20 Jun 2026 14:22:31 +0200 Subject: [PATCH] Fix hooked video darkening for *_SRGB-backbuffer games The host sampled the shared backbuffer copy through a view typed exactly like the game's backbuffer. For games whose backbuffer is an *_SRGB format (e.g. Life is Strange: Before the Storm -- confirmed R8G8B8A8_UNORM_SRGB / fmt 29 via the hook log), the GPU decoded sRGB->linear on the sample, and the host then wrote those linear values straight to its plain-UNORM swapchain with no re-encode, so the mirror came out noticeably darker than the game. Sample the copy as the plain-UNORM sibling of the format (srgb_to_unorm) so the bytes pass through unchanged -- matching what WGC already does. The UNORM and *_SRGB formats share a typeless group, so CopyResource from the producer's sRGB texture into the host's UNORM copy is allowed. Non-sRGB formats are unaffected. Adds srgb_format_test locking the mapping. All 6 tests pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 5 +++- host/src/capture/dxgi_format.hpp | 43 +++++++++++++++++++++++++++++ host/src/capture/shared_texture.cpp | 6 +++- tests/CMakeLists.txt | 6 ++++ tests/srgb_format_test.cpp | 41 +++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 host/src/capture/dxgi_format.hpp create mode 100644 tests/srgb_format_test.cpp diff --git a/README.md b/README.md index 92134e7..f963a6c 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,10 @@ Done: swapchains with an `ID3D11Texture2D` backbuffer are caught (the common D3D11 case); D3D9 / pure-D3D12 games keep WGC. Validated by `present_hook_test` (drives a real D3D11 swapchain end-to-end and reads the pixels back through the - shared texture). + shared texture). The host samples the shared copy as plain UNORM (see + `srgb_to_unorm`) so games with an `*_SRGB` backbuffer (e.g. Life is Strange: + Before the Storm, whose backbuffer is `R8G8B8A8_UNORM_SRGB`) mirror with correct + brightness instead of being darkened by an sRGB→linear decode. - **x86 (32-bit) game support. ✅** A nested Win32 sub-build (CMake `ExternalProject`, driven from the normal x64 build) produces `coop_hook_x86.dll` diff --git a/host/src/capture/dxgi_format.hpp b/host/src/capture/dxgi_format.hpp new file mode 100644 index 0000000..8cd2b9a --- /dev/null +++ b/host/src/capture/dxgi_format.hpp @@ -0,0 +1,43 @@ +// Small DXGI format helpers for the video mirror. +#pragma once + +#include + +namespace coop +{ + +// Map an sRGB DXGI format to its plain UNORM sibling (same byte layout / type +// group), leaving non-sRGB formats unchanged. +// +// Why the hooked video path needs this: a game backbuffer is often an *_SRGB +// format. If the host samples the shared copy through an sRGB-typed view, the GPU +// decodes sRGB->linear on the read; the host then writes those linear values to +// its plain-UNORM swapchain with no re-encode, so the mirror comes out too dark. +// Sampling the copy as plain UNORM passes the bytes through unchanged -- exactly +// what Windows Graphics Capture does -- so the colors match the original. The +// UNORM and *_SRGB formats share a typeless group, so CopyResource between them +// (the producer's sRGB texture -> the host's UNORM copy) is allowed. +inline DXGI_FORMAT srgb_to_unorm(DXGI_FORMAT format) +{ + switch (format) + { + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_B8G8R8X8_UNORM_SRGB: + return DXGI_FORMAT_B8G8R8X8_UNORM; + case DXGI_FORMAT_BC1_UNORM_SRGB: + return DXGI_FORMAT_BC1_UNORM; + case DXGI_FORMAT_BC2_UNORM_SRGB: + return DXGI_FORMAT_BC2_UNORM; + case DXGI_FORMAT_BC3_UNORM_SRGB: + return DXGI_FORMAT_BC3_UNORM; + case DXGI_FORMAT_BC7_UNORM_SRGB: + return DXGI_FORMAT_BC7_UNORM; + default: + return format; + } +} + +} // namespace coop diff --git a/host/src/capture/shared_texture.cpp b/host/src/capture/shared_texture.cpp index 75c99aa..ef97622 100644 --- a/host/src/capture/shared_texture.cpp +++ b/host/src/capture/shared_texture.cpp @@ -1,5 +1,6 @@ #include "capture/shared_texture.hpp" +#include "capture/dxgi_format.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" @@ -57,12 +58,15 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share) } // Private copy we sample from, so we only hold the keyed mutex during the copy. + // Use the plain-UNORM sibling of the (possibly sRGB) backbuffer format so the + // SRV passes the bytes through without an sRGB->linear decode that would darken + // the mirror (CopyResource is allowed within the shared typeless group). D3D11_TEXTURE2D_DESC desc{}; desc.Width = share.width; desc.Height = share.height; desc.MipLevels = 1; desc.ArraySize = 1; - desc.Format = static_cast(share.format); + desc.Format = srgb_to_unorm(static_cast(share.format)); desc.SampleDesc.Count = 1; desc.Usage = D3D11_USAGE_DEFAULT; desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2c0edb4..ce29167 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,6 +59,12 @@ target_link_libraries(audio_hook_test PRIVATE add_test(NAME audio_hook_test COMMAND audio_hook_test) +# Unit test for the sRGB->UNORM format mapping the hooked video path uses to avoid +# darkening *_SRGB-backbuffer games. Header-only, no device. +add_executable(srgb_format_test srgb_format_test.cpp) +target_include_directories(srgb_format_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) +add_test(NAME srgb_format_test COMMAND srgb_format_test) + # In-process self-test for the Present-hook video path. Reuses the shipping # present_hook.cpp and drives a real D3D11 swapchain in the same process, so it # exercises the IDXGISwapChain::Present inline hook, the shared keyed-mutex diff --git a/tests/srgb_format_test.cpp b/tests/srgb_format_test.cpp new file mode 100644 index 0000000..43ceba8 --- /dev/null +++ b/tests/srgb_format_test.cpp @@ -0,0 +1,41 @@ +// Unit test for srgb_to_unorm (host/src/capture/dxgi_format.hpp): the hooked +// video path samples the shared backbuffer copy as plain UNORM so an *_SRGB game +// backbuffer isn't darkened by an sRGB->linear decode the host never re-encodes. +// Locks the format mapping that fix depends on. No device needed. + +#include + +#include "capture/dxgi_format.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void expect(DXGI_FORMAT in, DXGI_FORMAT want, const char* what) +{ + const DXGI_FORMAT got = srgb_to_unorm(in); + if (got != want) + { + std::printf(" FAIL: %s (got %d, want %d)\n", what, static_cast(got), static_cast(want)); + ++g_failures; + } +} +} // namespace + +int main() +{ + // sRGB formats map to their plain-UNORM sibling. + expect(DXGI_FORMAT_R8G8B8A8_UNORM_SRGB, DXGI_FORMAT_R8G8B8A8_UNORM, "R8G8B8A8 sRGB -> UNORM"); + expect(DXGI_FORMAT_B8G8R8A8_UNORM_SRGB, DXGI_FORMAT_B8G8R8A8_UNORM, "B8G8R8A8 sRGB -> UNORM"); + expect(DXGI_FORMAT_B8G8R8X8_UNORM_SRGB, DXGI_FORMAT_B8G8R8X8_UNORM, "B8G8R8X8 sRGB -> UNORM"); + + // Non-sRGB formats pass through unchanged. + expect(DXGI_FORMAT_R8G8B8A8_UNORM, DXGI_FORMAT_R8G8B8A8_UNORM, "R8G8B8A8 UNORM passthrough"); + expect(DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_FORMAT_B8G8R8A8_UNORM, "B8G8R8A8 UNORM passthrough"); + expect(DXGI_FORMAT_R10G10B10A2_UNORM, DXGI_FORMAT_R10G10B10A2_UNORM, "R10G10B10A2 passthrough"); + expect(DXGI_FORMAT_R16G16B16A16_FLOAT, DXGI_FORMAT_R16G16B16A16_FLOAT, "RGBA16F passthrough"); + + std::printf(g_failures == 0 ? "SRGB FORMAT TEST PASS\n" : "SRGB FORMAT TEST FAILED (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}