From 92281bd353ec5d047bbb3cf5d2fab00c02409cf1 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 16 Oct 2026 22:03:37 +0200 Subject: [PATCH 1/3] fix(render): keep the pixels of canvas textures The game draws two textures through their CPU pixels: the guild mark (BITMAP_GUILD, CreateGuildMark) and the castle owner's mark on the flag in Valley of Loren (BITMAP_INTERFACE_MAP, CreateCastleMark). Only a comment kept upscaled art away from them, and it named the wrong ones. An upscaled Object31/Flag.OZT lost its pixels after the upload and the castle mark wrote through a null pointer. They now load through LoadCanvasBitmap, which asks the loader to keep the pixels. The mark writers are laid out for the original size, so an upscaled file loaded into a canvas is shrunk back to that size, each pixel the average of its block, and uploaded without mips. CreateCastleMark also checks that the bitmap is loaded, like CreateGuildMark. --- src/source/Engine/Object/ZzzInventory.cpp | 6 ++ src/source/Engine/Object/ZzzOpenData.cpp | 4 +- src/source/Render/Sprites/GlobalBitmap.cpp | 64 ++++++++++++++------ src/source/Render/Sprites/GlobalBitmap.h | 17 ++++-- src/source/Render/Textures/UpscaleMarker.cpp | 31 ++++++++++ src/source/Render/Textures/UpscaleMarker.h | 8 +++ src/source/Render/Textures/ZzzTexture.cpp | 55 +++++++++++------ src/source/Render/Textures/ZzzTexture.h | 4 ++ tests/render/test_upscale_marker.cpp | 13 ++++ 9 files changed, 160 insertions(+), 42 deletions(-) diff --git a/src/source/Engine/Object/ZzzInventory.cpp b/src/source/Engine/Object/ZzzInventory.cpp index 876c91b88f..c02091f7e9 100644 --- a/src/source/Engine/Object/ZzzInventory.cpp +++ b/src/source/Engine/Object/ZzzInventory.cpp @@ -7262,6 +7262,12 @@ void CreateCastleMark(int Type, BYTE* buffer, bool blend) Width = (int)b->Width; Height = (int)b->Height; BYTE* Buffer = b->Buffer; + if (!Buffer || Width == 0 || Height == 0) + { + mu::log::Get("gameplay") + ->error("[CreateCastleMark] bitmap {} not loaded (BitmapIndex={})", Type, b->BitmapIndex); + return; + } int alpha = 128; if (blend) diff --git a/src/source/Engine/Object/ZzzOpenData.cpp b/src/source/Engine/Object/ZzzOpenData.cpp index 5592019104..63c036a997 100644 --- a/src/source/Engine/Object/ZzzOpenData.cpp +++ b/src/source/Engine/Object/ZzzOpenData.cpp @@ -3498,7 +3498,7 @@ void OpenImages() LoadBitmap(L"Interface\\lock_02.jpg", BITMAP_INVENTORY_BUTTON + 15); LoadBitmap(L"Interface\\lock_03.jpg", BITMAP_INVENTORY_BUTTON + 16); LoadBitmap(L"Interface\\lock_04.jpg", BITMAP_INVENTORY_BUTTON + 17); - LoadBitmap(L"Interface\\guild.tga", BITMAP_GUILD); + LoadCanvasBitmap(L"Interface\\guild.tga", BITMAP_GUILD); } void OpenSounds() @@ -4197,7 +4197,7 @@ void OpenBasicData(HDC hDC) LoadBitmap(L"Interface\\DoorCL.jpg", BITMAP_INTERFACE_EX + 40); LoadBitmap(L"Interface\\DoorOP.jpg", BITMAP_INTERFACE_EX + 41); //OpenJpeg( "Effect\\FireSnuff.jpg" , BITMAP_FIRE_SNUFF, GL_LINEAR, GL_CLAMP_TO_EDGE ); - LoadBitmap(L"Object31\\Flag.tga", BITMAP_INTERFACE_MAP + 0, GL_LINEAR, GL_CLAMP_TO_EDGE); + LoadCanvasBitmap(L"Object31\\Flag.tga", BITMAP_INTERFACE_MAP + 0, GL_LINEAR, GL_CLAMP_TO_EDGE); LoadBitmap(L"Interface\\CursorAttack2.tga", BITMAP_CURSOR2); LoadBitmap(L"Effect\\Cratered.tga", BITMAP_CRATER); LoadBitmap(L"Effect\\FormationMark.tga", BITMAP_FORMATION_MARK); diff --git a/src/source/Render/Sprites/GlobalBitmap.cpp b/src/source/Render/Sprites/GlobalBitmap.cpp index 9c285996b8..dba5b405dd 100644 --- a/src/source/Render/Sprites/GlobalBitmap.cpp +++ b/src/source/Render/Sprites/GlobalBitmap.cpp @@ -185,9 +185,9 @@ SDL_GPUSampler* CreateSampler(SDL_GPUDevice* device, SDL_GPUFilter filter, SDL_G } // The CPU copy of an upscaled texture is up to 16 times the original and -// nothing reads it after the upload: only the canvases (BITMAP_FONT, -// BITMAP_GUILD) are redrawn through Buffer, and those never carry the marker. -// Keeping the copies took the 32 bit client past 3.3 GB of address space. +// nothing reads it after the upload: only the canvases (LoadCanvasBitmap) are +// redrawn through Buffer, and those are never uploaded upscaled. Keeping the +// copies took the 32 bit client past 3.3 GB of address space. // Returns the bytes released. std::size_t ReleaseUploadedPixels(BITMAP_t& bitmap) { @@ -197,6 +197,18 @@ std::size_t ReleaseUploadedPixels(BITMAP_t& bitmap) return released; } +// A canvas is drawn on through Buffer at the original size, so an upscaled file +// loaded into one is brought back to that size. Width and height become the +// size to upload. +void ShrinkCanvas(BITMAP_t& bitmap, int& width, int& height, int scale) +{ + bitmap.BufferStorage = + Render::Textures::Upscale::ShrinkToOriginal(bitmap.BufferStorage, width, height, bitmap.Components, scale); + bitmap.Buffer = bitmap.BufferStorage.data(); + width /= scale; + height /= scale; +} + bool UploadTextureSDLGpu(BITMAP_t* bitmap, const std::uint8_t* pixelData, int width, int height, SDL_GPUFilter filter, SDL_GPUSamplerAddressMode wrapMode, bool mipmapped) { @@ -609,7 +621,8 @@ GLuint CGlobalBitmap::LoadImageExclusive(const std::wstring& filename, GLuint ui } return BITMAP_UNKNOWN; } -bool CGlobalBitmap::LoadImage(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter, GLuint uiWrapMode, bool padToPowerOfTwo, bool premultiplyAlpha) +bool CGlobalBitmap::LoadImage(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter, GLuint uiWrapMode, + bool padToPowerOfTwo, bool premultiplyAlpha, bool keepPixels) { unsigned int UICLAMP = GL_CLAMP_TO_EDGE; unsigned int UIREPEAT = GL_REPEAT; @@ -657,9 +670,9 @@ bool CGlobalBitmap::LoadImage(GLuint uiBitmapIndex, const std::wstring& filename bool loaded = false; if (0 == _wcsicmp(ext.c_str(), L"jpg")) - loaded = OpenJpegTurbo(uiBitmapIndex, filename, uiFilter, uiWrapMode, padToPowerOfTwo); + loaded = OpenJpegTurbo(uiBitmapIndex, filename, uiFilter, uiWrapMode, padToPowerOfTwo, keepPixels); else if (0 == _wcsicmp(ext.c_str(), L"tga")) - loaded = OpenTga(uiBitmapIndex, filename, uiFilter, uiWrapMode, padToPowerOfTwo, premultiplyAlpha); + loaded = OpenTga(uiBitmapIndex, filename, uiFilter, uiWrapMode, padToPowerOfTwo, premultiplyAlpha, keepPixels); #ifdef _EDITOR if (loaded) m_loadWorlds[uiBitmapIndex] = Core::AssetLoadWorld::Get(); @@ -879,7 +892,7 @@ GLuint CGlobalBitmap::FindAvailableTextureIndex(GLuint uiSeed) } bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter, - GLuint uiWrapMode, bool padToPowerOfTwo) + GLuint uiWrapMode, bool padToPowerOfTwo, bool keepPixels) { std::wstring filename_ozj; ExchangeExt(filename, L"OZJ", filename_ozj); @@ -938,11 +951,11 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file const int textureWidth = padToPowerOfTwo ? NextPowerOfTwo(jpegWidth, MAX_WIDTH) : jpegWidth; const int textureHeight = padToPowerOfTwo ? NextPowerOfTwo(jpegHeight, MAX_HEIGHT) : jpegHeight; const int scale = Render::Textures::Upscale::ReadScale(jpegBuf, textureWidth, textureHeight); + const bool upscaled = scale > Render::Textures::Upscale::NoScale && !keepPixels; auto pNewBitmap = CreateBitmapStorage(uiBitmapIndex, filename, textureWidth, textureHeight, 3); pNewBitmap->Width = static_cast(textureWidth / scale); pNewBitmap->Height = static_cast(textureHeight / scale); - m_dwUsedTextureMemory += static_cast(pNewBitmap->BufferStorage.size()); const int jpegRowSize = jpegWidth * 3; const int textureRowSize = textureWidth * 3; @@ -964,15 +977,23 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file static_cast(jpegHeight) * static_cast(jpegWidth) * 3u); } + int uploadWidth = textureWidth; + int uploadHeight = textureHeight; + if (keepPixels && scale > Render::Textures::Upscale::NoScale) + { + ShrinkCanvas(*pNewBitmap, uploadWidth, uploadHeight, scale); + } + m_dwUsedTextureMemory += static_cast(pNewBitmap->BufferStorage.size()); + pNewBitmap->TextureNumber = uiBitmapIndex; - std::vector rgbaData = PadRGBToRGBA(pNewBitmap->Buffer, textureWidth, textureHeight); - if (!UploadTextureSDLGpu(pNewBitmap.get(), rgbaData.data(), textureWidth, textureHeight, MapGLFilterToSDL(uiFilter), - MapGLWrapToSDL(uiWrapMode), scale > Render::Textures::Upscale::NoScale)) + std::vector rgbaData = PadRGBToRGBA(pNewBitmap->Buffer, uploadWidth, uploadHeight); + if (!UploadTextureSDLGpu(pNewBitmap.get(), rgbaData.data(), uploadWidth, uploadHeight, MapGLFilterToSDL(uiFilter), + MapGLWrapToSDL(uiWrapMode), upscaled)) { g_ErrorReport.Write(L"SDL texture upload failed %ls (%d)\r\n", filename.c_str(), uiBitmapIndex); return false; } - if (scale > Render::Textures::Upscale::NoScale) + if (upscaled) { m_dwUsedTextureMemory -= static_cast(ReleaseUploadedPixels(*pNewBitmap)); } @@ -986,7 +1007,8 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file return true; } -bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter, GLuint uiWrapMode, bool padToPowerOfTwo, bool premultiplyAlpha) +bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter, GLuint uiWrapMode, + bool padToPowerOfTwo, bool premultiplyAlpha, bool keepPixels) { namespace Ozt = Render::Textures::Ozt; @@ -1020,12 +1042,12 @@ bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, const int Width = padToPowerOfTwo ? NextPowerOfTwo(header.width, MAX_WIDTH) : header.width; const int Height = padToPowerOfTwo ? NextPowerOfTwo(header.height, MAX_HEIGHT) : header.height; const int scale = Render::Textures::Upscale::ReadScale(pakBuffer, Width, Height); + const bool upscaled = scale > Render::Textures::Upscale::NoScale && !keepPixels; auto pNewBitmap = CreateBitmapStorage(uiBitmapIndex, filename, Width, Height, 4); pNewBitmap->Width = static_cast(Width / scale); pNewBitmap->Height = static_cast(Height / scale); const std::size_t BufferSize = pNewBitmap->BufferStorage.size(); - m_dwUsedTextureMemory += static_cast(BufferSize); Ozt::CopyPixels(pakBuffer, header, std::span(pNewBitmap->Buffer, BufferSize), Width); if (premultiplyAlpha) @@ -1033,14 +1055,22 @@ bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, Render::RmlUi::PremultiplyAlpha(pNewBitmap->Buffer, BufferSize / Ozt::BytesPerPixel); } + int uploadWidth = Width; + int uploadHeight = Height; + if (keepPixels && scale > Render::Textures::Upscale::NoScale) + { + ShrinkCanvas(*pNewBitmap, uploadWidth, uploadHeight, scale); + } + m_dwUsedTextureMemory += static_cast(pNewBitmap->BufferStorage.size()); + pNewBitmap->TextureNumber = uiBitmapIndex; - if (!UploadTextureSDLGpu(pNewBitmap.get(), pNewBitmap->Buffer, Width, Height, MapGLFilterToSDL(uiFilter), - MapGLWrapToSDL(uiWrapMode), scale > Render::Textures::Upscale::NoScale)) + if (!UploadTextureSDLGpu(pNewBitmap.get(), pNewBitmap->Buffer, uploadWidth, uploadHeight, + MapGLFilterToSDL(uiFilter), MapGLWrapToSDL(uiWrapMode), upscaled)) { g_ErrorReport.Write(L"SDL texture upload failed %ls (%d)\r\n", filename.c_str(), uiBitmapIndex); return false; } - if (scale > Render::Textures::Upscale::NoScale) + if (upscaled) { m_dwUsedTextureMemory -= static_cast(ReleaseUploadedPixels(*pNewBitmap)); } diff --git a/src/source/Render/Sprites/GlobalBitmap.h b/src/source/Render/Sprites/GlobalBitmap.h index c360dbc90c..e805e9b7ab 100644 --- a/src/source/Render/Sprites/GlobalBitmap.h +++ b/src/source/Render/Sprites/GlobalBitmap.h @@ -24,8 +24,8 @@ struct BITMAP_t GLuint BitmapIndex; wchar_t FileName[MAX_BITMAP_FILE_NAME]; // The size the game works with. For upscaled art (Render/Textures/UpscaleMarker.h) - // the uploaded texture is larger and Buffer is released after the upload. A bitmap - // drawn on through Buffer (BITMAP_FONT, BITMAP_GUILD) must therefore never be upscaled. + // the uploaded texture is larger and Buffer is released after the upload. A canvas + // (LoadCanvasBitmap: BITMAP_GUILD, BITMAP_INTERFACE_MAP) keeps Buffer at this size. float Width; float Height; char Components; @@ -131,7 +131,11 @@ class CGlobalBitmap virtual ~CGlobalBitmap(); GLuint LoadImage(const std::wstring& filename, GLuint uiFilter = GL_NEAREST, GLuint uiWrapMode = GL_CLAMP_TO_EDGE); - bool LoadImage(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter = GL_NEAREST, GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool padToPowerOfTwo = true, bool premultiplyAlpha = false); + // keepPixels: the game draws on the texture through Buffer (a canvas), so Buffer stays + // at the original size, also for an upscaled file. + bool LoadImage(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter = GL_NEAREST, + GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool padToPowerOfTwo = true, bool premultiplyAlpha = false, + bool keepPixels = false); // Like LoadImage(filename, ...), but never shares an existing by-filename match -- always // creates its own independent entry, even if the same file is already resident under a // numbered slot (e.g. BITMAP_LOG_IN+7). Needed by callers that cache the resolved GPU @@ -180,8 +184,11 @@ class CGlobalBitmap GLuint GenerateTextureIndex(); GLuint FindAvailableTextureIndex(GLuint uiSeed); - bool OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter = GL_NEAREST, GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool padToPowerOfTwo = true); - bool OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter = GL_NEAREST, GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool padToPowerOfTwo = true, bool premultiplyAlpha = false); + bool OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter = GL_NEAREST, + GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool padToPowerOfTwo = true, bool keepPixels = false); + bool OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, GLuint uiFilter = GL_NEAREST, + GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool padToPowerOfTwo = true, bool premultiplyAlpha = false, + bool keepPixels = false); void SplitFileName(IN const std::wstring& filepath, OUT std::wstring& filename, bool bIncludeExt); void SplitExt(IN const std::wstring& filepath, OUT std::wstring& ext, bool bIncludeDot); void ExchangeExt(IN const std::wstring& in_filepath, IN const std::wstring& ext, OUT std::wstring& out_filepath); diff --git a/src/source/Render/Textures/UpscaleMarker.cpp b/src/source/Render/Textures/UpscaleMarker.cpp index 8b516f72bd..618d0203f3 100644 --- a/src/source/Render/Textures/UpscaleMarker.cpp +++ b/src/source/Render/Textures/UpscaleMarker.cpp @@ -33,4 +33,35 @@ int ReadScale(std::span file, int textureWidth, int textureH } return scale; } + +std::vector ShrinkToOriginal(std::span pixels, int width, int height, + int bytesPerPixel, int scale) +{ + const int originalWidth = width / scale; + const int originalHeight = height / scale; + const int blockPixels = scale * scale; + std::vector original(static_cast(originalWidth) * originalHeight * bytesPerPixel); + + for (int y = 0; y < originalHeight; ++y) + { + for (int x = 0; x < originalWidth; ++x) + { + for (int channel = 0; channel < bytesPerPixel; ++channel) + { + int sum = 0; + for (int blockY = 0; blockY < scale; ++blockY) + { + const std::size_t row = static_cast(y * scale + blockY) * width; + for (int blockX = 0; blockX < scale; ++blockX) + { + sum += pixels[(row + x * scale + blockX) * bytesPerPixel + channel]; + } + } + original[(static_cast(y) * originalWidth + x) * bytesPerPixel + channel] = + static_cast(sum / blockPixels); + } + } + } + return original; +} } // namespace Render::Textures::Upscale diff --git a/src/source/Render/Textures/UpscaleMarker.h b/src/source/Render/Textures/UpscaleMarker.h index 37e6d5118c..85255c6524 100644 --- a/src/source/Render/Textures/UpscaleMarker.h +++ b/src/source/Render/Textures/UpscaleMarker.h @@ -3,6 +3,7 @@ #include #include #include +#include // Upscaled art: an OZJ or OZT file whose image is 2 or 4 times the size the // game was written against. Such a file starts with 'M' 'U' 'X' and the scale, @@ -19,4 +20,11 @@ inline constexpr int NoScale = 1; // Also 1 when the texture is smaller than the scale, as it then has no // original size. int ReadScale(std::span file, int textureWidth, int textureHeight); + +// An upscaled image brought back to the original size, each pixel the average +// of a scale x scale block. For a texture the game draws on through its pixels, +// which are laid out for the original size. Width and height are multiples of +// the scale. +std::vector ShrinkToOriginal(std::span pixels, int width, int height, + int bytesPerPixel, int scale); } // namespace Render::Textures::Upscale diff --git a/src/source/Render/Textures/ZzzTexture.cpp b/src/source/Render/Textures/ZzzTexture.cpp index 3f05e53467..8c6cb938e6 100644 --- a/src/source/Render/Textures/ZzzTexture.cpp +++ b/src/source/Render/Textures/ZzzTexture.cpp @@ -241,6 +241,32 @@ bool OpenJpegBuffer(wchar_t* filename, float* BufferFloat) return true; } +namespace +{ +bool LoadDataBitmap(const wchar_t* szFullPath, GLuint uiTextureIndex, GLuint uiFilter, GLuint uiWrapMode, bool bCheck, + bool keepPixels) +{ + if (bCheck) + { + if (false == Bitmaps.LoadImage(uiTextureIndex, szFullPath, uiFilter, uiWrapMode, true, false, keepPixels)) + { + wchar_t szErrorMsg[256] = { + 0, + }; + mu_swprintf(szErrorMsg, L"LoadBitmap Failed: %ls", szFullPath); +#ifdef FOR_WORK + PopUpErrorCheckMsgBox(szErrorMsg); +#else // FOR_WORK + PopUpErrorCheckMsgBox(szErrorMsg, true); +#endif // FOR_WORK + return false; + } + return true; + } + return Bitmaps.LoadImage(uiTextureIndex, szFullPath, uiFilter, uiWrapMode, true, false, keepPixels); +} +} // namespace + #ifdef KJH_ADD_INGAMESHOP_UI_SYSTEM bool LoadBitmap(const wchar_t* szFileName, GLuint uiTextureIndex, GLuint uiFilter, GLuint uiWrapMode, bool bCheck, bool bFullPath) @@ -265,24 +291,17 @@ bool LoadBitmap(const wchar_t* szFileName, GLuint uiTextureIndex, GLuint uiFilte wcscpy(szFullPath, L"Data\\"); wcscat(szFullPath, szFileName); #endif // KJH_ADD_INGAMESHOP_UI_SYSTEM - if (bCheck) - { - if (false == Bitmaps.LoadImage(uiTextureIndex, szFullPath, uiFilter, uiWrapMode)) - { - wchar_t szErrorMsg[256] = { - 0, - }; - mu_swprintf(szErrorMsg, L"LoadBitmap Failed: %ls", szFullPath); -#ifdef FOR_WORK - PopUpErrorCheckMsgBox(szErrorMsg); -#else // FOR_WORK - PopUpErrorCheckMsgBox(szErrorMsg, true); -#endif // FOR_WORK - return false; - } - return true; - } - return Bitmaps.LoadImage(uiTextureIndex, szFullPath, uiFilter, uiWrapMode); + return LoadDataBitmap(szFullPath, uiTextureIndex, uiFilter, uiWrapMode, bCheck, false); +} + +bool LoadCanvasBitmap(const wchar_t* szFileName, GLuint uiTextureIndex, GLuint uiFilter, GLuint uiWrapMode) +{ + wchar_t szFullPath[256] = { + 0, + }; + wcscpy(szFullPath, L"Data\\"); + wcscat(szFullPath, szFileName); + return LoadDataBitmap(szFullPath, uiTextureIndex, uiFilter, uiWrapMode, true, true); } void DeleteBitmap(GLuint uiTextureIndex, bool bForce) { diff --git a/src/source/Render/Textures/ZzzTexture.h b/src/source/Render/Textures/ZzzTexture.h index 2bfae92a1d..df2fdd9cb3 100644 --- a/src/source/Render/Textures/ZzzTexture.h +++ b/src/source/Render/Textures/ZzzTexture.h @@ -18,6 +18,10 @@ bool LoadBitmap(const wchar_t* szFileName, GLuint uiTextureIndex, GLuint uiFilte #else // KJH_ADD_INGAMESHOP_UI_SYSTEM bool LoadBitmap(const wchar_t* szFileName, GLuint uiTextureIndex, GLuint uiFilter = GL_NEAREST, GLuint uiWrapMode = GL_CLAMP_TO_EDGE, bool bCheck = true); #endif // KJH_ADD_INGAMESHOP_UI_SYSTEM +// Loads a texture the game draws on through its pixels (BITMAP_t::Buffer), such as +// the guild mark. The pixels are kept at the original size, also for upscaled art. +bool LoadCanvasBitmap(const wchar_t* szFileName, GLuint uiTextureIndex, GLuint uiFilter = GL_NEAREST, + GLuint uiWrapMode = GL_CLAMP_TO_EDGE); void DeleteBitmap(GLuint uiTextureIndex, bool bForce = false); void PopUpErrorCheckMsgBox(const wchar_t* szErrorMsg, bool bForceDestroy = false); // Shows the error (UTF-8) with Copy text, Continue and Quit, or only Copy diff --git a/tests/render/test_upscale_marker.cpp b/tests/render/test_upscale_marker.cpp index 743b4f32bc..ebec919069 100644 --- a/tests/render/test_upscale_marker.cpp +++ b/tests/render/test_upscale_marker.cpp @@ -55,3 +55,16 @@ TEST_CASE("a texture smaller than the scale has no scale [render][upscale_marker CHECK(ReadScale(MarkedFile(4), 2) == Upscale::NoScale); CHECK(ReadScale(MarkedFile(4), 4) == 4); } + +TEST_CASE("an upscaled image shrinks to the average of each block [render][upscale_marker]") +{ + // 4x2 RGB at scale 2: two blocks of 2x2 pixels. + const std::vector pixels{ + 0, 10, 20, 4, 10, 20, /**/ 100, 0, 255, 100, 0, 255, // + 8, 10, 20, 12, 10, 20, /**/ 100, 0, 255, 104, 0, 255, // + }; + + const std::vector original = Upscale::ShrinkToOriginal(pixels, 4, 2, 3, 2); + + CHECK(original == std::vector{6, 10, 20, 101, 0, 255}); +} From e6baf4a8a7c19cb46b4cd6cd38f693a542b68900 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 16 Oct 2026 22:05:14 +0200 Subject: [PATCH 2/3] perf(render): release the CPU copy of every texture after upload Apart from the two canvases, nothing reads a texture's pixels after the upload, so the loader now frees them for every texture, originals included, not only for upscaled ones. Texture Memory in the debug console counted those CPU copies, so it went down when a pack was installed and would now read almost zero. It counts the base level of each uploaded texture instead. --- src/source/Render/Sprites/GlobalBitmap.cpp | 37 ++++++++++++---------- src/source/Render/Sprites/GlobalBitmap.h | 7 ++-- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/source/Render/Sprites/GlobalBitmap.cpp b/src/source/Render/Sprites/GlobalBitmap.cpp index dba5b405dd..d5ebc73b9f 100644 --- a/src/source/Render/Sprites/GlobalBitmap.cpp +++ b/src/source/Render/Sprites/GlobalBitmap.cpp @@ -184,17 +184,21 @@ SDL_GPUSampler* CreateSampler(SDL_GPUDevice* device, SDL_GPUFilter filter, SDL_G return SDL_CreateGPUSampler(device, &samplerInfo); } -// The CPU copy of an upscaled texture is up to 16 times the original and -// nothing reads it after the upload: only the canvases (LoadCanvasBitmap) are -// redrawn through Buffer, and those are never uploaded upscaled. Keeping the -// copies took the 32 bit client past 3.3 GB of address space. -// Returns the bytes released. -std::size_t ReleaseUploadedPixels(BITMAP_t& bitmap) +// Nothing reads the CPU copy of a texture after the upload except for the +// canvases (LoadCanvasBitmap), which are redrawn through Buffer. The copy of an +// upscaled texture is up to 16 times the original; keeping the copies took the +// 32 bit client past 3.3 GB of address space. +void ReleaseUploadedPixels(BITMAP_t& bitmap) { - const std::size_t released = bitmap.BufferStorage.size(); std::vector().swap(bitmap.BufferStorage); bitmap.Buffer = nullptr; - return released; +} + +// The size of the base level of an uploaded texture, which the loader counts as +// texture memory. +std::uint32_t UploadedBytes(int width, int height) +{ + return static_cast(width) * static_cast(height) * 4u; } // A canvas is drawn on through Buffer at the original size, so an upscaled file @@ -705,8 +709,7 @@ void CGlobalBitmap::UnloadImage(GLuint uiBitmapIndex, bool bForce) } } - const auto memoryUsed = static_cast(pBitmap->BufferStorage.size()); - m_dwUsedTextureMemory -= memoryUsed; + m_dwUsedTextureMemory -= pBitmap->UploadedBytes; m_mapBitmap.erase(mi); #ifdef _EDITOR @@ -983,7 +986,6 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file { ShrinkCanvas(*pNewBitmap, uploadWidth, uploadHeight, scale); } - m_dwUsedTextureMemory += static_cast(pNewBitmap->BufferStorage.size()); pNewBitmap->TextureNumber = uiBitmapIndex; std::vector rgbaData = PadRGBToRGBA(pNewBitmap->Buffer, uploadWidth, uploadHeight); @@ -993,9 +995,11 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file g_ErrorReport.Write(L"SDL texture upload failed %ls (%d)\r\n", filename.c_str(), uiBitmapIndex); return false; } - if (upscaled) + pNewBitmap->UploadedBytes = UploadedBytes(uploadWidth, uploadHeight); + m_dwUsedTextureMemory += pNewBitmap->UploadedBytes; + if (!keepPixels) { - m_dwUsedTextureMemory -= static_cast(ReleaseUploadedPixels(*pNewBitmap)); + ReleaseUploadedPixels(*pNewBitmap); } SDL_GPUTexture* rawTexture = pNewBitmap->sdlTexture; @@ -1061,7 +1065,6 @@ bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, { ShrinkCanvas(*pNewBitmap, uploadWidth, uploadHeight, scale); } - m_dwUsedTextureMemory += static_cast(pNewBitmap->BufferStorage.size()); pNewBitmap->TextureNumber = uiBitmapIndex; if (!UploadTextureSDLGpu(pNewBitmap.get(), pNewBitmap->Buffer, uploadWidth, uploadHeight, @@ -1070,9 +1073,11 @@ bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, g_ErrorReport.Write(L"SDL texture upload failed %ls (%d)\r\n", filename.c_str(), uiBitmapIndex); return false; } - if (upscaled) + pNewBitmap->UploadedBytes = UploadedBytes(uploadWidth, uploadHeight); + m_dwUsedTextureMemory += pNewBitmap->UploadedBytes; + if (!keepPixels) { - m_dwUsedTextureMemory -= static_cast(ReleaseUploadedPixels(*pNewBitmap)); + ReleaseUploadedPixels(*pNewBitmap); } SDL_GPUTexture* rawTexture = pNewBitmap->sdlTexture; diff --git a/src/source/Render/Sprites/GlobalBitmap.h b/src/source/Render/Sprites/GlobalBitmap.h index e805e9b7ab..d2c0996bf3 100644 --- a/src/source/Render/Sprites/GlobalBitmap.h +++ b/src/source/Render/Sprites/GlobalBitmap.h @@ -24,8 +24,7 @@ struct BITMAP_t GLuint BitmapIndex; wchar_t FileName[MAX_BITMAP_FILE_NAME]; // The size the game works with. For upscaled art (Render/Textures/UpscaleMarker.h) - // the uploaded texture is larger and Buffer is released after the upload. A canvas - // (LoadCanvasBitmap: BITMAP_GUILD, BITMAP_INTERFACE_MAP) keeps Buffer at this size. + // the uploaded texture is larger. float Width; float Height; char Components; @@ -33,6 +32,8 @@ struct BITMAP_t std::uint8_t Ref; bool IsSkin; bool IsHair; + // The pixels, released after the upload except for a canvas (LoadCanvasBitmap: + // BITMAP_GUILD, BITMAP_INTERFACE_MAP), which keeps them at the size above. BYTE* Buffer; std::vector BufferStorage; @@ -44,6 +45,8 @@ struct BITMAP_t #pragma pack(pop) SDL_GPUTexture* sdlTexture = nullptr; SDL_GPUSampler* sdlSampler = nullptr; + // The base level, counted as texture memory. + std::uint32_t UploadedBytes = 0; }; class CBitmapCache From 856b35a53e99ea4514a4e820d7833e6ee65d25c9 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 16 Oct 2026 22:06:38 +0200 Subject: [PATCH 3/3] fix(render): allow marked textures up to their scale times 1024 px MAX_WIDTH and MAX_HEIGHT (1024) were checked against the stored image before the marker was read, so a 4x file of any original over 256 px, or a 2x file of one over 512 px, did not load. The log blamed TurboJPEG or an invalid OZT format. The loader now reads the marker first and accepts a marked image up to its scale times the limit; the power-of-two padding uses the same bound. A rejected image logs its size and the limit. --- src/source/Render/Sprites/GlobalBitmap.cpp | 32 +++++++++++++++----- src/source/Render/Textures/UpscaleMarker.cpp | 10 ++++-- src/source/Render/Textures/UpscaleMarker.h | 8 ++++- tests/render/test_upscale_marker.cpp | 7 +++++ 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/source/Render/Sprites/GlobalBitmap.cpp b/src/source/Render/Sprites/GlobalBitmap.cpp index d5ebc73b9f..a7a0f723b4 100644 --- a/src/source/Render/Sprites/GlobalBitmap.cpp +++ b/src/source/Render/Sprites/GlobalBitmap.cpp @@ -918,6 +918,11 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file return false; } + // A marked file holds an image up to its scale times the largest original. + const int markedScale = Render::Textures::Upscale::ReadMarkedScale(jpegBuf); + const int maxWidth = MAX_WIDTH * markedScale; + const int maxHeight = MAX_HEIGHT * markedScale; + // Skip first 24 bytes (OZJ header) const unsigned char* jpegData = jpegBuf.data() + 24; const auto jpegSize = static_cast(jpegBuf.size() - 24); @@ -935,11 +940,17 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file auto headerResult = tjDecompressHeader3(tjHandle.get(), jpegData, jpegSize, &jpegWidth, &jpegHeight, &jpegSubsamp, &jpegColorspace); - if (headerResult != 0 || jpegWidth <= 0 || jpegHeight <= 0 || jpegWidth > MAX_WIDTH || jpegHeight > MAX_HEIGHT) + if (headerResult != 0 || jpegWidth <= 0 || jpegHeight <= 0) { ReportTurboError(L"tjDecompressHeader3"); return false; } + if (jpegWidth > maxWidth || jpegHeight > maxHeight) + { + g_ErrorReport.Write(L"OpenJpegTurbo: image too large %ls (%dx%d, at most %dx%d)\r\n", filename_ozj.c_str(), + jpegWidth, jpegHeight, maxWidth, maxHeight); + return false; + } std::vector decompressedBuffer(static_cast(jpegWidth) * static_cast(jpegHeight) * 3u); @@ -951,8 +962,8 @@ bool CGlobalBitmap::OpenJpegTurbo(GLuint uiBitmapIndex, const std::wstring& file return false; } - const int textureWidth = padToPowerOfTwo ? NextPowerOfTwo(jpegWidth, MAX_WIDTH) : jpegWidth; - const int textureHeight = padToPowerOfTwo ? NextPowerOfTwo(jpegHeight, MAX_HEIGHT) : jpegHeight; + const int textureWidth = padToPowerOfTwo ? NextPowerOfTwo(jpegWidth, maxWidth) : jpegWidth; + const int textureHeight = padToPowerOfTwo ? NextPowerOfTwo(jpegHeight, maxHeight) : jpegHeight; const int scale = Render::Textures::Upscale::ReadScale(jpegBuf, textureWidth, textureHeight); const bool upscaled = scale > Render::Textures::Upscale::NoScale && !keepPixels; @@ -1029,8 +1040,13 @@ bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, std::vector pakBuffer((std::istreambuf_iterator(input)), std::istreambuf_iterator()); input.close(); + // A marked file holds an image up to its scale times the largest original. + const int markedScale = Render::Textures::Upscale::ReadMarkedScale(pakBuffer); + const int maxWidth = MAX_WIDTH * markedScale; + const int maxHeight = MAX_HEIGHT * markedScale; + // A file cut off in its header or its pixels is too small. - const Ozt::Header header = Ozt::ReadHeader(pakBuffer, MAX_WIDTH, MAX_HEIGHT); + const Ozt::Header header = Ozt::ReadHeader(pakBuffer, maxWidth, maxHeight); if (header.status == Ozt::Status::TooSmall) { g_ErrorReport.Write(L"OpenTga: file too small %ls (%zu bytes)\r\n", filename_ozt.c_str(), pakBuffer.size()); @@ -1038,13 +1054,13 @@ bool CGlobalBitmap::OpenTga(GLuint uiBitmapIndex, const std::wstring& filename, } if (header.status == Ozt::Status::InvalidFormat) { - g_ErrorReport.Write(L"OpenTga: invalid format %ls (bit=%d, %dx%d)\r\n", filename_ozt.c_str(), - header.bitsPerPixel, header.width, header.height); + g_ErrorReport.Write(L"OpenTga: invalid format %ls (bit=%d, %dx%d, at most %dx%d)\r\n", filename_ozt.c_str(), + header.bitsPerPixel, header.width, header.height, maxWidth, maxHeight); return false; } - const int Width = padToPowerOfTwo ? NextPowerOfTwo(header.width, MAX_WIDTH) : header.width; - const int Height = padToPowerOfTwo ? NextPowerOfTwo(header.height, MAX_HEIGHT) : header.height; + const int Width = padToPowerOfTwo ? NextPowerOfTwo(header.width, maxWidth) : header.width; + const int Height = padToPowerOfTwo ? NextPowerOfTwo(header.height, maxHeight) : header.height; const int scale = Render::Textures::Upscale::ReadScale(pakBuffer, Width, Height); const bool upscaled = scale > Render::Textures::Upscale::NoScale && !keepPixels; diff --git a/src/source/Render/Textures/UpscaleMarker.cpp b/src/source/Render/Textures/UpscaleMarker.cpp index 618d0203f3..8046b30d39 100644 --- a/src/source/Render/Textures/UpscaleMarker.cpp +++ b/src/source/Render/Textures/UpscaleMarker.cpp @@ -19,7 +19,7 @@ bool HasMarker(std::span file) } } // namespace -int ReadScale(std::span file, int textureWidth, int textureHeight) +int ReadMarkedScale(std::span file) { if (!HasMarker(file)) { @@ -27,7 +27,13 @@ int ReadScale(std::span file, int textureWidth, int textureH } const int scale = file[ScaleOffset]; - if (!IsSupportedScale(scale) || textureWidth < scale || textureHeight < scale) + return IsSupportedScale(scale) ? scale : NoScale; +} + +int ReadScale(std::span file, int textureWidth, int textureHeight) +{ + const int scale = ReadMarkedScale(file); + if (textureWidth < scale || textureHeight < scale) { return NoScale; } diff --git a/src/source/Render/Textures/UpscaleMarker.h b/src/source/Render/Textures/UpscaleMarker.h index 85255c6524..596a679ffe 100644 --- a/src/source/Render/Textures/UpscaleMarker.h +++ b/src/source/Render/Textures/UpscaleMarker.h @@ -10,12 +10,18 @@ // in the first 4 bytes, which no loader reads (an original file repeats the // start of its JPEG or TGA data there). The game uploads the whole image but // keeps working with the original size, so terrain UVs, sprite sizes and UI -// coordinates stay valid. +// coordinates stay valid. The loader accepts a marked image up to the scale +// times the largest original (CGlobalBitmap's MAX_WIDTH x MAX_HEIGHT, 1024 px). namespace Render::Textures::Upscale { inline constexpr std::size_t MarkerSize = 4; inline constexpr int NoScale = 1; +// The scale in the marker of a file: 2 or 4 for a marked file, 1 for any other. +// Needs no image size, so the loader can widen its size limit before it reads +// the image. +int ReadMarkedScale(std::span file); + // The scale of the image in a file: 2 or 4 for a marked file, 1 for any other. // Also 1 when the texture is smaller than the scale, as it then has no // original size. diff --git a/tests/render/test_upscale_marker.cpp b/tests/render/test_upscale_marker.cpp index ebec919069..e9a74f9bc2 100644 --- a/tests/render/test_upscale_marker.cpp +++ b/tests/render/test_upscale_marker.cpp @@ -68,3 +68,10 @@ TEST_CASE("an upscaled image shrinks to the average of each block [render][upsca CHECK(original == std::vector{6, 10, 20, 101, 0, 255}); } + +TEST_CASE("the marked scale needs no image size [render][upscale_marker]") +{ + CHECK(Upscale::ReadMarkedScale(MarkedFile(4)) == 4); + CHECK(Upscale::ReadMarkedScale(MarkedFile(3)) == Upscale::NoScale); + CHECK(Upscale::ReadMarkedScale(std::vector{0xFF, 0xD8, 0xFF, 0xE0}) == Upscale::NoScale); +}