From f72da74f78f4b5814c0f3651da33056d37716280 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 22 Jun 2026 00:58:38 +0200 Subject: [PATCH] Add F10 back-buffer screenshot (PNG, focus-independent) Capture the rendered back buffer to a timestamped PNG next to the exe via WIC, triggered by F10 (delivered even when unfocused). The capture runs in render_frame just before Present so it includes the ImGui overlay, and reads off the GPU so it works regardless of window focus, z-order, or occlusion. A brief toast confirms the save (drawn the next frame, so it's never in the shot). F10 chosen to avoid Steam's F12; its WM_SYSKEYDOWN is swallowed so Windows doesn't enter menu mode. Documented in the Help menu + README. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- host/src/d3d11_window.cpp | 118 +++++++++++++++++++++++++++++++++++++ host/src/d3d11_window.hpp | 14 +++++ host/src/main.cpp | 72 ++++++++++++++++++++-- host/src/ui/app_chrome.cpp | 1 + 5 files changed, 203 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fe06c0b..8084049 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,8 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap). assumption is visible. Streams created *after* injection are captured exactly. - **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the pipeline, not for end use. F1 hides it entirely so the window is a clean mirror - for RPT. + for RPT; F2 frees the operator cursor; **F10 saves a PNG screenshot** (back buffer, + written next to the exe) regardless of window focus or occlusion. ## Roadmap diff --git a/host/src/d3d11_window.cpp b/host/src/d3d11_window.cpp index 7456fc2..d6c2762 100644 --- a/host/src/d3d11_window.cpp +++ b/host/src/d3d11_window.cpp @@ -1,5 +1,7 @@ #include "d3d11_window.hpp" +#include // WIC PNG encoder for screenshots + #include // Forward declared in the ImGui Win32 backend; lets ImGui consume input first. @@ -13,7 +15,56 @@ namespace coop namespace { constexpr wchar_t kWindowClass[] = L"CoopAllTheThingsWindow"; + +// Encode a tightly-packed/row-pitched RGBA8 image to a PNG file via WIC. `src` is the +// CPU-mapped staging copy of the back buffer (stride = row_pitch). Pure COM, so no +// extra link dependency. Returns false on any failure (caller swallows it -- a missed +// screenshot is never fatal). +bool write_rgba8_png(const std::wstring& path, UINT width, UINT height, const BYTE* src, UINT row_pitch) +{ + ComPtr factory; + if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(factory.GetAddressOf())))) + { + return false; + } + ComPtr bitmap; // wrap the back-buffer bytes (RGBA, matches the swap chain) + if (FAILED(factory->CreateBitmapFromMemory(width, height, GUID_WICPixelFormat32bppRGBA, row_pitch, + row_pitch * height, const_cast(src), + bitmap.GetAddressOf()))) + { + return false; + } + ComPtr stream; + if (FAILED(factory->CreateStream(stream.GetAddressOf())) || + FAILED(stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE))) + { + return false; + } + ComPtr encoder; + if (FAILED(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf())) || + FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache))) + { + return false; + } + ComPtr frame; + ComPtr props; + if (FAILED(encoder->CreateNewFrame(frame.GetAddressOf(), props.GetAddressOf())) || + FAILED(frame->Initialize(props.Get())) || FAILED(frame->SetSize(width, height))) + { + return false; + } + // Let the encoder pick its native pixel format; WriteSource converts our RGBA to it. + WICPixelFormatGUID fmt = GUID_WICPixelFormat32bppBGRA; + frame->SetPixelFormat(&fmt); + if (FAILED(frame->WriteSource(bitmap.Get(), nullptr)) || FAILED(frame->Commit()) || + FAILED(encoder->Commit())) + { + return false; + } + return true; } +} // namespace D3D11Window::~D3D11Window() { @@ -174,11 +225,69 @@ void D3D11Window::render_frame(const RenderCallback& render, UINT sync_interval) render(); } + // Screenshot (F10): capture after the overlay is drawn but before Present -- the + // flip-model back buffer is undefined once presented. + if (!pending_screenshot_.empty()) + { + if (save_backbuffer_png(pending_screenshot_)) + { + saved_screenshot_ = pending_screenshot_; + } + pending_screenshot_.clear(); + } + // sync_interval 1 (default) vsyncs to the monitor; 0 presents immediately so the // caller can pace the flip itself (frame-sync to the game's published frames). swap_chain_->Present(sync_interval, 0); } +void D3D11Window::request_screenshot(std::wstring path) +{ + pending_screenshot_ = std::move(path); +} + +std::wstring D3D11Window::take_screenshot_result() +{ + std::wstring result; + result.swap(saved_screenshot_); + return result; +} + +bool D3D11Window::save_backbuffer_png(const std::wstring& path) +{ + ComPtr back; + if (FAILED(swap_chain_->GetBuffer(0, IID_PPV_ARGS(back.GetAddressOf())))) + { + return false; + } + D3D11_TEXTURE2D_DESC desc{}; + back->GetDesc(&desc); + + // A CPU-readable staging copy we can Map (the back buffer itself isn't readable). + D3D11_TEXTURE2D_DESC staging = desc; + staging.Usage = D3D11_USAGE_STAGING; + staging.BindFlags = 0; + staging.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + staging.MiscFlags = 0; + ComPtr cpu; + if (FAILED(device_->CreateTexture2D(&staging, nullptr, cpu.GetAddressOf()))) + { + return false; + } + context_->CopyResource(cpu.Get(), back.Get()); + + D3D11_MAPPED_SUBRESOURCE map{}; + if (FAILED(context_->Map(cpu.Get(), 0, D3D11_MAP_READ, 0, &map))) + { + return false; + } + // The swap chain is DXGI_FORMAT_R8G8B8A8_UNORM (see create_device), i.e. RGBA bytes. + const bool ok = + write_rgba8_png(path, desc.Width, desc.Height, static_cast(map.pData), map.RowPitch); + context_->Unmap(cpu.Get(), 0); + return ok; +} + LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { if (msg == WM_NCCREATE) @@ -204,6 +313,15 @@ LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARA self->resize_height_ = HIWORD(lparam); } return 0; + case WM_SYSKEYDOWN: + // F10 is our screenshot key; ImGui already saw this message (handler runs above), + // so swallow it here to stop DefWindowProc from flicking into Win32 menu mode. + // Alt+F4 (VK_F4) falls through to DefWindowProc so it still closes the window. + if (wparam == VK_F10) + { + return 0; + } + break; case WM_DESTROY: // Reached by the close button, Alt+F4 (DefWindowProc turns it into WM_CLOSE -> // DestroyWindow), and our own teardown. Esc deliberately does NOT quit -- it's a diff --git a/host/src/d3d11_window.hpp b/host/src/d3d11_window.hpp index 3792980..18e9c0e 100644 --- a/host/src/d3d11_window.hpp +++ b/host/src/d3d11_window.hpp @@ -8,6 +8,7 @@ #include #include +#include namespace coop { @@ -36,6 +37,16 @@ public: // flip to the game's published frames instead of the monitor). void render_frame(const RenderCallback& render, UINT sync_interval = 1); + // Request a PNG screenshot of the next rendered frame, saved to `path`. The capture + // happens inside render_frame just before Present, so it includes the ImGui overlay, + // and reads the back buffer off the GPU -- independent of window focus, z-order, or + // occlusion (works even when the window is fully covered). One-shot. + void request_screenshot(std::wstring path); + + // If render_frame saved a screenshot since the last call, returns its path and clears + // the result (so a confirmation shows once); otherwise returns an empty string. + [[nodiscard]] std::wstring take_screenshot_result(); + [[nodiscard]] HWND hwnd() const { return hwnd_; @@ -56,8 +67,11 @@ private: void create_render_target(); void release_render_target(); void handle_resize(UINT width, UINT height); + bool save_backbuffer_png(const std::wstring& path); HWND hwnd_ = nullptr; + std::wstring pending_screenshot_; // set by request_screenshot, consumed in render_frame + std::wstring saved_screenshot_; // last successfully written shot, for a UI confirmation bool resize_pending_ = false; UINT resize_width_ = 0; UINT resize_height_ = 0; diff --git a/host/src/main.cpp b/host/src/main.cpp index a8beaaf..106bb96 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -6,6 +6,8 @@ // this window via Windows Graphics Capture (Video mirror panel). #include +#include +#include #include @@ -18,6 +20,7 @@ #include "audio_panel.hpp" #include "capture_panel.hpp" #include "controllers_panel.hpp" +#include "coop/tool_paths.hpp" #include "d3d11_window.hpp" #include "imgui_layer.hpp" #include "inject/mkb_forward.hpp" @@ -26,13 +29,59 @@ #include "log_panel.hpp" #include "ui/app_chrome.hpp" -#ifdef COOP_WITH_STEAM -#include -#endif - namespace { +// Timestamped screenshot path next to the exe (e.g. coop_shot_20260622_143501.png). +std::wstring screenshot_path() +{ + SYSTEMTIME st{}; + GetLocalTime(&st); + wchar_t name[64]; + swprintf(name, static_cast(std::size(name)), L"coop_shot_%04u%02u%02u_%02u%02u%02u.png", st.wYear, + st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); + return coop::exe_directory() + name; +} + +// Just the filename of a path, narrowed to UTF-8 for an ImGui confirmation toast. +std::string screenshot_basename(const std::wstring& path) +{ + const std::size_t slash = path.find_last_of(L"\\/"); + const std::wstring file = slash == std::wstring::npos ? path : path.substr(slash + 1); + if (file.empty()) + { + return {}; + } + const int n = WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast(file.size()), nullptr, 0, + nullptr, nullptr); + std::string out(static_cast(n), '\0'); + WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast(file.size()), out.data(), n, nullptr, nullptr); + return out; +} + +// Brief fading "saved" confirmation after an F10 screenshot (bottom-left, non-interactive +// so it never steals a click). It's drawn the frame *after* the capture, so it never lands +// in the shot itself. +void draw_screenshot_toast(double seconds_since, const std::string& name) +{ + const float fade = 1.0f - static_cast(seconds_since) / 2.5f; + if (fade <= 0.0f || name.empty()) + { + return; + } + const ImGuiViewport* vp = ImGui::GetMainViewport(); + ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + 12.0f, vp->WorkPos.y + vp->WorkSize.y - 44.0f)); + ImGui::SetNextWindowBgAlpha(0.45f * fade); + const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + ImGui::Begin("##shot_toast", nullptr, flags); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 1.0f, 0.6f, fade)); + ImGui::Text("Saved screenshot: %s", name.c_str()); + ImGui::PopStyleColor(); + ImGui::End(); +} + #ifdef COOP_WITH_STEAM // Absolute path to the bundled Steam Input action manifest (next to the exe). std::string steam_manifest_path() @@ -151,6 +200,8 @@ int run() // Play Together; the pipelines keep running underneath either way. bool show_overlay = true; double overlay_hidden_at = 0.0; + double last_shot_at = -10.0; // when the last F10 screenshot was saved (for the toast) + std::string last_shot_name; coop::UiState ui; // Persist the "Debug details" verbosity in the .ini. Register before the first // begin_frame() below, which is when ImGui loads the .ini and replays our handler. @@ -210,6 +261,10 @@ int run() { injection.toggle_cursor_release(); // free/clip the operator's mouse for clipping games } + if (ImGui::IsKeyPressed(ImGuiKey_F10, false)) + { + window.request_screenshot(screenshot_path()); // captured at Present, overlay included + } if (show_overlay) { @@ -234,6 +289,7 @@ int run() { log.draw(); } + draw_screenshot_toast(ImGui::GetTime() - last_shot_at, last_shot_name); } else { @@ -265,6 +321,14 @@ int run() imgui.end_frame(); }, sync_interval); + + // render_frame saves a pending F10 screenshot just before Present; pick up the + // result here so next frame shows the confirmation toast (kept out of the shot). + if (std::wstring shot = window.take_screenshot_result(); !shot.empty()) + { + last_shot_at = ImGui::GetTime(); + last_shot_name = screenshot_basename(shot); + } } return 0; diff --git a/host/src/ui/app_chrome.cpp b/host/src/ui/app_chrome.cpp index 2eba7aa..caf3775 100644 --- a/host/src/ui/app_chrome.cpp +++ b/host/src/ui/app_chrome.cpp @@ -201,6 +201,7 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats) { ImGui::TextDisabled("F1 hide/show this overlay"); ImGui::TextDisabled("F2 release/clip the operator cursor"); + ImGui::TextDisabled("F10 save a screenshot (PNG, next to the exe)"); ImGui::TextDisabled("Alt+F4 quit (or File -> Exit)"); ImGui::Separator(); ImGui::TextDisabled("This window is what Remote Play");