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
+50 -92
View File
@@ -6,6 +6,7 @@ export import ra3.core;
export import ra3.fs;
export import ra3.render;
export import ra3.models;
import ra3.assets;
/**
* The map's real terrain, read from the compiled `CkMp` chunk tree.
@@ -91,82 +92,24 @@ export namespace ra3::terrain {
};
namespace detail {
/** View a `uint8` range as bytes, the currency of `libra3assets`. */
[[nodiscard]] inline auto as_bytes(std::span<const uint8> data) -> std::span<const std::byte> { return std::as_bytes(data); }
/** View a `libra3assets` byte range as OpenRA3's `uint8`. */
[[nodiscard]] inline auto as_u8(std::span<const std::byte> data) -> std::span<const uint8> {
return {reinterpret_cast<const uint8 *>(data.data()), data.size()};
}
[[nodiscard]] inline auto read_u16(const uint8 *p) -> uint16 { return static_cast<uint16>(p[0]) | (static_cast<uint16>(p[1]) << 8U); }
[[nodiscard]] inline auto read_u32(const uint8 *p) -> uint32 {
return static_cast<uint32>(p[0]) | (static_cast<uint32>(p[1]) << 8U) | (static_cast<uint32>(p[2]) << 16U) |
(static_cast<uint32>(p[3]) << 24U);
}
struct chunk {
std::string name;
uint16 version = 0;
usize offset = 0;
usize size = 0;
};
/**
* Parse the `CkMp` chunk tree into a flat chunk list.
*
* Layout: `"CkMp"`, `u32 assetCount`, then `assetCount` entries of
* `{ u8 nameLen, name, u32 index }` (index descending from the count),
* then `{ u32 index, u16 version, u32 dataSize, data[dataSize] }` per
* chunk until the end.
*/
[[nodiscard]] inline auto parse_chunks(std::span<const uint8> data) -> std::vector<chunk> {
if (data.size() < 8U || std::memcmp(data.data(), "CkMp", 4) != 0) throw terrain_error("not a CkMp map");
usize pos = 4;
const auto count = read_u32(data.data() + pos);
pos += 4;
std::vector<std::string> names(count + 1U);
for (uint32 i = count; i >= 1U; --i) {
if (pos >= data.size()) throw terrain_error("truncated asset-name table");
const auto len = data[pos++];
if (pos + len + 4U > data.size()) throw terrain_error("truncated asset-name");
names[i] = std::string{reinterpret_cast<const char *>(data.data() + pos), len};
pos += len;
pos += 4; // asset index (== i)
}
std::vector<chunk> chunks;
while (pos + 10U <= data.size()) {
const auto index = read_u32(data.data() + pos);
const auto version = read_u16(data.data() + pos + 4U);
const auto size = read_u32(data.data() + pos + 6U);
pos += 10;
if (index >= names.size() || pos + size > data.size()) throw terrain_error("bad chunk header");
chunks.push_back({names[index], version, pos, size});
pos += size;
}
return chunks;
}
[[nodiscard]] inline auto find(const std::vector<chunk> &chunks, std::string_view name) -> const chunk * {
for (const auto &c: chunks) {
if (c.name == name) return &c;
}
return nullptr;
}
inline auto parse_heightmap(std::span<const uint8> data, const chunk &c, map_data &out) -> void {
usize p = c.offset;
out.width = read_u32(data.data() + p);
out.height = read_u32(data.data() + p + 4U);
out.border_width = read_u32(data.data() + p + 8U);
const auto border_count = read_u32(data.data() + p + 12U);
p += 16;
p += static_cast<usize>(border_count) * (c.version >= 6U ? 16U : 8U);
p += 4U; // area
if (out.width == 0U || out.height == 0U) throw terrain_error("empty heightmap");
const auto area = static_cast<usize>(out.width) * out.height;
out.elevations.resize(area);
for (usize i = 0; i < area; ++i) {
if (p >= data.size()) throw terrain_error("truncated heightmap");
out.elevations[i] = c.version >= 5U ? read_u16(data.data() + p) : data[p];
p += c.version >= 5U ? 2U : 1U;
}
}
// The `CkMp` container and `HeightMapData` chunk are modelled by
// `libra3assets` (`ra3.assets`); `parse_map` reads them through a
// `map_document`. Only `BlendTileData` (which the library does not type
// yet) is parsed here, over the chunk payload the document exposes.
/** Where the texture table ends and how many `BlendDescription`s follow. */
struct texture_table_info {
@@ -246,20 +189,20 @@ export namespace ra3::terrain {
}
}
inline auto parse_blend(std::span<const uint8> data, const chunk &c, map_data &out) -> void {
inline auto parse_blend(std::span<const uint8> payload, uint16 version, map_data &out) -> void {
const auto area = static_cast<usize>(out.width) * out.height;
usize p = c.offset;
const auto num_tiles = read_u32(data.data() + p);
usize p = 0;
const auto num_tiles = read_u32(payload.data() + p);
p += 4;
if (num_tiles != area) throw terrain_error("BlendTileData tile count mismatch");
out.tiles.resize(area);
for (usize i = 0; i < area; ++i) out.tiles[i] = read_u16(data.data() + p + i * 2U);
for (usize i = 0; i < area; ++i) out.tiles[i] = read_u16(payload.data() + p + i * 2U);
p += area * 2U;
const auto bits = (c.version >= 14U && c.version < 24U) ? 32U : 16U;
const auto bits = (version >= 14U && version < 24U) ? 32U : 16U;
const auto word = bits / 8U;
const auto read_index = [&](usize off) -> uint16 {
return word == 4U ? static_cast<uint16>(read_u32(data.data() + off)) : read_u16(data.data() + off);
return word == 4U ? static_cast<uint16>(read_u32(payload.data() + off)) : read_u16(payload.data() + off);
};
out.blends.resize(area);
for (usize i = 0; i < area; ++i) out.blends[i] = read_index(p + i * word);
@@ -269,33 +212,48 @@ export namespace ra3::terrain {
p += area * word;
p += area * word; // CliffTextures (not rendered yet)
const auto chunk_end = c.offset + c.size;
const auto table = parse_textures(data, p, chunk_end, out);
parse_blend_descriptions(data, table, chunk_end, out);
const auto chunk_end = payload.size();
const auto table = parse_textures(payload, p, chunk_end, out);
parse_blend_descriptions(payload, table, chunk_end, out);
}
}
/**
* Parse the terrain chunks out of a `CkMp` (uncompressed) map payload.
*
* The container and `HeightMapData` come from `libra3assets`
* (`map_document`); `BlendTileData` (which the library does not model yet)
* is decoded here from the chunk payload the document exposes.
*
* @throws terrain_error if the chunk tree or terrain chunks are malformed.
*/
[[nodiscard]] inline auto parse_map(std::span<const uint8> ckmp) -> map_data {
const auto chunks = detail::parse_chunks(ckmp);
const auto *heightmap = detail::find(chunks, "HeightMapData");
const auto *blend = detail::find(chunks, "BlendTileData");
if (heightmap == nullptr || blend == nullptr) throw terrain_error("map has no terrain chunks");
try {
auto document = ra3::assets::map_document::parse(detail::as_bytes(ckmp));
map_data out;
detail::parse_heightmap(ckmp, *heightmap, out);
detail::parse_blend(ckmp, *blend, out);
if (const auto *water = detail::find(chunks, "GlobalWaterSettings"); water != nullptr && water->size >= 8U) {
out.has_water = detail::read_u32(ckmp.data() + water->offset) != 0U;
const auto bits = detail::read_u32(ckmp.data() + water->offset + 4U);
std::memcpy(&out.water_plane_z, &bits, sizeof(out.water_plane_z));
map_data out;
const auto height = document.height_map();
if (!height) throw terrain_error("map has no HeightMapData chunk");
out.width = height->width;
out.height = height->height;
out.border_width = height->border_width;
out.elevations.assign(height->elevations.begin(), height->elevations.end());
const auto *blend = document.find_chunk("BlendTileData");
if (blend == nullptr) throw terrain_error("map has no BlendTileData chunk");
detail::parse_blend(detail::as_u8(blend->payload), blend->version, out);
if (const auto *water = document.find_chunk("GlobalWaterSettings"); water != nullptr && water->payload.size() >= 8U) {
const auto *bytes = reinterpret_cast<const uint8 *>(water->payload.data());
out.has_water = detail::read_u32(bytes) != 0U;
const auto bits = detail::read_u32(bytes + 4U);
std::memcpy(&out.water_plane_z, &bits, sizeof(out.water_plane_z));
}
out.valid = true;
return out;
} catch (const ra3::assets::asset_error &error) {
throw terrain_error(error.what());
}
out.valid = true;
return out;
}
/** The decoded terrain textures, parallel to `map_data::textures`. */