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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
assumption is visible. Streams created *after* injection are captured exactly.
|
||||||
- **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the
|
- **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
|
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
|
## Roadmap
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "d3d11_window.hpp"
|
#include "d3d11_window.hpp"
|
||||||
|
|
||||||
|
#include <wincodec.h> // WIC PNG encoder for screenshots
|
||||||
|
|
||||||
#include <imgui_impl_win32.h>
|
#include <imgui_impl_win32.h>
|
||||||
|
|
||||||
// Forward declared in the ImGui Win32 backend; lets ImGui consume input first.
|
// Forward declared in the ImGui Win32 backend; lets ImGui consume input first.
|
||||||
@@ -13,7 +15,56 @@ namespace coop
|
|||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
constexpr wchar_t kWindowClass[] = L"CoopAllTheThingsWindow";
|
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<IWICImagingFactory> factory;
|
||||||
|
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
|
||||||
|
IID_PPV_ARGS(factory.GetAddressOf()))))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
ComPtr<IWICBitmap> 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<BYTE*>(src),
|
||||||
|
bitmap.GetAddressOf())))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ComPtr<IWICStream> stream;
|
||||||
|
if (FAILED(factory->CreateStream(stream.GetAddressOf())) ||
|
||||||
|
FAILED(stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ComPtr<IWICBitmapEncoder> encoder;
|
||||||
|
if (FAILED(factory->CreateEncoder(GUID_ContainerFormatPng, nullptr, encoder.GetAddressOf())) ||
|
||||||
|
FAILED(encoder->Initialize(stream.Get(), WICBitmapEncoderNoCache)))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ComPtr<IWICBitmapFrameEncode> frame;
|
||||||
|
ComPtr<IPropertyBag2> 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()
|
D3D11Window::~D3D11Window()
|
||||||
{
|
{
|
||||||
@@ -174,11 +225,69 @@ void D3D11Window::render_frame(const RenderCallback& render, UINT sync_interval)
|
|||||||
render();
|
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
|
// 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).
|
// caller can pace the flip itself (frame-sync to the game's published frames).
|
||||||
swap_chain_->Present(sync_interval, 0);
|
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<ID3D11Texture2D> 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<ID3D11Texture2D> 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<const BYTE*>(map.pData), map.RowPitch);
|
||||||
|
context_->Unmap(cpu.Get(), 0);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
|
LRESULT CALLBACK D3D11Window::wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
|
||||||
{
|
{
|
||||||
if (msg == WM_NCCREATE)
|
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);
|
self->resize_height_ = HIWORD(lparam);
|
||||||
}
|
}
|
||||||
return 0;
|
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:
|
case WM_DESTROY:
|
||||||
// Reached by the close button, Alt+F4 (DefWindowProc turns it into WM_CLOSE ->
|
// 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
|
// DestroyWindow), and our own teardown. Esc deliberately does NOT quit -- it's a
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
#include <wrl/client.h>
|
#include <wrl/client.h>
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
namespace coop
|
namespace coop
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,16 @@ public:
|
|||||||
// flip to the game's published frames instead of the monitor).
|
// flip to the game's published frames instead of the monitor).
|
||||||
void render_frame(const RenderCallback& render, UINT sync_interval = 1);
|
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
|
[[nodiscard]] HWND hwnd() const
|
||||||
{
|
{
|
||||||
return hwnd_;
|
return hwnd_;
|
||||||
@@ -56,8 +67,11 @@ private:
|
|||||||
void create_render_target();
|
void create_render_target();
|
||||||
void release_render_target();
|
void release_render_target();
|
||||||
void handle_resize(UINT width, UINT height);
|
void handle_resize(UINT width, UINT height);
|
||||||
|
bool save_backbuffer_png(const std::wstring& path);
|
||||||
|
|
||||||
HWND hwnd_ = nullptr;
|
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;
|
bool resize_pending_ = false;
|
||||||
UINT resize_width_ = 0;
|
UINT resize_width_ = 0;
|
||||||
UINT resize_height_ = 0;
|
UINT resize_height_ = 0;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
// this window via Windows Graphics Capture (Video mirror panel).
|
// this window via Windows Graphics Capture (Video mirror panel).
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@
|
|||||||
#include "audio_panel.hpp"
|
#include "audio_panel.hpp"
|
||||||
#include "capture_panel.hpp"
|
#include "capture_panel.hpp"
|
||||||
#include "controllers_panel.hpp"
|
#include "controllers_panel.hpp"
|
||||||
|
#include "coop/tool_paths.hpp"
|
||||||
#include "d3d11_window.hpp"
|
#include "d3d11_window.hpp"
|
||||||
#include "imgui_layer.hpp"
|
#include "imgui_layer.hpp"
|
||||||
#include "inject/mkb_forward.hpp"
|
#include "inject/mkb_forward.hpp"
|
||||||
@@ -26,13 +29,59 @@
|
|||||||
#include "log_panel.hpp"
|
#include "log_panel.hpp"
|
||||||
#include "ui/app_chrome.hpp"
|
#include "ui/app_chrome.hpp"
|
||||||
|
|
||||||
#ifdef COOP_WITH_STEAM
|
|
||||||
#include <string>
|
|
||||||
#endif
|
|
||||||
|
|
||||||
namespace
|
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<int>(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<int>(file.size()), nullptr, 0,
|
||||||
|
nullptr, nullptr);
|
||||||
|
std::string out(static_cast<std::size_t>(n), '\0');
|
||||||
|
WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast<int>(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<float>(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
|
#ifdef COOP_WITH_STEAM
|
||||||
// Absolute path to the bundled Steam Input action manifest (next to the exe).
|
// Absolute path to the bundled Steam Input action manifest (next to the exe).
|
||||||
std::string steam_manifest_path()
|
std::string steam_manifest_path()
|
||||||
@@ -151,6 +200,8 @@ int run()
|
|||||||
// Play Together; the pipelines keep running underneath either way.
|
// Play Together; the pipelines keep running underneath either way.
|
||||||
bool show_overlay = true;
|
bool show_overlay = true;
|
||||||
double overlay_hidden_at = 0.0;
|
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;
|
coop::UiState ui;
|
||||||
// Persist the "Debug details" verbosity in the .ini. Register before the first
|
// 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.
|
// 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
|
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)
|
if (show_overlay)
|
||||||
{
|
{
|
||||||
@@ -234,6 +289,7 @@ int run()
|
|||||||
{
|
{
|
||||||
log.draw();
|
log.draw();
|
||||||
}
|
}
|
||||||
|
draw_screenshot_toast(ImGui::GetTime() - last_shot_at, last_shot_name);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -265,6 +321,14 @@ int run()
|
|||||||
imgui.end_frame();
|
imgui.end_frame();
|
||||||
},
|
},
|
||||||
sync_interval);
|
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;
|
return 0;
|
||||||
|
|||||||
@@ -201,6 +201,7 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats)
|
|||||||
{
|
{
|
||||||
ImGui::TextDisabled("F1 hide/show this overlay");
|
ImGui::TextDisabled("F1 hide/show this overlay");
|
||||||
ImGui::TextDisabled("F2 release/clip the operator cursor");
|
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::TextDisabled("Alt+F4 quit (or File -> Exit)");
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::TextDisabled("This window is what Remote Play");
|
ImGui::TextDisabled("This window is what Remote Play");
|
||||||
|
|||||||
Reference in New Issue
Block a user