feat(assets): read retail assets through the vendored libra3assets library

Vendor libra3assets (C++26 modules: BIG4, RefPack, BinaryAsset, CSF, CkMp map)
under third_party/ and add the ra3_assets target. ra3.fs, ra3.map and ra3.terrain
become thin adapters over it:

- ra3.fs delegates RefPack and the BIG4 index/payload reads (index-only, payloads
  read on demand).
- ra3.map decodes ObjectsList and CSF via map_document/csf_table; starts come from
  player_starts(), replacing the off-by-one whole-buffer scan.
- ra3.terrain takes the CkMp container and HeightMapData from map_document, with
  BlendTileData (not modelled by the library) parsed from the chunk payload.

Adds an opt-in real-asset check (OPENRA3_TEST_ASSETS) plus the library's own unit
suite as ra3assets_unit.
This commit is contained in:
EnderTheCoder
2026-09-29 23:58:13 +08:00
parent 8047e80ed6
commit 23f8be394c
17 changed files with 3078 additions and 519 deletions
+310
View File
@@ -0,0 +1,310 @@
#include "test_main.hpp"
import std;
import ra3.assets;
using namespace ra3::assets;
namespace {
auto bytes_of(std::string_view text) -> std::vector<std::byte> {
std::vector<std::byte> out;
out.reserve(text.size());
for (const auto ch: text) out.push_back(static_cast<std::byte>(static_cast<unsigned char>(ch)));
return out;
}
auto text_of(std::span<const std::byte> bytes) -> std::string {
return std::string{reinterpret_cast<const char *>(bytes.data()), bytes.size()};
}
/** A deterministic pseudo-random buffer (no external RNG needed). */
auto pseudo_random(std::size_t size, std::uint32_t seed) -> std::vector<std::byte> {
std::vector<std::byte> out;
out.reserve(size);
std::uint32_t state = seed;
for (std::size_t i = 0; i < size; ++i) {
state = state * 1664525U + 1013904223U;
out.push_back(static_cast<std::byte>((state >> 16U) & 0xFFU));
}
return out;
}
auto make_empty_ckmp(const std::vector<std::string> &names) -> std::vector<std::byte> {
byte_writer writer;
writer.write_ascii("CkMp");
writer.write_u32(static_cast<std::uint32_t>(names.size()));
for (std::uint32_t i = static_cast<std::uint32_t>(names.size()); i >= 1U; --i) {
writer.write_u8(static_cast<std::uint8_t>(names[i - 1].size()));
writer.write_ascii(names[i - 1]);
writer.write_u32(i);
}
return writer.take();
}
auto make_csf() -> std::vector<std::byte> {
byte_writer writer;
writer.write_ascii(" FSC");
writer.write_u32(3);
writer.write_u32(2); // labels
writer.write_u32(2); // value blocks
writer.write_u32(0);
writer.write_u32(0);
writer.write_u32(csf_label_flag);
writer.write_u32(1);
writer.write_u32(3);
writer.write_ascii("ABC");
writer.write_u32(csf_value_flag);
writer.write_u32(5);
for (const auto ch: std::string_view{"Hello"}) writer.write_u16(static_cast<std::uint16_t>(ch) ^ 0xFFFFU);
writer.write_u32(csf_label_flag);
writer.write_u32(1);
writer.write_u32(3);
writer.write_ascii("UNI");
writer.write_u32(csf_value_flag);
writer.write_u32(1);
writer.write_u16(0x4E2DU ^ 0xFFFFU); // U+4E2D (CJK)
return writer.take();
}
} // namespace
TEST(refpack_roundtrip_small) {
const std::vector<std::string> samples{"", "A", "AB", "ABC", "ABCD", "abcabcabc", std::string(300, 'z')};
for (const auto &sample: samples) {
const auto input = bytes_of(sample);
const auto packed = refpack_compress(input);
const auto restored = refpack_decompress(packed);
CHECK_EQ(restored.size(), input.size());
CHECK(std::ranges::equal(restored, input));
}
}
TEST(refpack_roundtrip_large_and_repetitive) {
std::vector<std::byte> input;
for (int i = 0; i < 2000; ++i) {
for (const auto ch: std::string_view{"lorem ipsum dolor sit amet "}) input.push_back(static_cast<std::byte>(ch));
}
const auto random = pseudo_random(50000, 0x1234ABCDU);
input.insert(input.end(), random.begin(), random.end());
const auto packed = refpack_compress(input);
CHECK(packed.size() < input.size()); // the repeated prefix must compress
CHECK(std::ranges::equal(refpack_decompress(packed), input));
}
TEST(refpack_decode_known_stream) {
// "abcd" literals, then a back-reference (distance 4, length 3) -> "abcdabc".
const std::vector<std::byte> stream{
std::byte{0x10}, std::byte{0xFB}, std::byte{0x00}, std::byte{0x00}, std::byte{0x07}, std::byte{0xE0},
std::byte{'a'}, std::byte{'b'}, std::byte{'c'}, std::byte{'d'}, std::byte{0x00}, std::byte{0x03},
std::byte{0xFC},
};
CHECK(is_refpack(stream));
CHECK_EQ(text_of(refpack_decompress(stream)), "abcdabc");
CHECK_EQ(refpack_output_size(stream), 7U);
}
TEST(big_roundtrip) {
big_writer writer;
writer.add("data\\a.txt", bytes_of("hello world"));
writer.add("data\\b.bin", bytes_of(std::string(1000, 'x')), true);
const auto image = writer.write();
CHECK(image.size() >= 16U);
const std::string magic{reinterpret_cast<const char *>(image.data()), 4};
CHECK_EQ(magic, "BIG4");
const auto archive = big_archive::from_bytes(image);
CHECK_EQ(archive.size(), 2U);
CHECK(archive.contains("data\\a.txt"));
CHECK_EQ(text_of(archive.read("data\\a.txt")), "hello world");
CHECK_EQ(archive.read("data\\b.bin").size(), 1000U);
CHECK_EQ(archive.find("data\\").size(), 2U);
// The first payload starts 64-byte aligned, as the retail archives do.
CHECK_EQ(archive.entries()[0].offset % 64U, 0U);
}
TEST(big_open_from_disk_is_lazy) {
big_writer writer;
writer.add("data\\a.txt", bytes_of("hello disk"));
writer.add("data\\b.bin", bytes_of(std::string(4096, 'q')), true);
const auto image = writer.write();
const auto path = std::filesystem::temp_directory_path() / "libra3assets_big_lazy.big";
write_file(path, image);
const auto archive = big_archive::open(path);
CHECK_EQ(archive.size(), 2U);
CHECK(archive.path() == path);
CHECK(archive.contains("data\\a.txt"));
CHECK_EQ(archive.entries()[0].offset % 64U, 0U);
CHECK_EQ(text_of(archive.read("data\\a.txt")), "hello disk");
CHECK_EQ(archive.read("data\\b.bin").size(), 4096U);
CHECK_EQ(text_of(archive.read_prefix("data\\a.txt", 5)), "hello");
CHECK(archive.read_prefix("data\\b.bin", 8U).size() == 8U);
std::error_code ec;
std::filesystem::remove(path, ec);
}
TEST(binary_manifest_and_hash) {
CHECK_EQ(hash_string("W3DMesh"), 0xC2B1A262U);
CHECK_EQ(hash_string("ABAIRFIELD", false), 0x2B479BD3U);
CHECK(hash_string("Texture") != hash_string("Texture", false));
const std::string name = "W3DMesh:TEST";
const std::string source = "art:test.w3x";
byte_writer manifest;
manifest.write_u8(0); // not big-endian
manifest.write_u8(0); // not linked
manifest.write_u16(5); // version
manifest.write_u32(0); // checksum
manifest.write_u32(0); // all-types hash
manifest.write_u32(1); // count
manifest.write_u32(12);
manifest.write_u32(0);
manifest.write_u32(0);
manifest.write_u32(0);
manifest.write_u32(0); // reference buffer
manifest.write_u32(0); // reference-name buffer
manifest.write_u32(static_cast<std::uint32_t>(name.size() + 1));
manifest.write_u32(static_cast<std::uint32_t>(source.size() + 1));
manifest.write_u32(hash_string("W3DMesh"));
manifest.write_u32(hash_string("TEST", false));
manifest.write_u32(hash_string("W3DMesh"));
manifest.write_u32(hash_string("TEST", false));
manifest.write_i32(0); // reference offset
manifest.write_i32(0); // reference count
manifest.write_i32(0); // name offset
manifest.write_i32(0); // source offset
manifest.write_u32(8); // instance size
manifest.write_u32(0); // relocation size
manifest.write_u32(0); // imports size
manifest.write_u32(0); // tokenized
manifest.write_ascii(name);
manifest.write_u8(0);
manifest.write_ascii(source);
manifest.write_u8(0);
byte_writer data;
data.write_u32(0); // stream checksum
data.write_ascii("PAYLOAD!");
auto container = binary_container::from_bytes(manifest.take(), data.take());
CHECK_EQ(container.size(), 1U);
const auto &asset = container.assets()[0];
CHECK_EQ(asset.name, "W3DMesh:TEST");
CHECK_EQ(asset.type_name(), "W3DMesh");
CHECK_EQ(asset.instance_name(), "TEST");
CHECK_EQ(asset.source, "art:test.w3x");
CHECK_EQ(text_of(container.read_instance(asset)), "PAYLOAD!");
CHECK_EQ(container.find("#0").name, "W3DMesh:TEST");
CHECK_EQ(container.of_type("w3dmesh").size(), 1U);
CHECK_EQ(container.type_counts().at("W3DMesh"), 1U);
CHECK(container.read_relocation(asset).empty());
container.set_cdata_source([&asset](const std::string &cdata_name) -> std::optional<std::vector<std::byte>> {
if (cdata_name == asset.cdata_name("static")) return bytes_of("CDATA");
return std::nullopt;
});
CHECK(container.read_cdata(asset).has_value());
CHECK_EQ(text_of(container.read_payload(asset)), "CDATA");
}
TEST(csf_roundtrip) {
auto table = csf_table::parse(make_csf());
CHECK_EQ(table.size(), 2U);
CHECK_EQ(table.version(), 3U);
CHECK_EQ(table.lookup("abc"), "Hello"); // lookup is case-insensitive
CHECK_EQ(table.lookup("ABC"), "Hello");
CHECK_EQ(table.lookup("uni"), "\xE4\xB8\xAD");
CHECK(table.find("missing") == nullptr);
const auto rewritten = table.write();
const auto reparsed = csf_table::parse(rewritten);
CHECK_EQ(reparsed.size(), 2U);
CHECK_EQ(reparsed.lookup("abc"), "Hello");
CHECK_EQ(reparsed.lookup("uni"), "\xE4\xB8\xAD");
}
TEST(csf_decode_utf16_surrogate) {
const std::u16string emoji = u"\U0001F600";
CHECK_EQ(utf16_to_utf8(emoji), "\xF0\x9F\x98\x80");
}
TEST(map_roundtrip) {
const std::vector<std::string> names{"HeightMapData", "ObjectsList", "Object", "waypointName", "MPPositionList", "MPPositionInfo", "WorldInfo", "waypointID"};
auto document = map_document::parse(make_empty_ckmp(names));
CHECK_EQ(document.names().size(), names.size());
height_map_data height;
height.width = 2;
height.height = 2;
height.version = 6;
height.elevations = {1, 2, 3, 4};
document.set_height_map(height);
map_object waypoint;
waypoint.type_name = "*Waypoints/Waypoint";
waypoint.position = {100.0F, 200.0F, 0.0F};
waypoint.properties.push_back(asset_property::text("waypointName", "Player_1_Start"));
waypoint.properties.push_back(asset_property::integer("waypointID", 0));
document.set_objects({waypoint});
mp_position position;
position.is_human = true;
position.team = 1;
document.set_mp_positions({position});
const auto ckmp = document.to_ckmp();
const auto reparsed = map_document::parse(ckmp);
CHECK(reparsed.has_chunk(chunk_kind::height_map_data));
CHECK(reparsed.has_chunk(chunk_kind::objects_list));
CHECK(reparsed.has_chunk(chunk_kind::mp_position_list));
CHECK_EQ(reparsed.find_chunk("HeightMapData")->kind, chunk_kind::height_map_data);
CHECK_EQ(chunk_kind_of("BlendTileData"), chunk_kind::blend_tile_data);
CHECK_EQ(chunk_kind_of("NotAChunk"), chunk_kind::unknown);
const auto restored = reparsed.height_map();
CHECK(restored.has_value());
CHECK_EQ(restored->width, 2U);
CHECK_EQ(restored->at(1, 1), 4U);
CHECK_EQ(restored->vertical_scale(), 0.0390625F);
const auto objects = reparsed.objects();
CHECK_EQ(objects.size(), 1U);
CHECK_EQ(objects[0].type_name, "*Waypoints/Waypoint");
CHECK_EQ(objects[0].property("waypointID")->as_int(), 0);
const auto starts = reparsed.player_starts();
CHECK_EQ(starts.size(), 1U);
CHECK_EQ(starts[0].index, 1);
CHECK_EQ(starts[0].position.x, 100.0F);
const auto positions = reparsed.mp_positions();
CHECK_EQ(positions.size(), 1U);
CHECK(positions[0].is_human);
CHECK_EQ(positions[0].team, 1U);
}
TEST(map_compressed_and_big_payload) {
const std::vector<std::string> names{"HeightMapData"};
auto document = map_document::parse(make_empty_ckmp(names));
height_map_data height;
height.width = 1;
height.height = 1;
height.version = 6;
height.elevations = {7};
document.set_height_map(height);
// EAR\0 + RefPack round-trip.
const auto compressed = document.serialize(true);
const auto from_compressed = map_document::parse(compressed);
CHECK(from_compressed.height_map().has_value());
CHECK_EQ(from_compressed.height_map()->at(0, 0), 7U);
// A BIG payload is one more RefPack layer over the map file.
const auto big_payload = refpack_compress(document.to_ckmp());
const auto from_big = map_document::parse(big_payload);
CHECK_EQ(from_big.height_map()->at(0, 0), 7U);
}
+5
View File
@@ -0,0 +1,5 @@
#include "test_main.hpp"
int main() {
return ra3test::run_all();
}
+83
View File
@@ -0,0 +1,83 @@
#ifndef RA3TEST_HPP
#define RA3TEST_HPP
// A tiny dependency-free test harness (registry + CHECK macros). Each unit-test
// executable compiles test_main.cpp and links the sources under test.
#include <functional>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace ra3test {
struct test_case {
std::string name;
std::function<void()> fn;
};
inline auto registry() -> std::vector<test_case> & {
static std::vector<test_case> tests;
return tests;
}
inline auto failure_count() -> int & {
static int count = 0;
return count;
}
struct registrar {
registrar(std::string name, std::function<void()> fn) { registry().push_back({std::move(name), std::move(fn)}); }
};
inline auto report_failure(const std::string &expr, const std::string &file, int line) -> void {
++failure_count();
std::cerr << " FAIL " << file << ':' << line << " " << expr << '\n';
}
inline auto check(bool condition, const std::string &expr, const std::string &file, int line) -> void {
if (!condition) report_failure(expr, file, line);
}
inline auto run_all() -> int {
int passed = 0;
for (auto &test: registry()) {
const int before = failure_count();
std::cout << "[ RUN ] " << test.name << '\n';
try {
test.fn();
} catch (const std::exception &exc) {
report_failure(std::string("uncaught exception: ") + exc.what(), __FILE__, __LINE__);
}
if (failure_count() == before) {
++passed;
std::cout << "[ OK ] " << test.name << '\n';
}
}
std::cout << "\n" << passed << '/' << registry().size() << " tests passed, " << failure_count() << " failure(s)\n";
return failure_count() == 0 ? 0 : 1;
}
} // namespace ra3test
#define TEST(name) \
static void name(); \
static ::ra3test::registrar ra3test_reg_##name(#name, name); \
static void name()
#define CHECK(cond) ::ra3test::check((cond), #cond, __FILE__, __LINE__)
#define CHECK_EQ(a, b) ::ra3test::check_eq((a), (b), #a, #b, __FILE__, __LINE__)
namespace ra3test {
template<typename A, typename B>
auto check_eq(const A &a, const B &b, const std::string &ea, const std::string &eb, const std::string &file, int line) -> void {
if (!(a == b)) {
std::ostringstream os;
os << ea << " == " << eb;
report_failure(os.str(), file, line);
}
}
}
#endif // RA3TEST_HPP