Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7a3c3f7
build(screenshot): Add stb_image_write library via FetchContent
bobtista Apr 24, 2026
50c3e27
feat(screenshot): Add threaded JPEG/PNG screenshots without game stalls
bobtista Apr 24, 2026
fc332dc
tweak(screenshot): Name files by timestamp so JPG and PNG sort together
bobtista May 21, 2026
7a736c4
tweak(screenshot): Replicate to Generals
bobtista May 21, 2026
31dbce9
fix(stb): Create stb target on both vcpkg and FetchContent paths
bobtista May 21, 2026
1ae734e
fix(screenshot): Use engine logging and match codebase style
bobtista Jun 18, 2026
c9f43be
fix(screenshot): Replicate to Generals
bobtista Jun 18, 2026
14cc69d
build(stb): use TheSuperHackers comment keyword format
bobtista Jun 29, 2026
0144f86
style(screenshot): drop unused leafname from thread data
bobtista Jun 29, 2026
a6837a0
style(screenshot): Replicate to Generals
bobtista Jun 29, 2026
a1d44bb
chore(screenshot): Remove unused W3DScreenshot.h include from W3DDisplay
bobtista Jul 3, 2026
1b1d930
unify(screenshot): Move W3DScreenshot to Core
bobtista Jul 8, 2026
9ce4a5f
fix(screenshot): Convert 16 bit back buffers to 24 bit color
bobtista Jul 8, 2026
24bf262
refactor(screenshot): Pass JPEG quality from the message handler
bobtista Jul 8, 2026
9270f65
feat(screenshot): Save screenshots into a Screenshots subfolder
bobtista Jul 8, 2026
120921d
refactor(screenshot): Release image buffer in thread data destructor …
bobtista Jul 8, 2026
0e3dabe
tweak(screenshot): Look up file extension from format array with comp…
bobtista Jul 8, 2026
bbb154e
tweak(screenshot): Cap JPEG quality at 95 and make getter const
bobtista Jul 8, 2026
7b46fad
style(screenshot): Remove key mappings from message comments and upda…
bobtista Jul 8, 2026
30256c8
style(screenshot): Remove TheSuperHackers comment prefix from stb.cmake
bobtista Jul 9, 2026
dee0bbb
chore(screenshot): Remove unify script entry for the new stb_image_wr…
bobtista Jul 9, 2026
dd3aa65
refactor(screenshot): Move pixel conversion and file operations to th…
bobtista Jul 9, 2026
47f029b
style(screenshot): Add TheSuperHackers comments to new GlobalData and…
bobtista Jul 9, 2026
f54185d
refactor(screenshot): Assemble the screenshot directory path on the s…
bobtista Jul 9, 2026
6ffc6df
style(screenshot): Simplify TheSuperHackers comments on new GlobalDat…
bobtista Jul 9, 2026
d8e7629
style(screenshot): Fix comment keyword and wording per review
bobtista Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ endif()
include(cmake/config.cmake)
include(cmake/gamespy.cmake)
include(cmake/lzhl.cmake)
include(cmake/stb.cmake)

if (IS_VS6_BUILD)
# The original max sdk does not compile against a modern compiler.
Expand Down
1 change: 1 addition & 0 deletions Core/GameEngine/Include/Common/OptionPreferences.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class OptionPreferences : public UserPreferences
Bool getRightMouseScrollWithAlternateMouseEnabled() const;
Bool getRetaliationModeEnabled();
Bool getDoubleClickAttackMoveEnabled();
Int getJPEGQuality() const;
Real getScrollFactor();
Bool getDrawScrollAnchor();
Bool getMoveScrollAnchor();
Expand Down
10 changes: 9 additions & 1 deletion Core/GameEngine/Include/GameClient/Display.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@
#include "GameClient/GameFont.h"
#include "GameClient/View.h"

enum ScreenshotFormat
{
SCREENSHOT_JPEG,
SCREENSHOT_PNG,

SCREENSHOT_FORMAT_COUNT
};

struct ShroudLevel
{
Short m_currentShroud; ///< A Value of 1 means shrouded. 0 is not. Negative is the count of people looking.
Expand Down Expand Up @@ -172,7 +180,7 @@ class Display : public SubsystemInterface
virtual void preloadModelAssets( AsciiString model ) = 0; ///< preload model asset
virtual void preloadTextureAssets( AsciiString texture ) = 0; ///< preload texture asset

virtual void takeScreenShot() = 0; ///< saves screenshot to a file
virtual void takeScreenShot(ScreenshotFormat format, Int jpegQuality = 80) = 0; ///< saves screenshot in specified format
virtual void toggleMovieCapture() = 0; ///< starts saving frames to an avi or frame sequence
virtual void toggleLetterBox() = 0; ///< enabled letter-boxed display
virtual void enableLetterBox(Bool enable) = 0; ///< forces letter-boxed display on/off
Expand Down
12 changes: 12 additions & 0 deletions Core/GameEngine/Source/Common/OptionPreferences.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ Bool OptionPreferences::getDoubleClickAttackMoveEnabled()
return FALSE;
}

Int OptionPreferences::getJPEGQuality() const
{
OptionPreferences::const_iterator it = find("JPEGQuality");
if (it == end())
return 80;

// TheSuperHackers @feature bobtista 08/07/2026 Cap the quality at 95, because JPEG quality

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think @feature fits either. This talks about about why a quality setting is capped at 95, not about adding a new feature.

// above that increases the file size significantly with no visible benefit.
Int quality = atoi(it->second.str());
return clamp(1, quality, 95);
}

Real OptionPreferences::getScrollFactor()
{
OptionPreferences::const_iterator it = find("ScrollFactor");
Expand Down
10 changes: 9 additions & 1 deletion Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3738,7 +3738,15 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage
case GameMessage::MSG_META_TAKE_SCREENSHOT:
{
if (TheDisplay)
TheDisplay->takeScreenShot();
TheDisplay->takeScreenShot(SCREENSHOT_JPEG, TheGlobalData->m_jpegQuality);
disp = DESTROY_MESSAGE;
break;
}
Comment thread
bobtista marked this conversation as resolved.

case GameMessage::MSG_META_TAKE_SCREENSHOT_PNG:
{
if (TheDisplay)
TheDisplay->takeScreenShot(SCREENSHOT_PNG);
disp = DESTROY_MESSAGE;
break;
}
Expand Down
21 changes: 21 additions & 0 deletions Core/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ static const LookupListRec GameMessageMetaTypeNames[] =
{ "END_PREFER_SELECTION", GameMessage::MSG_META_END_PREFER_SELECTION },

{ "TAKE_SCREENSHOT", GameMessage::MSG_META_TAKE_SCREENSHOT },
{ "TAKE_SCREENSHOT_PNG", GameMessage::MSG_META_TAKE_SCREENSHOT_PNG },
{ "ALL_CHEER", GameMessage::MSG_META_ALL_CHEER },

{ "BEGIN_CAMERA_ROTATE_LEFT", GameMessage::MSG_META_BEGIN_CAMERA_ROTATE_LEFT },
Expand Down Expand Up @@ -945,6 +946,26 @@ void MetaMap::generateMetaMap()
map->m_usableIn = COMMANDUSABLE_GAME;
}
}
{
MetaMapRec *map = TheMetaMap->getMetaMapRec(GameMessage::MSG_META_TAKE_SCREENSHOT);
if (map->m_key == MK_NONE)
{
map->m_key = MK_F12;
map->m_transition = DOWN;
map->m_modState = NONE;
map->m_usableIn = COMMANDUSABLE_EVERYWHERE;
}
}
Comment thread
bobtista marked this conversation as resolved.
{
MetaMapRec *map = TheMetaMap->getMetaMapRec(GameMessage::MSG_META_TAKE_SCREENSHOT_PNG);
if (map->m_key == MK_NONE)
{
map->m_key = MK_F12;
map->m_transition = DOWN;
map->m_modState = CTRL;
map->m_usableIn = COMMANDUSABLE_EVERYWHERE;
}
}

#if defined(RTS_DEBUG)
{
Expand Down
4 changes: 4 additions & 0 deletions Core/GameEngineDevice/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ set(GAMEENGINEDEVICE_SRC
Include/W3DDevice/GameClient/W3DTreeBuffer.h
Include/W3DDevice/GameClient/W3DVideoBuffer.h
Include/W3DDevice/GameClient/W3DView.h
Include/W3DDevice/GameClient/W3DScreenshot.h
# Include/W3DDevice/GameClient/W3DVolumetricShadow.h
Include/W3DDevice/GameClient/W3DWater.h
Include/W3DDevice/GameClient/W3DWaterTracks.h
Expand Down Expand Up @@ -175,6 +176,8 @@ set(GAMEENGINEDEVICE_SRC
Source/W3DDevice/GameClient/W3DTreeBuffer.cpp
Source/W3DDevice/GameClient/W3DVideoBuffer.cpp
Source/W3DDevice/GameClient/W3DView.cpp
Source/W3DDevice/GameClient/W3DScreenshot.cpp
Source/W3DDevice/GameClient/stb_image_write_impl.cpp
# Source/W3DDevice/GameClient/W3dWaypointBuffer.cpp
# Source/W3DDevice/GameClient/W3DWebBrowser.cpp
Source/W3DDevice/GameClient/Water/W3DWater.cpp
Expand Down Expand Up @@ -220,6 +223,7 @@ target_include_directories(corei_gameenginedevice_public INTERFACE
target_link_libraries(corei_gameenginedevice_private INTERFACE
corei_always
corei_main
stb
)

target_link_libraries(corei_gameenginedevice_public INTERFACE
Expand Down
24 changes: 24 additions & 0 deletions Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DScreenshot.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
** Command & Conquer Generals Zero Hour(tm)
Comment thread
bobtista marked this conversation as resolved.
Comment thread
bobtista marked this conversation as resolved.
** Copyright 2025 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#pragma once

#include "GameClient/Display.h"

void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality);

216 changes: 216 additions & 0 deletions Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp
Comment thread
xezon marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
/*
** Command & Conquer Generals Zero Hour(tm)
** Copyright 2025 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#include "W3DDevice/GameClient/W3DScreenshot.h"
#include "Common/GlobalData.h"
#include "GameClient/GameText.h"
#include "GameClient/InGameUI.h"
#include "WW3D2/dx8wrapper.h"
#include "WW3D2/surfaceclass.h"
#include <stb_image_write.h>
Comment thread
bobtista marked this conversation as resolved.

struct ScreenshotThreadData
{
ScreenshotThreadData()
: pixelData(nullptr)
{
}

~ScreenshotThreadData()
Comment thread
xezon marked this conversation as resolved.
{
delete [] pixelData;
}

unsigned char* pixelData;
unsigned int width;
unsigned int height;
bool is16Bit;
char userDataDirectory[_MAX_PATH];
char leafname[_MAX_FNAME];
int quality;
ScreenshotFormat format;
};

static DWORD WINAPI screenshotThreadFunc(LPVOID param)
{
ScreenshotThreadData* data = (ScreenshotThreadData*)param;

// TheSuperHackers @feature bobtista 08/07/2026 Save screenshots into a Screenshots subfolder
// to keep the user data root folder tidy.
char pathname[_MAX_PATH];
strlcpy(pathname, data->userDataDirectory, ARRAY_SIZE(pathname));
strlcat(pathname, "Screenshots\\", ARRAY_SIZE(pathname));
CreateDirectory(pathname, nullptr);
strlcat(pathname, data->leafname, ARRAY_SIZE(pathname));

const unsigned int width = data->width;
const unsigned int height = data->height;
unsigned int x, y, index;

// Convert to R8G8B8 for stb_image_write.
unsigned char* image = new unsigned char[3 * width * height];

if (!data->is16Bit)
{
// Convert A8R8G8B8/X8R8G8B8 to R8G8B8
for (y = 0; y < height; y++)
{
const unsigned char* srcLine = data->pixelData + y * width * 4;
for (x = 0; x < width; x++)
{
index = 3 * (x + y * width);
image[index + 0] = srcLine[4 * x + 2];
image[index + 1] = srcLine[4 * x + 1];
image[index + 2] = srcLine[4 * x + 0];
}
}
}
else
{
// Convert R5G6B5 to R8G8B8
for (y = 0; y < height; y++)
{
const unsigned short* srcLine = (const unsigned short*)(data->pixelData + y * width * 2);
for (x = 0; x < width; x++)
{
const unsigned short rgb = srcLine[x];
index = 3 * (x + y * width);
image[index + 0] = (unsigned char)((rgb & 0xF800) >> 8);
image[index + 1] = (unsigned char)((rgb & 0x07E0) >> 3);
image[index + 2] = (unsigned char)((rgb & 0x001F) << 3);
}
}
}

int success = 0;
switch (data->format)
{
case SCREENSHOT_JPEG:
success = stbi_write_jpg(pathname, width, height, 3, image, data->quality);
break;
case SCREENSHOT_PNG:
success = stbi_write_png(pathname, width, height, 3, image, width * 3);
break;
}

if (!success)
{
DEBUG_LOG(("Failed to write screenshot %s", pathname));
}

delete [] image;
delete data;

return success;
}

void W3D_TakeCompressedScreenshot(ScreenshotFormat format, Int jpegQuality)
{
static const char* const ScreenshotFormatExtensions[] = { "jpg", "png" };
static_assert(ARRAY_SIZE(ScreenshotFormatExtensions) == SCREENSHOT_FORMAT_COUNT, "Incorrect array size");

// The filename is created here because the success message below shows it.
char leafname[_MAX_FNAME];
const char* extension = ScreenshotFormatExtensions[format];

SYSTEMTIME st;
GetLocalTime(&st);
sprintf(leafname, "sshot_%04d%02d%02d_%02d%02d%02d_%03d.%s",
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds, extension);

// TheSuperHackers @bugfix xezon 21/05/2025 Get the back buffer and create a copy of the surface.
// Originally this code took the front buffer and tried to lock it. This does not work when the
// render view clips outside the desktop boundaries. It crashed the game.
SurfaceClass* surface = DX8Wrapper::_Get_DX8_Back_Buffer();
SurfaceClass::SurfaceDescription surfaceDesc;
surface->Get_Description(surfaceDesc);

// TheSuperHackers @bugfix bobtista 08/07/2026 Support the 16 bit back buffer format that the
// game uses when running in 16 bit color mode. Reading it with the 32 bit stride read garbage.
const bool is32Bit = surfaceDesc.Format == WW3D_FORMAT_A8R8G8B8 || surfaceDesc.Format == WW3D_FORMAT_X8R8G8B8;
const bool is16Bit = surfaceDesc.Format == WW3D_FORMAT_R5G6B5;

if (!is32Bit && !is16Bit)
{
DEBUG_LOG(("Screenshot does not support back buffer format %d", (int)surfaceDesc.Format));
surface->Release_Ref();
return;
}

SurfaceClass* surfaceCopy = NEW_REF(SurfaceClass, (DX8Wrapper::_Create_DX8_Surface(surfaceDesc.Width, surfaceDesc.Height, surfaceDesc.Format)));
DX8Wrapper::_Copy_DX8_Rects(surface->Peek_D3D_Surface(), nullptr, 0, surfaceCopy->Peek_D3D_Surface(), nullptr);

surface->Release_Ref();
surface = nullptr;

struct Rect
{
int Pitch;
void* pBits;
} lrect;

lrect.pBits = surfaceCopy->Lock(&lrect.Pitch);
if (lrect.pBits == nullptr)
{
surfaceCopy->Release_Ref();
return;
}

unsigned int y;
unsigned int width = surfaceDesc.Width;
unsigned int height = surfaceDesc.Height;

// Copy the surface rows into a tightly packed buffer. The pixel conversion and all file
// operations are done on the screenshot thread to keep the main thread cheap.
const unsigned int bytesPerPixel = is32Bit ? 4 : 2;
unsigned char* pixels = new unsigned char[bytesPerPixel * width * height];

for (y = 0; y < height; y++)
{
memcpy(pixels + y * width * bytesPerPixel, (const unsigned char*)lrect.pBits + y * lrect.Pitch, width * bytesPerPixel);
}

surfaceCopy->Unlock();
surfaceCopy->Release_Ref();
surfaceCopy = nullptr;

ScreenshotThreadData* threadData = new ScreenshotThreadData();
threadData->pixelData = pixels;
threadData->width = width;
threadData->height = height;
threadData->is16Bit = is16Bit;
threadData->quality = jpegQuality;
threadData->format = format;
strlcpy(threadData->userDataDirectory, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(threadData->userDataDirectory));
strlcpy(threadData->leafname, leafname, ARRAY_SIZE(threadData->leafname));

DWORD threadId;
HANDLE hThread = CreateThread(nullptr, 0, screenshotThreadFunc, threadData, 0, &threadId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Spawning new thread for every image is quite an expensive approach. It would be better to have a screenshot processing thread sleeping and wake up when new work is ready. Unfortunately with c++98 we do not have good sync primitives for that so maybe the thread spawn is ok for now.

if (hThread)
{
CloseHandle(hThread);

UnicodeString ufileName;
ufileName.translate(leafname);
TheInGameUI->message(TheGameText->fetch("GUI:ScreenCapture"), ufileName.str());
}
else
{
delete threadData;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
** Command & Conquer Generals(tm)
** Copyright 2025 TheSuperHackers
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#define STB_IMAGE_WRITE_IMPLEMENTATION
#include <stb_image_write.h>

Loading
Loading