Skip to content

feat(screenshot): Add threaded JPEG/PNG screenshots without game stalls#1785

Open
bobtista wants to merge 20 commits into
TheSuperHackers:mainfrom
bobtista:bobtista/compressed-screenshot-f11
Open

feat(screenshot): Add threaded JPEG/PNG screenshots without game stalls#1785
bobtista wants to merge 20 commits into
TheSuperHackers:mainfrom
bobtista:bobtista/compressed-screenshot-f11

Conversation

@bobtista

@bobtista bobtista commented Nov 3, 2025

Copy link
Copy Markdown

Summary

Replaces the old BMP screenshot with compressed JPEG screenshots that don't stall the game, and adds PNG support.

Closes #1555
Closes #106 ... sort of

Adds a new screenshot function using the stb_image_write library with background threading:

  • F12 - Compressed JPEG screenshot (no stalling, ~600KB files)
  • Ctrl+F12 - Lossless PNG screenshot

Notes

  • stb_image-write is public domain licensed, single-header, zero compilation needed
  • Captures frame buffer on main thread (~1-2ms)
  • Spawns detached thread for JPEG/PNG compression and disk I/O
  • JPEG quality configurable via Options.ini (default 80)
  • Works for both Generals and Zero Hour

Testing

  • Screenshots save correctly with F12 (jpg) and Ctrl+F12 (png)
  • No game stalling during screenshot
  • Files appear in correct directory with sequential numbering
    sshot003

@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch 4 times, most recently from 20a3df1 to 37bd840 Compare November 3, 2025 04:32
@Stubbjax

Stubbjax commented Nov 3, 2025

Copy link
Copy Markdown

Some initial thoughts:

  • Keep variants of the same function to a single hotkey; instead toggle image format via a new setting in Options.ini
  • Apply image compression / quality via a new setting in Options.ini
  • Can we consolidate this logic in core instead of repeating the implementation twice?

@xezon

xezon commented Nov 3, 2025

Copy link
Copy Markdown

Agree with Stubbjax.

JPG 90 is big file. Better make it default 80.

Replace BMP screenshot with PNG screenshot. PNG is lossless compressed and always better than BMP.

Make F12 take JPG 80 screenshot. Make CTRL+F12 take PNG screenshot. Make JPG Quality adjustable.

Remove the old BMP code(s) and only use the new code for screenshot.

@xezon

xezon commented Nov 3, 2025

Copy link
Copy Markdown

Regarding Github formatting:

When you write

Addresses #1555

Then it will not close this report when this is merged.

Please read up on it here:
https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue#closing-multiple-issues

@xezon xezon added Enhancement Is new feature or request Major Severity: Minor < Major < Critical < Blocker Gen Relates to Generals ZH Relates to Zero Hour labels Nov 3, 2025
@L3-M L3-M added this to the GenTool features replication milestone Nov 3, 2025
@L3-M L3-M added the Input label Nov 3, 2025
@bobtista

bobtista commented Nov 3, 2025

Copy link
Copy Markdown
Author

Some initial thoughts:

  • Keep variants of the same function to a single hotkey; instead toggle image format via a new setting in Options.ini
  • Apply image compression / quality via a new setting in Options.ini
  • Can we consolidate this logic in core instead of repeating the implementation twice?

RE moving logic to core, I moved what I could to core - but there are a lot more files that need to be moved to core before this can be moved there eg WWVegas/WW3D2/*

@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch 3 times, most recently from 3535e1e to efc773f Compare November 3, 2025 17:45
Comment thread Generals/Code/GameEngineDevice/Include/W3DDevice/GameClient/W3DDisplay.h Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp Outdated
Comment thread Generals/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp Outdated
@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch 2 times, most recently from 977a6dc to f8162f3 Compare November 3, 2025 23:07
Comment thread cmake/stb.cmake Outdated
@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch from d7e8a8d to d197bdd Compare November 5, 2025 18:04
@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch from d197bdd to 9669966 Compare November 11, 2025 21:27
@xezon

xezon commented Nov 22, 2025

Copy link
Copy Markdown

Needs rebase.

@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch from 9669966 to 4897b0b Compare November 22, 2025 18:10
@bobtista

Copy link
Copy Markdown
Author

Needs rebase.

Done

Comment thread Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DScreenshot.h Outdated
Comment thread Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DScreenshot.cpp
Comment thread Generals/Code/GameEngine/Include/Common/MessageStream.h Outdated
Comment thread Generals/Code/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp Outdated
Comment thread Generals/Code/GameEngine/Source/GameClient/MessageStream/MetaEvent.cpp Outdated
Comment thread GeneralsMD/Code/GameEngine/Include/Common/MessageStream.h Outdated
Comment thread Generals/Code/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp Outdated
Skyaero42
Skyaero42 previously approved these changes Nov 22, 2025

@Skyaero42 Skyaero42 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Looks good to me

@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch from 9c99306 to de35e57 Compare December 3, 2025 16:48
@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch from ee755ff to c9f43be Compare June 29, 2026 19:14

@Skyaero42 Skyaero42 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is good to go imo

Comment thread cmake/stb.cmake Outdated
@@ -0,0 +1,20 @@
# TheSuperHackers @build bobtista 02/11/2025 STB single-file public domain libraries for image encoding

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Obsolete comment in cmake file

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Reworded, not sure if you wanted it removed or not

@xezon xezon Jul 9, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We never added TheSuperHackers comment prefixes in cmake files because they are implicitly all ours.

return 80;

Int quality = atoi(it->second.str());
return clamp(1, quality, 100);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Is Quality of 1 usable? I would expect the image falls apart below 30 or so.

I think quality 100 is waste. It ceils at 95 or so.

Chat Gippy says:

The answer depends on what you mean by the "quality value," because different JPEG encoders use different scales. Assuming you're referring to the common quality setting from 0–100 (used by libraries like libjpeg, Pillow, ImageMagick, etc.):

  • 0–20: Very low quality. Strong compression artifacts; generally only useful for thumbnails or previews.

  • 30–50: Low quality. Noticeable blockiness and ringing, but sometimes acceptable for web previews.

  • 60–75: Reasonable minimum for most photographs. Compression artifacts are usually visible only on close inspection.

  • 80–90: Sweet spot for most uses. Very good visual quality with significantly smaller files than maximum quality.

  • 90–95: Excellent quality. File size increases rapidly while visual improvements become difficult to notice.

  • 96–100: Usually not recommended. Files become much larger with little or no visible benefit. Some encoders also disable or minimize chroma subsampling at the highest settings, causing a large jump in file size.

A practical recommendation is:

Minimum and maximum "reasonable" values

  • Minimum reasonable: 60 (below this, artifacts become increasingly obvious for typical photos).

  • Maximum reasonable: 95 (above this, file size grows disproportionately compared to the tiny quality gain).

If you're using a specific JPEG encoder (such as libjpeg-turbo, mozjpeg, Photoshop, GIMP, or Apple's APIs), the interpretation of the quality value may differ slightly, but 60–95 remains a sensible general range, with 80–90 being the best balance for most images.

Use case Recommended quality
Small web images 70–80
General web photos 80–85
High-quality photos 85–90
Archiving/editing (JPEG only) 90–95
Avoid >95 unless you have a specific reason

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ok, clamped 1-95, I don't see the harm in letting people choose potato quality on the low end if they want

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 don't think users should be gatekeeped like that. Applications like Photoshop, Gimp and Paint.net are not clamping the values either and allow any quality between 1 and 100. We should do the same.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Photoshop is for image professionals. Game screenshot is for gamers. Gamers will not know that JPG screenshot quality of 100 will make their images 4 times larger for no quality gain. it is best to protect them from bad decisions.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can you show screenshot with quality 1 ?

@githubawn githubawn Jul 9, 2026

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 did a programmatic perception test using LPIPS. If we don't want gamers to be able to output the "wrong" image quality then the sweet spot is either 90 or 91, which can be hardcoded.

image

90
quality_90

91
quality_91

1
quality_1

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Quality 1 looks absolutely useless.

enum ScreenshotFormat
{
SCREENSHOT_JPEG,
SCREENSHOT_PNG

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Minor: Can add comma behind PNG

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done, and added SCREENSHOT_FORMAT_COUNT for the extension array check

Bool getRightMouseScrollWithAlternateMouseEnabled() const;
Bool getRetaliationModeEnabled();
Bool getDoubleClickAttackMoveEnabled();
Int getJPEGQuality();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Can be const

Comment thread Core/GameEngineDevice/CMakeLists.txt Outdated
Source/W3DDevice/GameClient/W3DTreeBuffer.cpp
Source/W3DDevice/GameClient/W3DVideoBuffer.cpp
Source/W3DDevice/GameClient/W3DView.cpp
# Source/W3DDevice/GameClient/W3DScreenshot.cpp

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

What is this for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

stb_image_write is a header-only library; this TU defines STB_IMAGE_WRITE_IMPLEMENTATION so the implementation compiles once in Core then links into both games

DEBUG_LOG(("Failed to write screenshot %s", data->pathname));
}

delete [] data->imageData;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe move that to the destructor of the struct.

{
char leafname[_MAX_FNAME];
char pathname[_MAX_PATH];
const char* extension = (format == SCREENSHOT_JPEG) ? "jpg" : "png";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Better make a extra function or array for this and align it with the enum values and static assert on its completeness.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done, with a static_assert against SCREENSHOT_FORMAT_COUNT

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);
strlcpy(pathname, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(pathname));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does it save them into the User Data root? Maybe this is a good opportunity to move them into a Screenshot/Image subfolder. It removes clutter from the User Data root where the INI configs are.

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

unsigned char* image = new unsigned char[3 * width * height];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Does the 24 bit image work correctly on 16 bit game? I know that on Windows XP (and maybe 7) it was possible to run Generals Zero Hour in 16 bit color mode and then it is necessary to convert the 16 bit render image to 24 bit color jpg. GenTool did that and its screenshots can be taken in 16 bit color game.

Here is the code that GenTool used:

bool CScreenshot::CopyBitsAs24Bit(SImageData* pImageData, TBytes& imageBytes)
{
	// Note: The conversion algorithm does flip the bit map vertically

	const bool is32bit = pImageData->d3dFormat == D3DFMT_A8R8G8B8;
	const bool is16bit = pImageData->d3dFormat == D3DFMT_R5G6B5;

	if (!is32bit && !is16bit)
		return false;

	D3DLOCKED_RECT lockedRect = {0};
	if (FAILED(pImageData->GetSurface()->LockRect(&lockedRect, NULL, D3DLOCK_READONLY)))
		return false;

	const int w = (int)pImageData->width;
	const int h = (int)pImageData->height;
	BYTE* pDest = &imageBytes[0];

	if (is32bit)
	{
		// Convert A8 R8 G8 B8 (32 bit) to R8 G8 B8 (24 bit)

		for (int y = h-1; y >= 0; --y)
		{
			// Source RGB
			// r 11111111000000000000000000000000
			// g 00000000111111110000000000000000
			// b 00000000000000001111111100000000
			BYTE* pSrcLine = static_cast<BYTE*>(lockedRect.pBits) + y*w*4;

			for (int x = 0; x < w; ++x)
			{
				pDest[0] = pSrcLine[0];
				pDest[1] = pSrcLine[1];
				pDest[2] = pSrcLine[2];
				pDest += 3;
				pSrcLine += 4;
			}
		}
	}
	else if (is16bit)
	{
		// Convert R5 G6 B5 (16 bit) to R8 G8 B8 (24 bit)

		for (int y = h-1; y >= 0; --y)
		{
			// Source RGB
			// r 1111100000000000
			// g 0000011111100000
			// b 0000000000011111
			WORD* pSrcLine = static_cast<WORD*>(lockedRect.pBits) + y*w;

			for (int x = 0; x < w; ++x)
			{
				// Destination RGB
				// 111110000000000000000000
				// 000000001111110000000000
				// 000000000000000011111000
				const WORD rgb = *pSrcLine;
				const BYTE r = static_cast<BYTE>((rgb & 0xF800) >> 8);
				const BYTE g = static_cast<BYTE>((rgb & 0x07E0) >> 3);
				const BYTE b = static_cast<BYTE>((rgb & 0x001F) << 3);
				pDest[0] = b;
				pDest[1] = g;
				pDest[2] = r;
				pDest += 3;
				pSrcLine += 1;
			}
		}
	}

	pImageData->GetSurface()->UnlockRect();
	return true;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

It didn't, thank you for sharing the code. Added your R5G6B5 to R8G8B8 conversion, and unknown formats now bail out

{
ScreenshotThreadData* data = (ScreenshotThreadData*)param;

int result = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

success

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

renamed to succes

Comment thread Generals/Code/Tools/GUIEdit/Include/GUIEditDisplay.h Outdated
@bobtista bobtista force-pushed the bobtista/compressed-screenshot-f11 branch from ec48835 to 7b46fad Compare July 8, 2026 20:52

unsigned char* image = new unsigned char[3 * width * height];

if (is32Bit)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For less stalling could also memcpy the lrect.pBits to a temporary buffer and then handle the conversion to R8G8B8 in screenshotThreadFunc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is when the high performance bros gather around the camp fire to tell the legandary tales.

// to keep them out of the User Data root.
strlcpy(pathname, TheGlobalData->getPath_UserData().str(), ARRAY_SIZE(pathname));
strlcat(pathname, "Screenshots\\", ARRAY_SIZE(pathname));
CreateDirectory(pathname, nullptr);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This file operation better also be done on thread. The whole file pathing can be done there. Keep main thread as cheap as possible.

strlcpy(threadData->pathname, pathname, ARRAY_SIZE(threadData->pathname));

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Enhancement Is new feature or request Gen Relates to Generals Input Major Severity: Minor < Major < Critical < Blocker ZH Relates to Zero Hour

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add JPG/PNG Screenshot with no stalls BMP Screenshot taken with F12 can stall game significantly

9 participants