From 1d72766330db8129397dc1db81ec7c5a63420006 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 18:13:25 -0500 Subject: [PATCH] feat(ugc): icons as DXT5 DDS like the client's own The client's 128x128 UI icons are almost all DXT5 without mipmaps, with header flags caps|height|width|pixel format|linear size (0x81007) and caps 0x1000. The UGC server's icon.dds is now written the same way instead of as uncompressed 32-bit BGRA with a pitch. The BC3 encoder fits each block's colour endpoints along the principal axis of its visible pixels (transparent ones are never seen), refines them by least squares, and stores alpha with the block's own range. Tests check the header against the client's format and decode the result back. Co-Authored-By: Claude Opus 5.5 --- dUgcServer/UgcFormats.cpp | 175 +++++++++++++++++++++++++++++++---- dUgcServer/UgcFormats.h | 9 +- docs/UgcServer.md | 3 +- tests/dUgcTests/UgcTests.cpp | 86 ++++++++++++++++- 4 files changed, 252 insertions(+), 21 deletions(-) diff --git a/dUgcServer/UgcFormats.cpp b/dUgcServer/UgcFormats.cpp index 00a41bfbb..bc62077e7 100644 --- a/dUgcServer/UgcFormats.cpp +++ b/dUgcServer/UgcFormats.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -335,29 +336,169 @@ namespace UgcFormats { return out; } + std::array EncodeDxt5Block(const std::array& rgba) { + std::array out{}; + + // Alpha: the block's lowest and highest, eight levels between (a0 > a1), 3 bits per pixel + uint8_t aMin = 255, aMax = 0; + for (int i = 0; i < 16; i++) { + aMin = std::min(aMin, rgba[i * 4 + 3]); + aMax = std::max(aMax, rgba[i * 4 + 3]); + } + out[0] = aMax; + out[1] = aMin; + if (aMax != aMin) { + std::array levels{ aMax, aMin }; + for (int i = 1; i < 7; i++) levels[i + 1] = ((7 - i) * aMax + i * aMin) / 7; + uint64_t bits = 0; + for (int i = 0; i < 16; i++) { + int best = 0, bestError = std::numeric_limits::max(); + for (int l = 0; l < 8; l++) { + const int error = std::abs(levels[l] - rgba[i * 4 + 3]); + if (error < bestError) { bestError = error; best = l; } + } + bits |= static_cast(best) << (3 * i); + } + for (int i = 0; i < 6; i++) out[2 + i] = static_cast(bits >> (8 * i)); + } + + // Color: fit along the principal axis of the pixels that show (transparent ones don't count, their color is + // never seen), then refine the two endpoints by least squares once + std::array, 16> px{}; + std::array used{}; + int count = 0; + for (int i = 0; i < 16; i++) { + for (int c = 0; c < 3; c++) px[i][c] = rgba[i * 4 + c]; + used[i] = rgba[i * 4 + 3] > 0; + count += used[i]; + } + if (count == 0) used.fill(true), count = 16; + std::array mean{}; + for (int i = 0; i < 16; i++) if (used[i]) for (int c = 0; c < 3; c++) mean[c] += px[i][c] / count; + float cov[6]{}; + for (int i = 0; i < 16; i++) { + if (!used[i]) continue; + const float r = px[i][0] - mean[0], g = px[i][1] - mean[1], b = px[i][2] - mean[2]; + cov[0] += r * r; cov[1] += r * g; cov[2] += r * b; cov[3] += g * g; cov[4] += g * b; cov[5] += b * b; + } + std::array axis{ 1.0f, 1.0f, 1.0f }; + for (int iteration = 0; iteration < 8; iteration++) { + const std::array next{ cov[0] * axis[0] + cov[1] * axis[1] + cov[2] * axis[2], + cov[1] * axis[0] + cov[3] * axis[1] + cov[4] * axis[2], cov[2] * axis[0] + cov[4] * axis[1] + cov[5] * axis[2] }; + const float length = std::max({ std::abs(next[0]), std::abs(next[1]), std::abs(next[2]) }); + if (length < 1e-6f) break; + for (int c = 0; c < 3; c++) axis[c] = next[c] / length; + } + float lo = std::numeric_limits::max(), hi = std::numeric_limits::lowest(); + for (int i = 0; i < 16; i++) { + if (!used[i]) continue; + const float t = (px[i][0] - mean[0]) * axis[0] + (px[i][1] - mean[1]) * axis[1] + (px[i][2] - mean[2]) * axis[2]; + lo = std::min(lo, t); + hi = std::max(hi, t); + } + const float axisLength2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]; + std::array e0{}, e1{}; + for (int c = 0; c < 3; c++) { + e0[c] = mean[c] + axis[c] * hi / std::max(axisLength2, 1e-6f); + e1[c] = mean[c] + axis[c] * lo / std::max(axisLength2, 1e-6f); + } + const auto to565 = [](const std::array& c) { + const auto q = [](float v, int max) { return static_cast(std::clamp(static_cast(v / 255.0f * max + 0.5f), 0, max)); }; + return static_cast((q(c[0], 31) << 11) | (q(c[1], 63) << 5) | q(c[2], 31)); + }; + const auto from565 = [](uint16_t v) { + return std::array{ ((v >> 11) & 31) * 255.0f / 31.0f, ((v >> 5) & 63) * 255.0f / 63.0f, (v & 31) * 255.0f / 31.0f }; + }; + const auto indicesFor = [&](uint16_t c0, uint16_t c1, uint32_t& bits) { + const auto a = from565(c0), b = from565(c1); + std::array, 4> palette{ a, b }; + for (int c = 0; c < 3; c++) { + palette[2][c] = (2 * a[c] + b[c]) / 3.0f; + palette[3][c] = (a[c] + 2 * b[c]) / 3.0f; + } + float total = 0.0f; + bits = 0; + for (int i = 0; i < 16; i++) { + int best = 0; + float bestError = std::numeric_limits::max(); + for (int p = 0; p < 4; p++) { + float error = 0.0f; + for (int c = 0; c < 3; c++) error += (palette[p][c] - px[i][c]) * (palette[p][c] - px[i][c]); + if (error < bestError) { bestError = error; best = p; } + } + if (used[i]) total += bestError; + bits |= static_cast(best) << (2 * i); + } + return total; + }; + uint16_t c0 = to565(e0), c1 = to565(e1); + uint32_t bits = 0; + float error = indicesFor(c0 < c1 ? c1 : c0, c0 < c1 ? c0 : c1, bits); + if (c0 < c1) std::swap(c0, c1); + // Least squares on the chosen indices (weights 1, 0, 2/3, 1/3 for the first endpoint) + { + static constexpr float W[4] = { 1.0f, 0.0f, 2.0f / 3.0f, 1.0f / 3.0f }; + float aa = 0, bb = 0, ab = 0; + std::array ax{}, bx{}; + for (int i = 0; i < 16; i++) { + if (!used[i]) continue; + const float w = W[(bits >> (2 * i)) & 3], v = 1.0f - w; + aa += w * w; bb += v * v; ab += w * v; + for (int c = 0; c < 3; c++) { ax[c] += w * px[i][c]; bx[c] += v * px[i][c]; } + } + const float det = aa * bb - ab * ab; + if (std::abs(det) > 1e-6f) { + std::array r0{}, r1{}; + for (int c = 0; c < 3; c++) { + r0[c] = (ax[c] * bb - bx[c] * ab) / det; + r1[c] = (bx[c] * aa - ax[c] * ab) / det; + } + uint16_t n0 = to565(r0), n1 = to565(r1); + if (n0 < n1) std::swap(n0, n1); + uint32_t nbits = 0; + const float nerror = indicesFor(n0, n1, nbits); + if (nerror < error) { c0 = n0; c1 = n1; bits = nbits; error = nerror; } + } + } + if (c0 == c1) bits = 0; // one color: four-color mode needs c0 > c1, and every index then means c0 + out[8] = static_cast(c0); + out[9] = static_cast(c0 >> 8); + out[10] = static_cast(c1); + out[11] = static_cast(c1 >> 8); + for (int i = 0; i < 4; i++) out[12 + i] = static_cast(bits >> (8 * i)); + return out; + } + std::string EncodeDds(const UgcRender::Image& image) { + const auto width = static_cast(image.width), height = static_cast(image.height); + const uint32_t blocksX = std::max(1u, (width + 3) / 4), blocksY = std::max(1u, (height + 3) / 4); std::array header{}; header[0] = 124; - header[1] = 0x1 | 0x2 | 0x4 | 0x8 | 0x1000; // caps, height, width, pitch, pixel format - header[2] = static_cast(image.height); - header[3] = static_cast(image.width); - header[4] = static_cast(image.width) * 4; // pitch - header[18] = 32; // pixel format size - header[19] = 0x41; // RGB with alpha - header[21] = 32; - header[22] = 0x00FF0000; - header[23] = 0x0000FF00; - header[24] = 0x000000FF; - header[25] = 0xFF000000; + header[1] = 0x1 | 0x2 | 0x4 | 0x1000 | 0x80000; // caps, height, width, pixel format, linear size + header[2] = height; + header[3] = width; + header[4] = blocksX * blocksY * 16; // linear size + header[18] = 32; // pixel format size + header[19] = 0x4; // four CC + header[20] = 0x35545844; // "DXT5" header[26] = 0x1000; // texture std::string out = "DDS "; out.append(reinterpret_cast(header.data()), header.size() * 4); - out.reserve(out.size() + image.rgba.size()); - for (size_t i = 0; i + 3 < image.rgba.size(); i += 4) { - out += static_cast(image.rgba[i + 2]); - out += static_cast(image.rgba[i + 1]); - out += static_cast(image.rgba[i]); - out += static_cast(image.rgba[i + 3]); + out.reserve(out.size() + header[4]); + for (uint32_t by = 0; by < blocksY; by++) { + for (uint32_t bx = 0; bx < blocksX; bx++) { + std::array block{}; + for (uint32_t y = 0; y < 4; y++) { + for (uint32_t x = 0; x < 4; x++) { + // Edge blocks of sizes that aren't a multiple of 4 repeat the last row and column + const uint32_t sx = std::min(bx * 4 + x, width - 1), sy = std::min(by * 4 + y, height - 1); + const size_t from = (static_cast(sy) * width + sx) * 4; + if (from + 3 < image.rgba.size()) std::memcpy(&block[(y * 4 + x) * 4], &image.rgba[from], 4); + } + } + const auto encoded = EncodeDxt5Block(block); + out.append(reinterpret_cast(encoded.data()), encoded.size()); + } } return out; } diff --git a/dUgcServer/UgcFormats.h b/dUgcServer/UgcFormats.h index 26c77f187..f8aabbb82 100644 --- a/dUgcServer/UgcFormats.h +++ b/dUgcServer/UgcFormats.h @@ -1,5 +1,7 @@ #pragma once +#include + #include #include #include @@ -50,9 +52,14 @@ namespace UgcFormats { // PNG (8-bit RGBA) std::string EncodePng(const UgcRender::Image& image); - // DDS, uncompressed 32-bit BGRA without mipmaps + // DDS as the client's own icons are: DXT5 (BC3), without mipmaps, header flags caps|height|width|pixel format|linear + // size (0x81007), caps texture (0x1000) std::string EncodeDds(const UgcRender::Image& image); + // One 4x4 block (RGBA, 64 bytes, row by row) as DXT5's 16 bytes: alpha endpoints and 3-bit indices, then the + // color's two RGB565 endpoints and 2-bit indices + std::array EncodeDxt5Block(const std::array& rgba); + // Lowercase hex MD5 of `data` std::string Md5Hex(std::string_view data); diff --git a/docs/UgcServer.md b/docs/UgcServer.md index fec1ba86a..ffaa227e2 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -136,7 +136,8 @@ defaults. The table below goes through it step by step. light, a fill from the camera, the sun with soft shadows, a highlight, exposure and contrast) is set so the icons are as bright as the game's own model icons (`res/textures/ui/inventory/models`: mean luminance 120 of 255 over 150 of them; ours 118 on a set of player models). Drawn by a software rasterizer (no GPU, no display), 4x4 supersampled, - on a transparent background: `icon.png` for the dashboard and a 32-bit `icon.dds` for the client. + on a transparent background: `icon.png` for the dashboard and an `icon.dds` for the client, written like + the client's own 128x128 icons (DXT5, no mipmaps, header flags 0x81007 with the linear size, caps 0x1000). The light settings are `icon_world_light`, `icon_sun_light`, `icon_fill`, `icon_specular`, `icon_shininess`, `icon_exposure`, `icon_contrast`, `icon_shadow_strength` and `icon_ao_strength` (new names: the older diff --git a/tests/dUgcTests/UgcTests.cpp b/tests/dUgcTests/UgcTests.cpp index 9c4f88088..d6c1ae0e3 100644 --- a/tests/dUgcTests/UgcTests.cpp +++ b/tests/dUgcTests/UgcTests.cpp @@ -266,9 +266,8 @@ TEST(UgcFormats, ImagesAndChecksums) { const auto png = UgcFormats::EncodePng(image); EXPECT_TRUE(png.starts_with("\x89PNG\r\n\x1a\n")); const auto dds = UgcFormats::EncodeDds(image); - ASSERT_EQ(dds.size(), 128u + 16u); + ASSERT_EQ(dds.size(), 128u + 16u); // one DXT5 block EXPECT_TRUE(dds.starts_with("DDS ")); - EXPECT_EQ(dds[128 + 2], 10); // stored BGRA EXPECT_EQ(UgcFormats::Md5Hex("abc"), "900150983cd24fb0d6963f7d28e17f72"); EXPECT_NE(UgcFormats::ChecksumXml("abc").find("900150983cd24fb0d6963f7d28e17f723"), std::string::npos); std::string md5; @@ -280,6 +279,89 @@ TEST(UgcFormats, ImagesAndChecksums) { EXPECT_FALSE(UgcFormats::ReadChecksumXml("900150983cd24fb0d6963f7d28e17f72x", md5, size)); } +namespace { + uint32_t U32(const std::string& data, size_t at) { + uint32_t v{}; + std::memcpy(&v, data.data() + at, 4); + return v; + } + + // A DXT5 block back to RGBA (the reference decoding, for the tests) + std::array DecodeDxt5Block(const uint8_t* b) { + std::array out{}; + std::array alpha{ b[0], b[1] }; + for (int i = 2; i < 8; i++) alpha[i] = b[0] > b[1] ? ((8 - i) * b[0] + (i - 1) * b[1]) / 7 : (i < 6 ? ((6 - i) * b[0] + (i - 1) * b[1]) / 5 : (i == 6 ? 0 : 255)); + uint64_t abits = 0; + for (int i = 0; i < 6; i++) abits |= static_cast(b[2 + i]) << (8 * i); + const uint16_t c0 = b[8] | (b[9] << 8), c1 = b[10] | (b[11] << 8); + const auto rgb = [](uint16_t v) { return std::array{ ((v >> 11) & 31) * 255 / 31, ((v >> 5) & 63) * 255 / 63, (v & 31) * 255 / 31 }; }; + const auto a = rgb(c0), z = rgb(c1); + std::array, 4> pal{ a, z }; + for (int c = 0; c < 3; c++) { + pal[2][c] = c0 > c1 ? (2 * a[c] + z[c]) / 3 : (a[c] + z[c]) / 2; + pal[3][c] = c0 > c1 ? (a[c] + 2 * z[c]) / 3 : 0; + } + const uint32_t bits = b[12] | (b[13] << 8) | (b[14] << 16) | (static_cast(b[15]) << 24); + for (int i = 0; i < 16; i++) { + for (int c = 0; c < 3; c++) out[i * 4 + c] = static_cast(pal[(bits >> (2 * i)) & 3][c]); + out[i * 4 + 3] = static_cast(alpha[(abits >> (3 * i)) & 7]); + } + return out; + } +} + +// Icons are written like the client's own 128x128 ones: DXT5, no mipmaps, flags 0x81007, the linear size, caps 0x1000 +TEST(UgcFormats, DdsIsDxt5LikeTheClientsIcons) { + UgcRender::Image image{ 128, 128, std::vector(128 * 128 * 4, 0) }; + for (int y = 0; y < 128; y++) { + for (int x = 0; x < 128; x++) { + auto* p = &image.rgba[(y * 128 + x) * 4]; + const bool inside = x >= 32 && x < 96 && y >= 32 && y < 96; + p[0] = static_cast(x * 2); + p[1] = static_cast(y * 2); + p[2] = 90; + p[3] = inside ? 255 : 0; + } + } + const auto dds = UgcFormats::EncodeDds(image); + ASSERT_EQ(dds.size(), 128u + 32u * 32u * 16u); + EXPECT_EQ(U32(dds, 4), 124u); + EXPECT_EQ(U32(dds, 8), 0x81007u); + EXPECT_EQ(U32(dds, 12), 128u); + EXPECT_EQ(U32(dds, 16), 128u); + EXPECT_EQ(U32(dds, 20), 16384u); // linear size + EXPECT_EQ(U32(dds, 28), 0u); // no mipmaps + EXPECT_EQ(U32(dds, 80), 0x4u); // four CC + EXPECT_EQ(dds.substr(84, 4), "DXT5"); + EXPECT_EQ(U32(dds, 108), 0x1000u); + + // Decoded, the visible pixels are close to the source and the background stays transparent + int worst = 0; + for (int by = 0; by < 32; by++) { + for (int bx = 0; bx < 32; bx++) { + const auto block = DecodeDxt5Block(reinterpret_cast(dds.data()) + 128 + (by * 32 + bx) * 16); + for (int i = 0; i < 16; i++) { + const auto* src = &image.rgba[((by * 4 + i / 4) * 128 + bx * 4 + i % 4) * 4]; + EXPECT_EQ(block[i * 4 + 3], src[3]); + if (src[3] == 0) continue; + for (int c = 0; c < 3; c++) worst = std::max(worst, std::abs(block[i * 4 + c] - src[c])); + } + } + } + EXPECT_LE(worst, 12); + + // A flat block is one color, however it's stored + std::array flat{}; + for (int i = 0; i < 16; i++) flat[i * 4] = 200, flat[i * 4 + 1] = 40, flat[i * 4 + 2] = 10, flat[i * 4 + 3] = 128; + const auto encoded = UgcFormats::EncodeDxt5Block(flat); + const auto decoded = DecodeDxt5Block(encoded.data()); + for (int i = 0; i < 16; i++) { + EXPECT_NEAR(decoded[i * 4], 200, 5); + EXPECT_NEAR(decoded[i * 4 + 1], 40, 5); + EXPECT_EQ(decoded[i * 4 + 3], 128); + } +} + // A download is written for both of the client's modes: .gz and .checksum (3D services) and .sd0 (without), all // holding the same file TEST(UgcJobs, AddsTheDownloadForBothClientModes) {