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) {