Skip to content

Commit eeb5cd6

Browse files
henderkesclaude
andcommitted
Add a per-window title-bar screenshot button (debug / docs affordance)
Adds a camera icon next to the existing settings cog on every Toolbox window's title bar. Clicking it saves a PNG of just that window's pixel rect to the Toolbox Screens folder, so we can capture docs illustrations without faffing about with full-screen screenshots and cropping. Off by default, gated by two new ini settings (show_screenshot_button_in_outpost / _in_explorable) and a matching checkbox pair under Settings -> Toolbox Settings -> "Show screenshot button in:". Implementation: * Resources::SaveBackbufferRectToFile takes an IDirect3DDevice9 and an optional sub-rect. It grabs the current render target, copies it to a SYSTEMMEM offscreen plain surface (GetRenderTargetData needs identical dimensions + format on a system-memory target), reuses the existing ConvertD3D9FormatToDXGI helper to land on a DXGI format, builds a DirectX::Image pointing at just the sub-rect inside the locked surface, and writes PNG / JPG / BMP via DirectX::SaveToWICFile (DirectXTex is already linked). No new dependency — the Resources::SaveTextureToFile path already uses the same WIC pipeline for textures. * ToolboxSettings::DrawSettingsCogButtons (renamed in spirit, kept the symbol name for ABI continuity) now lays out the title-bar overlay buttons from the right edge inward, drawing the cog first and the camera one slot further left. The lambda factoring out the per-button paint + click-test keeps the additional code small. A click on the camera populates a PendingScreenshot struct with the window's outer rect, the destination path, and GetFrameCount()+1 as the capture frame. * On the deferred capture frame, IsScreenshotInFlight() short- circuits the title-bar overlay entirely so neither the cog nor the camera appears in the saved PNG. GWToolbox::Draw calls ToolboxSettings::FlushPendingScreenshot(device) immediately after ImGui_ImplDX9_RenderDrawData, which is the first point at which the back buffer actually contains the freshly-rendered Toolbox windows. * Output filenames are gwtoolbox_<slug>_<yyyymmdd-hhmmss>.png under Resources::GetPath("Screens"). The slug is the window's Name() lowercased with non-alnum runs collapsed to underscores, so e.g. "Hero Builds" -> "hero_builds". Known limitation: multi-viewport mode (ConfigFlags_ViewportsEnable) with a Toolbox window docked out into its own OS window won't work — we only read back the main GW back buffer. That's fine for the intended docs-authoring use case. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d4a26a5 commit eeb5cd6

5 files changed

Lines changed: 254 additions & 24 deletions

File tree

GWToolboxdll/GWToolbox.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,11 @@ void GWToolbox::Draw(IDirect3DDevice9* device)
11291129
ImGui::Render();
11301130
ImGui_ImplDX9_RenderDrawData(ImGui::GetDrawData());
11311131

1132+
// The Toolbox windows are now drawn into the back buffer; if the user
1133+
// clicked a title-bar camera button last frame, this is where we
1134+
// actually read the pixels out and save them to disk.
1135+
ToolboxSettings::FlushPendingScreenshot(device);
1136+
11321137
// Update and Render additional Platform Windows
11331138
if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) {
11341139
ImGui::UpdatePlatformWindows();

GWToolboxdll/Modules/Resources.cpp

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1504,6 +1504,110 @@ bool Resources::SaveTextureToFile(IDirect3DTexture9* texture, const std::filesys
15041504
return true;
15051505
}
15061506

1507+
bool Resources::SaveBackbufferRectToFile(IDirect3DDevice9* device, const RECT* region, const std::filesystem::path& file_path)
1508+
{
1509+
if (!device) {
1510+
Log::Warning("SaveBackbufferRectToFile: device is null");
1511+
return false;
1512+
}
1513+
1514+
// Pick the WIC codec from the extension up front so we fail fast on
1515+
// unsupported output formats before we copy any pixels.
1516+
auto ext = file_path.extension().string();
1517+
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
1518+
GUID codec_guid;
1519+
if (ext == ".png") codec_guid = GUID_ContainerFormatPng;
1520+
else if (ext == ".jpg" || ext == ".jpeg") codec_guid = GUID_ContainerFormatJpeg;
1521+
else if (ext == ".bmp") codec_guid = GUID_ContainerFormatBmp;
1522+
else {
1523+
Log::Warning("SaveBackbufferRectToFile: unsupported file format: %s", ext.c_str());
1524+
return false;
1525+
}
1526+
1527+
IDirect3DSurface9* backbuffer = nullptr;
1528+
HRESULT hr = device->GetRenderTarget(0, &backbuffer);
1529+
if (FAILED(hr) || !backbuffer) {
1530+
Log::Warning("SaveBackbufferRectToFile: GetRenderTarget failed: 0x%X", hr);
1531+
return false;
1532+
}
1533+
1534+
D3DSURFACE_DESC desc;
1535+
backbuffer->GetDesc(&desc);
1536+
1537+
// GetRenderTargetData requires a SYSTEMMEM destination of identical
1538+
// dimensions & format. We copy the whole back buffer, then construct a
1539+
// DirectX::Image that points at just the sub-rect.
1540+
IDirect3DSurface9* sysmem = nullptr;
1541+
hr = device->CreateOffscreenPlainSurface(desc.Width, desc.Height, desc.Format, D3DPOOL_SYSTEMMEM, &sysmem, nullptr);
1542+
if (FAILED(hr) || !sysmem) {
1543+
backbuffer->Release();
1544+
Log::Warning("SaveBackbufferRectToFile: CreateOffscreenPlainSurface failed: 0x%X", hr);
1545+
return false;
1546+
}
1547+
1548+
hr = device->GetRenderTargetData(backbuffer, sysmem);
1549+
backbuffer->Release();
1550+
if (FAILED(hr)) {
1551+
sysmem->Release();
1552+
Log::Warning("SaveBackbufferRectToFile: GetRenderTargetData failed: 0x%X", hr);
1553+
return false;
1554+
}
1555+
1556+
const DXGI_FORMAT dxgi = ConvertD3D9FormatToDXGI(desc.Format);
1557+
if (dxgi == DXGI_FORMAT_UNKNOWN) {
1558+
sysmem->Release();
1559+
Log::Warning("SaveBackbufferRectToFile: unsupported back buffer format: 0x%X", desc.Format);
1560+
return false;
1561+
}
1562+
1563+
// Clamp the requested rect to the back buffer; degenerate rects fail out.
1564+
LONG x = 0, y = 0;
1565+
LONG w = static_cast<LONG>(desc.Width);
1566+
LONG h = static_cast<LONG>(desc.Height);
1567+
if (region) {
1568+
x = std::max<LONG>(0, region->left);
1569+
y = std::max<LONG>(0, region->top);
1570+
w = std::min<LONG>(static_cast<LONG>(desc.Width) - x, region->right - region->left);
1571+
h = std::min<LONG>(static_cast<LONG>(desc.Height) - y, region->bottom - region->top);
1572+
}
1573+
if (w <= 0 || h <= 0) {
1574+
sysmem->Release();
1575+
Log::Warning("SaveBackbufferRectToFile: degenerate region after clamp (%dx%d)", w, h);
1576+
return false;
1577+
}
1578+
1579+
D3DLOCKED_RECT locked;
1580+
hr = sysmem->LockRect(&locked, nullptr, D3DLOCK_READONLY);
1581+
if (FAILED(hr)) {
1582+
sysmem->Release();
1583+
Log::Warning("SaveBackbufferRectToFile: LockRect failed: 0x%X", hr);
1584+
return false;
1585+
}
1586+
1587+
const size_t bpp = DirectX::BitsPerPixel(dxgi) / 8;
1588+
uint8_t* base = static_cast<uint8_t*>(locked.pBits) + static_cast<size_t>(y) * locked.Pitch + static_cast<size_t>(x) * bpp;
1589+
1590+
DirectX::Image img = {};
1591+
img.width = static_cast<size_t>(w);
1592+
img.height = static_cast<size_t>(h);
1593+
img.format = dxgi;
1594+
img.rowPitch = static_cast<size_t>(locked.Pitch);
1595+
img.slicePitch = static_cast<size_t>(locked.Pitch) * static_cast<size_t>(h);
1596+
img.pixels = base;
1597+
1598+
const HRESULT save_hr = DirectX::SaveToWICFile(img, DirectX::WIC_FLAGS_NONE, codec_guid, file_path.c_str());
1599+
sysmem->UnlockRect();
1600+
sysmem->Release();
1601+
1602+
if (FAILED(save_hr)) {
1603+
Log::Warning("SaveBackbufferRectToFile: SaveToWICFile failed: 0x%X", save_hr);
1604+
return false;
1605+
}
1606+
1607+
Log::Info("Saved screenshot to %s (%dx%d)", file_path.string().c_str(), (int)w, (int)h);
1608+
return true;
1609+
}
1610+
15071611
uint32_t Resources::GetTexmodHashCube(IDirect3DCubeTexture9* cubeTexture)
15081612
{
15091613
if (!cubeTexture) {

GWToolboxdll/Modules/Resources.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ class Resources : public ToolboxModule {
107107
// Guaranteed to return a pointer, but reference will be null until the texture has been loaded
108108
static IDirect3DTexture9** GetItemImage(const std::wstring& item_name);
109109
static bool SaveTextureToFile(IDirect3DTexture9* texture, const std::filesystem::path& file_path);
110+
// Captures a sub-rect of the current D3D9 back buffer to disk as PNG/JPG/BMP
111+
// (extension is taken from file_path). Pass nullptr for region to capture
112+
// the whole back buffer.
113+
static bool SaveBackbufferRectToFile(IDirect3DDevice9* device, const RECT* region, const std::filesystem::path& file_path);
110114
// Fetches File page from GWW, parses out the image for the file given
111115
// Not elegant, but without a proper API to provide images, and to avoid including libxml, this is the next best thing.
112116
// Guaranteed to return a pointer, but reference will be null until the texture has been loaded

GWToolboxdll/Modules/ToolboxSettings.cpp

Lines changed: 133 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,12 @@ void ToolboxSettings::DrawFreezeSetting()
334334
ImGui::Checkbox("Outpost", &show_cog_in_outpost);
335335
ImGui::Checkbox("Explorable", &show_cog_in_explorable);
336336
ImGui::Unindent();
337+
ImGui::Text("Show screenshot button in:");
338+
ImGui::ShowHelp("Show a " ICON_FA_CAMERA " button in the title bar of each window.\nClick it to save a PNG of just that window to the Toolbox Screens folder.\nIntended for capturing screenshots for documentation or bug reports.");
339+
ImGui::Indent();
340+
ImGui::Checkbox("Outpost##scrn", &show_screenshot_button_in_outpost);
341+
ImGui::Checkbox("Explorable##scrn", &show_screenshot_button_in_explorable);
342+
ImGui::Unindent();
337343
}
338344

339345
void ToolboxSettings::LoadSettings(ToolboxIni* ini)
@@ -347,6 +353,8 @@ void ToolboxSettings::LoadSettings(ToolboxIni* ini)
347353
LOAD_BOOL(send_anonymous_gameplay_info);
348354
LOAD_BOOL(show_cog_in_outpost);
349355
LOAD_BOOL(show_cog_in_explorable);
356+
LOAD_BOOL(show_screenshot_button_in_outpost);
357+
LOAD_BOOL(show_screenshot_button_in_explorable);
350358
// Migrate from old hide_close_in_explorable: if it was true, default both show vars to false
351359
if (ini->GetBoolValue(Name(), "hide_close_in_explorable", false)) {
352360
show_close_in_outpost = false;
@@ -372,6 +380,8 @@ void ToolboxSettings::SaveSettings(ToolboxIni* ini)
372380
SAVE_BOOL(send_anonymous_gameplay_info);
373381
SAVE_BOOL(show_cog_in_outpost);
374382
SAVE_BOOL(show_cog_in_explorable);
383+
SAVE_BOOL(show_screenshot_button_in_outpost);
384+
SAVE_BOOL(show_screenshot_button_in_explorable);
375385
SAVE_BOOL(show_close_in_outpost);
376386
SAVE_BOOL(show_close_in_explorable);
377387

@@ -396,12 +406,68 @@ void ToolboxSettings::Draw(IDirect3DDevice9*)
396406
}
397407
}
398408

409+
namespace {
410+
// Deferred screenshot request — populated when the user clicks the
411+
// camera button on a window's title bar, consumed at end-of-frame by
412+
// ToolboxSettings::FlushPendingScreenshot. capture_at_frame defers
413+
// by one frame so the click frame itself isn't drawn into the
414+
// capture (and we can suppress the cog/camera overlays on the
415+
// captured frame to avoid them appearing in the screenshot).
416+
struct PendingScreenshot {
417+
bool active = false;
418+
ImRect rect;
419+
std::filesystem::path path;
420+
int capture_at_frame = 0;
421+
};
422+
PendingScreenshot pending_screenshot;
423+
424+
bool IsScreenshotInFlight()
425+
{
426+
return pending_screenshot.active && ImGui::GetFrameCount() <= pending_screenshot.capture_at_frame;
427+
}
428+
429+
std::filesystem::path BuildScreenshotPath(const char* window_name)
430+
{
431+
// Slug the window name so it makes a sensible filename: strip
432+
// anything that isn't alnum/underscore.
433+
std::string slug;
434+
slug.reserve(strlen(window_name));
435+
for (const char* p = window_name; *p; ++p) {
436+
const unsigned char c = static_cast<unsigned char>(*p);
437+
if (isalnum(c)) slug.push_back(static_cast<char>(tolower(c)));
438+
else if (slug.empty() || slug.back() != '_') slug.push_back('_');
439+
}
440+
while (!slug.empty() && slug.back() == '_') slug.pop_back();
441+
if (slug.empty()) slug = "window";
442+
443+
// Local-time timestamp; precision down to the second is enough
444+
// to disambiguate consecutive captures.
445+
SYSTEMTIME st;
446+
GetLocalTime(&st);
447+
char stamp[32];
448+
snprintf(stamp, sizeof(stamp), "%04d%02d%02d-%02d%02d%02d",
449+
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
450+
451+
const auto folder = Resources::GetPath(L"Screens");
452+
Resources::EnsureFolderExists(folder);
453+
const std::string fname = std::string("gwtoolbox_") + slug + "_" + stamp + ".png";
454+
return folder / fname;
455+
}
456+
}
457+
399458
void ToolboxSettings::DrawSettingsCogButtons()
400459
{
401-
if (is_in_explorable ? !show_cog_in_explorable : !show_cog_in_outpost) return;
460+
const bool show_cog = is_in_explorable ? show_cog_in_explorable : show_cog_in_outpost;
461+
const bool show_screenshot = is_in_explorable ? show_screenshot_button_in_explorable : show_screenshot_button_in_outpost;
462+
if (!show_cog && !show_screenshot) return;
463+
464+
// Suppress the entire overlay on the frame we're actually capturing
465+
// — we don't want our own icons to appear in the saved PNG.
466+
if (IsScreenshotInFlight()) return;
402467

403468
const ImVec2 mouse_pos = ImGui::GetIO().MousePos;
404-
ToolboxUIElement* hovered_elem = nullptr;
469+
ToolboxUIElement* hovered_cog = nullptr;
470+
ToolboxUIElement* hovered_cam = nullptr;
405471

406472
for (auto* elem : GWToolbox::GetUIElements()) {
407473
if (!elem->visible || !elem->show_titlebar) continue;
@@ -417,41 +483,84 @@ void ToolboxSettings::DrawSettingsCogButtons()
417483
const bool close_btn_shown = elem->IsWindow() && elem->GetVisiblePtr() != nullptr;
418484
const float close_offset = close_btn_shown ? btn_h : 0.f;
419485

420-
const ImVec2 btn_min = {tb.Max.x - close_offset - btn_h, tb.Min.y};
421-
const ImVec2 btn_max = {btn_min.x + btn_h, tb.Max.y};
422-
423-
const bool hovered = mouse_pos.x >= btn_min.x && mouse_pos.x < btn_max.x
424-
&& mouse_pos.y >= btn_min.y && mouse_pos.y < btn_max.y;
425-
426486
ImDrawList* dl = window->DrawList;
427487
dl->PushClipRect(tb.Min, tb.Max, false);
428488

429-
if (hovered) {
430-
hovered_elem = const_cast<ToolboxUIElement*>(elem);
431-
dl->AddRectFilled(btn_min, btn_max, IM_COL32(255, 255, 255, 30));
489+
// Slot 0 (rightmost, just left of the close-button gap) is the cog.
490+
// Slot 1 (one slot further left) is the camera. Only the buttons
491+
// enabled in settings get drawn.
492+
float right_edge = tb.Max.x - close_offset;
493+
494+
const auto draw_button = [&](const char* glyph, ToolboxUIElement** hovered_out) -> bool {
495+
const ImVec2 btn_min = {right_edge - btn_h, tb.Min.y};
496+
const ImVec2 btn_max = {right_edge, tb.Max.y};
497+
right_edge -= btn_h;
498+
499+
const bool hovered = mouse_pos.x >= btn_min.x && mouse_pos.x < btn_max.x
500+
&& mouse_pos.y >= btn_min.y && mouse_pos.y < btn_max.y;
501+
if (hovered) {
502+
*hovered_out = const_cast<ToolboxUIElement*>(elem);
503+
dl->AddRectFilled(btn_min, btn_max, IM_COL32(255, 255, 255, 30));
504+
}
505+
506+
const ImVec2 text_size = ImGui::CalcTextSize(glyph);
507+
const ImVec2 icon_pos = {
508+
btn_min.x + (btn_h - text_size.x) * 0.5f,
509+
tb.Min.y + (btn_h - text_size.y) * 0.5f
510+
};
511+
ImVec4 col = ImGui::GetStyle().Colors[ImGuiCol_Text];
512+
if (!hovered) col.w *= 0.5f;
513+
dl->AddText(icon_pos, ImGui::ColorConvertFloat4ToU32(col), glyph);
432514

433515
const ImVec2 drag = ImGui::GetMouseDragDelta(ImGuiMouseButton_Left);
434-
if (ImGui::IsMouseReleased(ImGuiMouseButton_Left) && drag.x == 0.f && drag.y == 0.f) {
435-
SettingsWindow::Instance().NavigateToSection(elem->SettingsName());
436-
}
516+
return hovered
517+
&& ImGui::IsMouseReleased(ImGuiMouseButton_Left)
518+
&& drag.x == 0.f && drag.y == 0.f;
519+
};
520+
521+
if (show_cog && draw_button(ICON_FA_COG, &hovered_cog)) {
522+
SettingsWindow::Instance().NavigateToSection(elem->SettingsName());
523+
}
524+
525+
if (show_screenshot && draw_button(ICON_FA_CAMERA, &hovered_cam)) {
526+
// Capture the window's current pixel rect. The clamp into the
527+
// back buffer happens later, inside SaveBackbufferRectToFile.
528+
pending_screenshot.active = true;
529+
pending_screenshot.rect = window->Rect();
530+
pending_screenshot.path = BuildScreenshotPath(elem->Name());
531+
// Defer by one frame so the next frame can re-render the
532+
// window without these overlay icons (see early-return above)
533+
// before we read back the swap chain.
534+
pending_screenshot.capture_at_frame = ImGui::GetFrameCount() + 1;
437535
}
438536

439-
const ImVec2 text_size = ImGui::CalcTextSize(ICON_FA_COG);
440-
const ImVec2 icon_pos = {
441-
btn_min.x + (btn_h - text_size.x) * 0.5f,
442-
tb.Min.y + (btn_h - text_size.y) * 0.5f
443-
};
444-
ImVec4 col = ImGui::GetStyle().Colors[ImGuiCol_Text];
445-
if (!hovered) col.w *= 0.5f;
446-
dl->AddText(icon_pos, ImGui::ColorConvertFloat4ToU32(col), ICON_FA_COG);
447537
dl->PopClipRect();
448538
}
449539

450-
if (hovered_elem) {
451-
ImGui::SetTooltip("Open %s settings", hovered_elem->SettingsName());
540+
if (hovered_cog) {
541+
ImGui::SetTooltip("Open %s settings", hovered_cog->SettingsName());
542+
}
543+
else if (hovered_cam) {
544+
ImGui::SetTooltip("Save a PNG screenshot of '%s'", hovered_cam->Name());
452545
}
453546
}
454547

548+
void ToolboxSettings::FlushPendingScreenshot(IDirect3DDevice9* device)
549+
{
550+
if (!pending_screenshot.active) return;
551+
if (ImGui::GetFrameCount() < pending_screenshot.capture_at_frame) return;
552+
553+
RECT r{
554+
static_cast<LONG>(pending_screenshot.rect.Min.x),
555+
static_cast<LONG>(pending_screenshot.rect.Min.y),
556+
static_cast<LONG>(pending_screenshot.rect.Max.x),
557+
static_cast<LONG>(pending_screenshot.rect.Max.y),
558+
};
559+
Resources::SaveBackbufferRectToFile(device, &r, pending_screenshot.path);
560+
561+
pending_screenshot = {};
562+
}
563+
455564
void ToolboxSettings::Update(float)
456565
{
457566
if (!(save_location_data && TIMER_DIFF(location_timer) > 1000))

GWToolboxdll/Modules/ToolboxSettings.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ class ToolboxSettings : public ToolboxUIElement {
3636
void DrawSizeAndPositionSettings() override { }
3737

3838
static void DrawSettingsCogButtons();
39+
// Run at end-of-frame, after ImGui has finished rendering, to actually
40+
// write the queued window screenshot to disk. No-op when no capture is
41+
// pending.
42+
static void FlushPendingScreenshot(IDirect3DDevice9* device);
3943

4044
static inline bool move_all = false;
4145
static inline bool clamp_windows_to_screen = false;
@@ -45,6 +49,10 @@ class ToolboxSettings : public ToolboxUIElement {
4549
static inline bool show_cog_in_explorable = false;
4650
static inline bool show_close_in_outpost = true;
4751
static inline bool show_close_in_explorable = true;
52+
// Off by default — this is a docs-authoring affordance, not a player
53+
// feature.
54+
static inline bool show_screenshot_button_in_outpost = false;
55+
static inline bool show_screenshot_button_in_explorable = false;
4856
static inline bool is_in_explorable = false;
4957
static inline bool is_in_mobile_mode = false;
5058
private:

0 commit comments

Comments
 (0)