blob: 6e9667c114add205a7aa21642c4b3b337eb7684b [file]
#include <catch.hpp>
#include "rive/texture_archive.hpp"
#include <cstring>
#include <filesystem>
#include <fstream>
#include <vector>
using namespace rive;
// ---------------------------------------------------------------------------
// Helper: build a valid RTEX byte stream in memory.
//
// File layout (matches packages/runtime/src/texture_archive.cpp):
// [4] magic 'RTEX'
// [2] version (u16)
// [2] textureCount (u16)
// per texture:
// [2]x4 width, height, paddedWidth, paddedHeight (u16)
// [1]x4 blockSizeX, blockSizeY, bytesPerBlock, numMips (u8)
// [1] format (u8 GPUTextureFormat)
// per mip (numMips entries):
// [4] blocksX, blocksY, bytesTotal (i32 each)
// [blob] raw mip data, size == sum(bytesTotal) over all mips
// ---------------------------------------------------------------------------
namespace
{
struct MipDesc
{
int32_t blocksX;
int32_t blocksY;
int32_t bytesTotal;
};
struct TexDesc
{
uint16_t width = 4, height = 4, paddedWidth = 4, paddedHeight = 4;
uint8_t blockSizeX = 4, blockSizeY = 4, bytesPerBlock = 16, numMips = 1;
GPUTextureFormat format = GPUTextureFormat::bc7;
std::vector<MipDesc> mips;
};
template <typename T> static void append(std::vector<uint8_t>& buf, T val)
{
const uint8_t* p = reinterpret_cast<const uint8_t*>(&val);
buf.insert(buf.end(), p, p + sizeof(T));
}
static std::vector<uint8_t> buildArchive(uint16_t version,
const std::vector<TexDesc>& textures,
const std::vector<uint8_t>& dataBlob)
{
std::vector<uint8_t> buf;
buf.insert(buf.end(), {'R', 'T', 'E', 'X'});
append(buf, version);
append(buf, static_cast<uint16_t>(textures.size()));
for (const auto& t : textures)
{
append(buf, t.width);
append(buf, t.height);
append(buf, t.paddedWidth);
append(buf, t.paddedHeight);
append(buf, t.blockSizeX);
append(buf, t.blockSizeY);
append(buf, t.bytesPerBlock);
append(buf, t.numMips);
append(buf, static_cast<uint8_t>(t.format));
for (const auto& m : t.mips)
{
append(buf, m.blocksX);
append(buf, m.blocksY);
append(buf, m.bytesTotal);
}
}
buf.insert(buf.end(), dataBlob.begin(), dataBlob.end());
return buf;
}
// Build a minimal valid single-texture archive with one 4x4 BC7 mip.
static std::vector<uint8_t> makeSimpleArchive()
{
std::vector<uint8_t> blob(16, 0xAB);
TexDesc tex;
tex.mips = {{1, 1, 16}};
return buildArchive(1, {tex}, blob);
}
static std::string writeTempArchive(const std::vector<uint8_t>& bytes)
{
static int counter = 0;
std::string path = (std::filesystem::temp_directory_path() /
("rive_ta_test_" + std::to_string(counter++) + ".rtex"))
.string();
std::ofstream out(path, std::ios::binary);
out.write(reinterpret_cast<const char*>(bytes.data()),
static_cast<std::streamsize>(bytes.size()));
out.close();
return path;
}
} // namespace
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
TEST_CASE("TextureDirectory import: valid single BC7 texture",
"[texture_archive]")
{
auto bytes = makeSimpleArchive();
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
REQUIRE(dir.dir.size() == 1);
const TextureData& tex = dir.dir[0];
CHECK(tex.width == 4);
CHECK(tex.height == 4);
CHECK(tex.paddedWidth == 4);
CHECK(tex.paddedHeight == 4);
CHECK(tex.blockSizeX == 4);
CHECK(tex.blockSizeY == 4);
CHECK(tex.bytesPerBlock == 16);
CHECK(tex.numMips == 1);
CHECK(tex.format == GPUTextureFormat::bc7);
CHECK(tex.totalBytes == 16);
REQUIRE(tex.mipLevels.size() == 1);
CHECK(tex.mipLevels[0].blocksX == 1);
CHECK(tex.mipLevels[0].blocksY == 1);
CHECK(tex.mipLevels[0].bytesTotal == 16);
}
TEST_CASE("TextureDirectory import: mip blocks pointer into dataBlob",
"[texture_archive]")
{
auto bytes = makeSimpleArchive();
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
const TextureMipLevel& mip = dir.dir[0].mipLevels[0];
REQUIRE(mip.blocks != nullptr);
const uint8_t* blobBegin = dir.dataBlob.data();
const uint8_t* blobEnd = blobBegin + dir.dataBlob.size();
CHECK(mip.blocks >= blobBegin);
CHECK(mip.blocks + mip.bytesTotal <= blobEnd);
CHECK(mip.blocks[0] == 0xAB);
}
TEST_CASE("TextureDirectory import: zero textures", "[texture_archive]")
{
auto bytes = buildArchive(1, {}, {});
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
CHECK(dir.dir.empty());
CHECK(dir.dataBlob.empty());
}
TEST_CASE("TextureDirectory import: multiple textures", "[texture_archive]")
{
// tex0 + tex1 each contribute 16 bytes of contiguous mip data.
std::vector<uint8_t> blob(32);
std::fill(blob.begin(), blob.begin() + 16, 0x11);
std::fill(blob.begin() + 16, blob.end(), 0x22);
TexDesc tex0, tex1;
tex0.mips = {{1, 1, 16}};
tex1.mips = {{1, 1, 16}};
auto bytes = buildArchive(1, {tex0, tex1}, blob);
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
REQUIRE(dir.dir.size() == 2);
CHECK(dir.dir[0].mipLevels[0].blocks[0] == 0x11);
CHECK(dir.dir[1].mipLevels[0].blocks[0] == 0x22);
}
TEST_CASE("TextureDirectory import: multiple mip levels", "[texture_archive]")
{
// Mip 0: 2x2 blocks = 64 bytes. Mip 1: 1x1 block = 16 bytes.
std::vector<uint8_t> blob(80);
std::fill(blob.begin(), blob.begin() + 64, 0xAA);
std::fill(blob.begin() + 64, blob.end(), 0xBB);
TexDesc tex;
tex.width = 8;
tex.height = 8;
tex.paddedWidth = 8;
tex.paddedHeight = 8;
tex.numMips = 2;
tex.mips = {{2, 2, 64}, {1, 1, 16}};
auto bytes = buildArchive(1, {tex}, blob);
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
REQUIRE(dir.dir[0].mipLevels.size() == 2);
CHECK(dir.dir[0].mipLevels[0].bytesTotal == 64);
CHECK(dir.dir[0].mipLevels[0].blocks[0] == 0xAA);
CHECK(dir.dir[0].mipLevels[1].bytesTotal == 16);
CHECK(dir.dir[0].mipLevels[1].blocks[0] == 0xBB);
}
TEST_CASE("TextureDirectory import: clears previous state on re-import",
"[texture_archive]")
{
auto bytes = makeSimpleArchive();
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
REQUIRE(dir.dir.size() == 1);
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
CHECK(dir.dir.size() == 1);
}
TEST_CASE("TextureDirectory import: bad magic", "[texture_archive]")
{
auto bytes = makeSimpleArchive();
bytes[0] = 'X';
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
}
TEST_CASE("TextureDirectory import: empty span", "[texture_archive]")
{
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(nullptr, 0)));
}
TEST_CASE("TextureDirectory import: too small for header", "[texture_archive]")
{
std::vector<uint8_t> bytes = {'R', 'T', 'E', 'X'}; // header needs 8 bytes
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
}
TEST_CASE("TextureDirectory import: unsupported version", "[texture_archive]")
{
auto bytes = buildArchive(99, {}, {}); // bogus version
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
}
TEST_CASE("TextureDirectory import: truncated descriptor", "[texture_archive]")
{
// Claim 1 texture but provide no descriptor bytes.
std::vector<uint8_t> buf;
buf.insert(buf.end(), {'R', 'T', 'E', 'X'});
append(buf, uint16_t(1));
append(buf, uint16_t(1));
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(buf.data(), buf.size())));
}
TEST_CASE("TextureDirectory import: truncated mip table", "[texture_archive]")
{
// Descriptor present but mip entries truncated.
std::vector<uint8_t> buf;
buf.insert(buf.end(), {'R', 'T', 'E', 'X'});
append(buf, uint16_t(1));
append(buf, uint16_t(1));
append(buf, uint16_t(4)); // width
append(buf, uint16_t(4)); // height
append(buf, uint16_t(4)); // paddedWidth
append(buf, uint16_t(4)); // paddedHeight
append(buf, uint8_t(4)); // blockSizeX
append(buf, uint8_t(4)); // blockSizeY
append(buf, uint8_t(16)); // bytesPerBlock
append(buf, uint8_t(1)); // numMips = 1
append(buf, uint8_t(static_cast<uint8_t>(GPUTextureFormat::bc7)));
// No mip entry follows.
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(buf.data(), buf.size())));
}
TEST_CASE("TextureDirectory import: blob size mismatch", "[texture_archive]")
{
// Mip table claims 16 bytes but blob has only 8.
std::vector<uint8_t> blob(8, 0);
TexDesc tex;
tex.mips = {{1, 1, 16}};
auto bytes = buildArchive(1, {tex}, blob);
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
}
TEST_CASE("TextureDirectory import: blob too large", "[texture_archive]")
{
// Mip table claims 16 bytes but blob has 32 — strict size check rejects.
std::vector<uint8_t> blob(32, 0);
TexDesc tex;
tex.mips = {{1, 1, 16}};
auto bytes = buildArchive(1, {tex}, blob);
TextureDirectory dir;
CHECK_FALSE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
}
TEST_CASE("TextureDirectory import: ASTC format preserved", "[texture_archive]")
{
std::vector<uint8_t> blob(16, 0xCC);
TexDesc tex;
tex.format = GPUTextureFormat::astc;
tex.blockSizeX = 6;
tex.blockSizeY = 6;
tex.bytesPerBlock = 16; // ASTC always 16 bytes per block
tex.mips = {{1, 1, 16}};
auto bytes = buildArchive(1, {tex}, blob);
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
CHECK(dir.dir[0].format == GPUTextureFormat::astc);
CHECK(dir.dir[0].blockSizeX == 6);
CHECK(dir.dir[0].blockSizeY == 6);
CHECK(dir.dir[0].bytesPerBlock == 16);
}
TEST_CASE("TextureDirectory import: ETC2 format preserved", "[texture_archive]")
{
std::vector<uint8_t> blob(8, 0xDD);
TexDesc tex;
tex.format = GPUTextureFormat::etc2;
tex.blockSizeX = 4;
tex.blockSizeY = 4;
tex.bytesPerBlock = 8; // ETC2 RGB8: 8 bytes per block
tex.mips = {{1, 1, 8}};
auto bytes = buildArchive(1, {tex}, blob);
TextureDirectory dir;
REQUIRE(dir.import(Span<const uint8_t>(bytes.data(), bytes.size())));
CHECK(dir.dir[0].format == GPUTextureFormat::etc2);
CHECK(dir.dir[0].bytesPerBlock == 8);
}
TEST_CASE("TextureDirectory addTexture: packs mip data into dataBlob",
"[texture_archive]")
{
std::vector<uint8_t> pixelData(16, 0xAB);
TextureData td;
td.width = td.height = td.paddedWidth = td.paddedHeight = 4;
td.blockSizeX = td.blockSizeY = 4;
td.bytesPerBlock = 16;
td.numMips = 1;
td.format = GPUTextureFormat::bc7;
td.totalBytes = 16;
TextureMipLevel mip;
mip.blocksX = mip.blocksY = 1;
mip.bytesTotal = 16;
mip.blocks = pixelData.data();
td.mipLevels.push_back(mip);
TextureDirectory archive;
archive.addTexture(td);
REQUIRE(archive.dataBlob.size() == 16);
CHECK(archive.dataBlob[0] == 0xAB);
REQUIRE(archive.dir.size() == 1);
const uint8_t* blobBegin = archive.dataBlob.data();
const uint8_t* blobEnd = blobBegin + archive.dataBlob.size();
const TextureMipLevel& stored = archive.dir[0].mipLevels[0];
CHECK(stored.blocks >= blobBegin);
CHECK(stored.blocks + stored.bytesTotal <= blobEnd);
CHECK(stored.blocks[0] == 0xAB);
}
TEST_CASE("TextureDirectory addTexture: two textures share contiguous dataBlob",
"[texture_archive]")
{
auto makeTd = [](uint8_t fill, GPUTextureFormat fmt) {
std::vector<uint8_t> pixels(16, fill);
TextureData td;
td.width = td.height = td.paddedWidth = td.paddedHeight = 4;
td.blockSizeX = td.blockSizeY = 4;
td.bytesPerBlock = 16;
td.numMips = 1;
td.format = fmt;
td.totalBytes = 16;
TextureMipLevel mip;
mip.blocksX = mip.blocksY = 1;
mip.bytesTotal = 16;
mip.blocks = pixels.data();
td.mipLevels.push_back(mip);
return std::make_pair(td, pixels);
};
auto [td0, px0] = makeTd(0x11, GPUTextureFormat::bc7);
td0.mipLevels[0].blocks = px0.data();
auto [td1, px1] = makeTd(0x22, GPUTextureFormat::astc);
td1.mipLevels[0].blocks = px1.data();
TextureDirectory archive;
archive.addTexture(td0);
archive.addTexture(td1);
REQUIRE(archive.dataBlob.size() == 32);
REQUIRE(archive.dir.size() == 2);
CHECK(archive.dir[0].mipLevels[0].blocks[0] == 0x11);
CHECK(archive.dir[1].mipLevels[0].blocks[0] == 0x22);
CHECK(archive.dir[1].mipLevels[0].blocks ==
archive.dir[0].mipLevels[0].blocks + 16);
}
TEST_CASE("TextureDirectory exportArchive: round-trip preserves data",
"[texture_archive]")
{
std::vector<uint8_t> pixelData(16, 0x5A);
TextureData td;
td.width = td.height = td.paddedWidth = td.paddedHeight = 4;
td.blockSizeX = td.blockSizeY = 4;
td.bytesPerBlock = 16;
td.numMips = 1;
td.format = GPUTextureFormat::bc7;
td.totalBytes = 16;
TextureMipLevel mip;
mip.blocksX = mip.blocksY = 1;
mip.bytesTotal = 16;
mip.blocks = pixelData.data();
td.mipLevels.push_back(mip);
TextureDirectory writer;
writer.addTexture(td);
auto path =
(std::filesystem::temp_directory_path() / "rive_ta_roundtrip.rtex")
.string();
REQUIRE(writer.exportArchive(path));
TextureDirectory reader;
REQUIRE(reader.import(path));
REQUIRE(reader.dir.size() == 1);
CHECK(reader.dir[0].format == GPUTextureFormat::bc7);
CHECK(reader.dir[0].width == 4);
CHECK(reader.dir[0].mipLevels[0].bytesTotal == 16);
CHECK(reader.dir[0].mipLevels[0].blocks[0] == 0x5A);
std::remove(path.c_str());
}
TEST_CASE("TextureDirectory import (file): valid archive", "[texture_archive]")
{
auto bytes = makeSimpleArchive();
auto path = writeTempArchive(bytes);
TextureDirectory dir;
REQUIRE(dir.import(path));
REQUIRE(dir.dir.size() == 1);
CHECK(dir.dir[0].width == 4);
CHECK(dir.dir[0].mipLevels[0].blocks[0] == 0xAB);
std::remove(path.c_str());
}
TEST_CASE("TextureDirectory import (file): file not found", "[texture_archive]")
{
TextureDirectory dir;
CHECK_FALSE(dir.import((std::filesystem::temp_directory_path() /
"rive_does_not_exist_xyz.rtex")
.string()));
}
TEST_CASE("TextureDirectory import (file): bad magic", "[texture_archive]")
{
auto bytes = makeSimpleArchive();
bytes[0] = 'X';
auto path = writeTempArchive(bytes);
TextureDirectory dir;
CHECK_FALSE(dir.import(path));
std::remove(path.c_str());
}
TEST_CASE("TextureDirectory import (file): too small for header",
"[texture_archive]")
{
std::vector<uint8_t> bytes = {'R', 'T', 'E', 'X'};
auto path = writeTempArchive(bytes);
TextureDirectory dir;
CHECK_FALSE(dir.import(path));
std::remove(path.c_str());
}