From 9a843f3420b7834a20a25afa0e4fdc24454e0307 Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 19 May 2026 11:17:19 -0500 Subject: [PATCH 1/2] support dds images in qtfred image picker --- code/ddsutils/ddsutils.cpp | 94 ++++++++++++++++++++++++++++ code/ddsutils/ddsutils.h | 9 +++ qtfred/src/ui/util/ImageRenderer.cpp | 43 +++++++++++-- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/code/ddsutils/ddsutils.cpp b/code/ddsutils/ddsutils.cpp index 3f42e532dcc..4a85a1216e6 100644 --- a/code/ddsutils/ddsutils.cpp +++ b/code/ddsutils/ddsutils.cpp @@ -549,6 +549,100 @@ int dds_read_bitmap(const char *filename, ubyte *data, ubyte *bpp, int cf_type) return DDS_ERROR_NONE; } +int dds_decompress_top_mip_bgra(const char *filename, int cf_type, + int *out_width, int *out_height, + SCP_vector &out_pixels) +{ + Assert(filename != nullptr); + + // normalize to a .dds extension, same as dds_read_bitmap + char real_name[MAX_FILENAME_LEN]; + strcpy_s(real_name, filename); + char *p = strchr(real_name, '.'); + if (p) { *p = 0; } + strcat_s(real_name, ".dds"); + + CFILE *cfp = cfopen(real_name, "rb", cf_type); + if (cfp == nullptr) + return DDS_ERROR_INVALID_FILENAME; + + DDS_HEADER dds_header; + DDS_HEADER_DXT10 dx10_header; + int retval = _dds_read_header(cfp, dds_header, &dx10_header); + if (retval != DDS_ERROR_NONE) { + cfclose(cfp); + return retval; + } + + // only 2D FOURCC-compressed images are supported here + if (!(dds_header.ddspf.dwFlags & DDPF_FOURCC) || + (dds_header.dwCaps2 & DDSCAPS2_CUBEMAP)) { + cfclose(cfp); + return DDS_ERROR_UNSUPPORTED; + } + + void (*decode)(const void *, void *, int) = nullptr; + int block_size = 0; + switch (dds_header.ddspf.dwFourCC) { + case FOURCC_DXT1: decode = bcdec_bc1; block_size = BCDEC_BC1_BLOCK_SIZE; break; + case FOURCC_DXT3: decode = bcdec_bc2; block_size = BCDEC_BC2_BLOCK_SIZE; break; + case FOURCC_DXT5: decode = bcdec_bc3; block_size = BCDEC_BC3_BLOCK_SIZE; break; + case FOURCC_DX10: + if (!valid_dx10_format(dx10_header)) { + cfclose(cfp); + return DDS_ERROR_UNSUPPORTED; + } + decode = bcdec_bc7; + block_size = BCDEC_BC7_BLOCK_SIZE; + break; + default: + cfclose(cfp); + return DDS_ERROR_UNSUPPORTED; + } + + const int w = static_cast(dds_header.dwWidth); + const int h = static_cast(dds_header.dwHeight); + if (w <= 0 || h <= 0 || (w % 4) != 0 || (h % 4) != 0) { + cfclose(cfp); + return DDS_ERROR_INVALID_FORMAT; + } + + // _dds_read_header leaves the file positioned right after the header + // (including the DX10 sub-header if present), so the next read is the + // top mip's pixel data. + const int blocks_w = w / 4; + const int blocks_h = h / 4; + const size_t compressed_size = static_cast(blocks_w) * blocks_h * block_size; + + SCP_vector compressed(compressed_size); + const int got = cfread(compressed.data(), 1, static_cast(compressed_size), cfp); + cfclose(cfp); + if (got != static_cast(compressed_size)) + return DDS_ERROR_INVALID_FORMAT; + + out_pixels.assign(static_cast(w) * h * 4, 0); + ubyte *const dst = out_pixels.data(); + const ubyte *src = compressed.data(); + const int dst_stride = w * 4; + + for (int by = 0; by < blocks_h; ++by) { + for (int bx = 0; bx < blocks_w; ++bx) { + ubyte *blk_dst = dst + (by * 4) * dst_stride + (bx * 4) * 4; + decode(src, blk_dst, dst_stride); + src += block_size; + } + } + + // bcdec outputs RGBA byte-order; swap to BGRA + for (size_t x = 0; x < out_pixels.size(); x += 4) { + std::swap(out_pixels[x], out_pixels[x + 2]); + } + + if (out_width) *out_width = w; + if (out_height) *out_height = h; + return DDS_ERROR_NONE; +} + // save some image data as a DDS image // NOTE: we only support, uncompressed, 24-bit RGB and 32-bit RGBA images here!! void dds_save_image(int width, int height, int bpp, int num_mipmaps, ubyte *data, int cubemap, const char *filename) diff --git a/code/ddsutils/ddsutils.h b/code/ddsutils/ddsutils.h index 0d6a6ca6392..03c3694ce87 100644 --- a/code/ddsutils/ddsutils.h +++ b/code/ddsutils/ddsutils.h @@ -285,6 +285,15 @@ int dds_read_header(const char *filename, CFILE *img_cfp = NULL, int *width = 0, //size of the data it stored in size int dds_read_bitmap(const char *filename, ubyte *data, ubyte *bpp = NULL, int cf_type = CF_TYPE_ANY); +// Decompress just the top mip of a 2D FOURCC-compressed DDS (DXT1/3/5, BC7) +// to 32-bpp BGRA, regardless of what the renderer's compression support is. +// Intended for tool/preview code that needs raw pixels and doesn't care +// about mipmaps or cubemap faces. On success, out_pixels is sized to +// width*height*4 in BGRA byte order. +int dds_decompress_top_mip_bgra(const char *filename, int cf_type, + int *out_width, int *out_height, + SCP_vector &out_pixels); + // writes a DDS file using given data void dds_save_image(int width, int height, int bpp, int num_mipmaps, ubyte *data = NULL, int cubemap = 0, const char *filename = NULL); diff --git a/qtfred/src/ui/util/ImageRenderer.cpp b/qtfred/src/ui/util/ImageRenderer.cpp index 772fc245e84..a253a821d23 100644 --- a/qtfred/src/ui/util/ImageRenderer.cpp +++ b/qtfred/src/ui/util/ImageRenderer.cpp @@ -1,6 +1,7 @@ #include "ImageRenderer.h" #include // bm_load, bm_get_info, bm_lock, bm_unlock +#include #include @@ -12,6 +13,24 @@ static void setError(QString* outError, const QString& text) *outError = text; } +// bm_lock_dds keeps compressed data as-is when the renderer reports s3tc/BPTC +// support, which would crash the regular 32-bpp QImage path. For the picker +// preview, ask ddsutils to decompress the top mip directly. +static bool decompressDdsToQImage(const char* bm_filename, QImage& outImage, QString* outError) +{ + int w = 0, h = 0; + SCP_vector pixels; + const int err = dds_decompress_top_mip_bgra(bm_filename, CF_TYPE_ANY, &w, &h, pixels); + if (err != DDS_ERROR_NONE) { + setError(outError, QStringLiteral("DDS decompress failed (%1).").arg(err)); + return false; + } + + QImage tmp(pixels.data(), w, h, w * 4, QImage::Format_ARGB32); + outImage = tmp.copy(); // detach before `pixels` goes out of scope + return !outImage.isNull(); +} + bool loadHandleToQImage(int bmHandle, QImage& outImage, QString* outError) { outImage = QImage(); // clear @@ -21,6 +40,14 @@ bool loadHandleToQImage(int bmHandle, QImage& outImage, QString* outError) return false; } + if (bm_is_compressed(bmHandle)) { + const char* fname = bm_get_filename(bmHandle); + if (fname && *fname) + return decompressDdsToQImage(fname, outImage, outError); + setError(outError, QStringLiteral("Compressed DDS with no filename; cannot preview.")); + return false; + } + int w = 0, h = 0; if (bm_get_info(bmHandle, &w, &h) < 0 || w <= 0 || h <= 0) { setError(outError, QStringLiteral("Bitmap has invalid info.")); @@ -35,8 +62,15 @@ bool loadHandleToQImage(int bmHandle, QImage& outImage, QString* outError) return false; } - // rowsize is stored in pixels; multiply by bytes-per-pixel for the Qt stride. - const int bytesPerLine = bmp->w * (bmp->bpp >> 3); + // bm_lock_dds also doesn't honor the requested bpp for uncompressed DDS + // (e.g. 24-bpp RGB files), which would have us read past the buffer. + if (bmp->bpp != 32) { + bm_unlock(bmHandle); + setError(outError, QStringLiteral("Unsupported bitmap bpp (%1) for QImage preview.").arg(bmp->bpp)); + return false; + } + + const int bytesPerLine = bmp->w * 4; QImage tmp(reinterpret_cast(bmp->data), bmp->w, bmp->h, bytesPerLine, QImage::Format_ARGB32); outImage = tmp.copy(); // detach from bmpman memory before unlock bm_unlock(bmHandle); @@ -67,8 +101,9 @@ bool loadImageToQImage(const std::string& filename, QImage& outImage, QString* o const bool ok = loadHandleToQImage(handle, outImage, outError); - - // bm_unload(handle); TODO test unloading + // bm_unload is load_count aware, so if another + // part of qtfred is sharing the handle it stays alive for them. + bm_unload(handle); return ok; } From 474f62b21e77be480721431737703919edf7a7ee Mon Sep 17 00:00:00 2001 From: Mike Nelson Date: Tue, 26 May 2026 16:10:33 -0500 Subject: [PATCH 2/2] address feedback --- code/ddsutils/ddsutils.cpp | 40 +++++++++++++++++++--------- qtfred/src/ui/util/ImageRenderer.cpp | 28 +++++++++++++------ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/code/ddsutils/ddsutils.cpp b/code/ddsutils/ddsutils.cpp index 4a85a1216e6..a8ce3fb0513 100644 --- a/code/ddsutils/ddsutils.cpp +++ b/code/ddsutils/ddsutils.cpp @@ -576,7 +576,9 @@ int dds_decompress_top_mip_bgra(const char *filename, int cf_type, // only 2D FOURCC-compressed images are supported here if (!(dds_header.ddspf.dwFlags & DDPF_FOURCC) || - (dds_header.dwCaps2 & DDSCAPS2_CUBEMAP)) { + (dds_header.dwCaps2 & (DDSCAPS2_CUBEMAP | DDSCAPS2_VOLUME)) || + (dds_header.dwFlags & DDSD_DEPTH) || + (dds_header.dwDepth > 1)) { cfclose(cfp); return DDS_ERROR_UNSUPPORTED; } @@ -588,7 +590,9 @@ int dds_decompress_top_mip_bgra(const char *filename, int cf_type, case FOURCC_DXT3: decode = bcdec_bc2; block_size = BCDEC_BC2_BLOCK_SIZE; break; case FOURCC_DXT5: decode = bcdec_bc3; block_size = BCDEC_BC3_BLOCK_SIZE; break; case FOURCC_DX10: - if (!valid_dx10_format(dx10_header)) { + if (!valid_dx10_format(dx10_header) || + dx10_header.resourceDimension != D3D10_RESOURCE_DIMENSION::D3D10_RESOURCE_DIMENSION_TEXTURE2D || + dx10_header.arraySize > 1) { cfclose(cfp); return DDS_ERROR_UNSUPPORTED; } @@ -602,37 +606,49 @@ int dds_decompress_top_mip_bgra(const char *filename, int cf_type, const int w = static_cast(dds_header.dwWidth); const int h = static_cast(dds_header.dwHeight); - if (w <= 0 || h <= 0 || (w % 4) != 0 || (h % 4) != 0) { + if (w <= 0 || h <= 0) { cfclose(cfp); return DDS_ERROR_INVALID_FORMAT; } + // BCn data is stored as ceil(w/4) * ceil(h/4) 4x4 blocks; dimensions + // don't have to be multiples of 4. + const int blocks_w = (w + 3) / 4; + const int blocks_h = (h + 3) / 4; + const int padded_w = blocks_w * 4; + const int padded_h = blocks_h * 4; + const size_t compressed_size = static_cast(blocks_w) * blocks_h * block_size; + // _dds_read_header leaves the file positioned right after the header // (including the DX10 sub-header if present), so the next read is the // top mip's pixel data. - const int blocks_w = w / 4; - const int blocks_h = h / 4; - const size_t compressed_size = static_cast(blocks_w) * blocks_h * block_size; - SCP_vector compressed(compressed_size); const int got = cfread(compressed.data(), 1, static_cast(compressed_size), cfp); cfclose(cfp); if (got != static_cast(compressed_size)) return DDS_ERROR_INVALID_FORMAT; - out_pixels.assign(static_cast(w) * h * 4, 0); - ubyte *const dst = out_pixels.data(); + // Decode into a padded buffer so edge blocks have room, then crop to w*h. + SCP_vector decoded(static_cast(padded_w) * padded_h * 4); + const int dec_stride = padded_w * 4; const ubyte *src = compressed.data(); - const int dst_stride = w * 4; for (int by = 0; by < blocks_h; ++by) { for (int bx = 0; bx < blocks_w; ++bx) { - ubyte *blk_dst = dst + (by * 4) * dst_stride + (bx * 4) * 4; - decode(src, blk_dst, dst_stride); + ubyte *blk_dst = decoded.data() + (by * 4) * dec_stride + (bx * 4) * 4; + decode(src, blk_dst, dec_stride); src += block_size; } } + out_pixels.assign(static_cast(w) * h * 4, 0); + const int dst_stride = w * 4; + for (int y = 0; y < h; ++y) { + std::memcpy(out_pixels.data() + static_cast(y) * dst_stride, + decoded.data() + static_cast(y) * dec_stride, + static_cast(dst_stride)); + } + // bcdec outputs RGBA byte-order; swap to BGRA for (size_t x = 0; x < out_pixels.size(); x += 4) { std::swap(out_pixels[x], out_pixels[x + 2]); diff --git a/qtfred/src/ui/util/ImageRenderer.cpp b/qtfred/src/ui/util/ImageRenderer.cpp index a253a821d23..c64483a7f29 100644 --- a/qtfred/src/ui/util/ImageRenderer.cpp +++ b/qtfred/src/ui/util/ImageRenderer.cpp @@ -57,22 +57,34 @@ bool loadHandleToQImage(int bmHandle, QImage& outImage, QString* outError) // All FSO animation types (ANI, APNG, EFF) produce BGRA byte-order data // at 32 bpp, which matches QImage::Format_ARGB32 on little-endian. auto* bmp = bm_lock(bmHandle, 32, BMP_TEX_XPARENT); - if (bmp == nullptr || bmp->data == 0) { + if (bmp == nullptr) { + setError(outError, QStringLiteral("bm_lock failed.")); + return false; + } + if (bmp->data == 0) { + // bm_lock incremented the refcount before populating data; release it. + bm_unlock(bmHandle); setError(outError, QStringLiteral("bm_lock failed.")); return false; } - // bm_lock_dds also doesn't honor the requested bpp for uncompressed DDS - // (e.g. 24-bpp RGB files), which would have us read past the buffer. - if (bmp->bpp != 32) { + // bm_lock ignores the requested bpp for JPG (always 24, BGR) and for + // uncompressed DDS (whatever the file uses). Handle the two common + // cases (32-bpp BGRA and 24-bpp BGR) and reject anything else. + if (bmp->bpp == 32) { + const int bytesPerLine = bmp->w * 4; + QImage tmp(reinterpret_cast(bmp->data), bmp->w, bmp->h, bytesPerLine, QImage::Format_ARGB32); + outImage = tmp.copy(); // detach from bmpman memory before unlock + } else if (bmp->bpp == 24) { + const int bytesPerLine = bmp->w * 3; + QImage tmp(reinterpret_cast(bmp->data), bmp->w, bmp->h, bytesPerLine, QImage::Format_RGB888); + // FSO stores 24-bpp as BGR; swap to RGB and promote to ARGB32 (also detaches). + outImage = tmp.rgbSwapped().convertToFormat(QImage::Format_ARGB32); + } else { bm_unlock(bmHandle); setError(outError, QStringLiteral("Unsupported bitmap bpp (%1) for QImage preview.").arg(bmp->bpp)); return false; } - - const int bytesPerLine = bmp->w * 4; - QImage tmp(reinterpret_cast(bmp->data), bmp->w, bmp->h, bytesPerLine, QImage::Format_ARGB32); - outImage = tmp.copy(); // detach from bmpman memory before unlock bm_unlock(bmHandle); if (outImage.isNull()) {