OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish

This commit is contained in:
EnderTheCoder
2026-09-12 00:26:45 +08:00
commit 3ad380c81e
24 changed files with 2630 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
module;
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
export module ra3.map;
export import ra3.core;
export import ra3.fs;
/**
* Red Alert 3 map discovery and loading.
*
* Multiplayer maps live in `Data\MapsMultiplayer.big` as one directory per map:
* `<id>\<id>.map` plus compiled companions. The `.map` file is an `EAR\0`
* wrapper around a RefPack stream whose payload is the `CkMp` chunk tree.
*
* This module reads the archive for real, unwraps the map, and extracts the
* `Player_N_Start` waypoint coordinates that seed a skirmish. Anything beyond
* start positions (terrain, objects) is the next milestone.
*/
export namespace ra3::map {
using ra3::core::coord3d;
using ra3::core::real;
using ra3::core::uint32;
using ra3::core::uint8;
using ra3::core::usize;
/** A player start location extracted from a map's waypoints. */
struct start_position {
real x = 0.0F;
real y = 0.0F;
real z = 0.0F;
[[nodiscard]] auto as_coord() const -> coord3d { return {x, y, z}; }
};
/** Identity and sizes of one map inside an archive. */
struct map_info {
std::string id;
std::string entry;
uint32 stored_size = 0;
uint32 unpacked_size = 0;
};
/** The result of loading a map: its metadata and recovered start positions. */
struct loaded_map {
map_info info;
std::vector<start_position> starts;
usize ckmp_size = 0;
};
namespace detail {
[[nodiscard]] inline auto split_path(std::string_view path) -> std::vector<std::string> {
std::vector<std::string> parts;
usize start = 0;
while (start <= path.size()) {
const auto at = path.find_first_of("\\/", start);
const auto end = at == std::string_view::npos ? path.size() : at;
parts.emplace_back(path.substr(start, end - start));
if (at == std::string_view::npos) break;
start = at + 1;
}
return parts;
}
/** True for a main map entry (`.../<stem>/<stem>.map`). */
[[nodiscard]] inline auto is_main_map(const fs::big_entry &entry) -> bool {
const auto parts = split_path(entry.name);
if (parts.size() < 2) return false;
const auto &file = parts[parts.size() - 1];
const auto &parent = parts[parts.size() - 2];
if (file.size() < 5 || file.compare(file.size() - 4, 4, ".map") != 0) return false;
const auto stem = file.substr(0, file.size() - 4);
return stem == parent && !stem.ends_with("_edit");
}
[[nodiscard]] inline auto stem_of(std::string_view path) -> std::string {
const auto parts = split_path(path);
if (parts.empty()) return {};
auto file = parts.back();
if (file.size() > 4 && file.compare(file.size() - 4, 4, ".map") == 0) file.resize(file.size() - 4);
return file;
}
[[nodiscard]] inline auto find_bytes(std::span<const uint8> haystack, std::string_view needle) -> usize {
if (needle.empty()) return 0;
const auto *needle_begin = reinterpret_cast<const uint8 *>(needle.data());
const auto it = std::search(haystack.begin(), haystack.end(), needle_begin, needle_begin + needle.size());
return it == haystack.end() ? static_cast<usize>(-1) : static_cast<usize>(it - haystack.begin());
}
[[nodiscard]] inline auto read_le_f32(const uint8 *p) -> real {
const auto bits = fs::read_le32(p);
real value = 0.0F;
std::memcpy(&value, &bits, sizeof(value));
return value;
}
/** First plausible `(x, y, 0)` float triple at or after `from`. */
[[nodiscard]] inline auto scan_triple(std::span<const uint8> data, usize from, usize window) -> std::optional<start_position> {
const auto end = std::min(data.size(), from + window);
for (usize p = from; p + 12U <= end; ++p) {
const auto x = read_le_f32(data.data() + p);
const auto y = read_le_f32(data.data() + p + 4U);
const auto z = read_le_f32(data.data() + p + 8U);
if (z == 0.0F && std::isfinite(x) && std::isfinite(y) && x > 0.0F && x < 20000.0F && y > 0.0F && y < 20000.0F) {
return start_position{x, y, z};
}
}
return std::nullopt;
}
/** Extract `Player_1_Start` .. `Player_N_Start` waypoint coordinates. */
[[nodiscard]] inline auto extract_start_positions(std::span<const uint8> ckmp, int max_players = 8) -> std::vector<start_position> {
std::vector<start_position> result;
for (int n = 1; n <= max_players; ++n) {
const auto needle = std::string{"Player_"} + std::to_string(n) + "_Start";
usize cursor = 0;
while (cursor < ckmp.size()) {
const auto at = find_bytes(ckmp.subspan(cursor), needle);
if (at == static_cast<usize>(-1)) break;
const auto abs = cursor + at;
if (const auto triple = scan_triple(ckmp, abs + needle.size(), 128); triple) {
result.push_back(*triple);
break;
}
cursor = abs + 1;
}
}
return result;
}
/** Number of distinct integer positions (used to reject degenerate sets). */
[[nodiscard]] inline auto distinct_positions(const std::vector<start_position> &starts) -> usize {
std::vector<std::pair<int, int>> seen;
for (const auto &start: starts) {
const auto key = std::pair{static_cast<int>(start.x), static_cast<int>(start.y)};
if (std::find(seen.begin(), seen.end(), key) == seen.end()) seen.push_back(key);
}
return seen.size();
}
}
/**
* Every main map discoverable in one archive.
*/
class map_catalog {
public:
/**
* Scan an archive for main map entries.
*
* @param archive An opened `MapsMultiplayer.big` (or similar).
*/
[[nodiscard]] static auto from_archive(const fs::big_archive &archive) -> map_catalog {
map_catalog catalog;
for (const auto &entry: archive.entries()) {
if (!detail::is_main_map(entry)) continue;
map_info info;
info.entry = entry.name;
info.id = detail::stem_of(entry.name);
info.stored_size = entry.size;
info.unpacked_size = entry.size;
// The BIG4 payload is RefPack; its output is the `EAR\0` wrapper.
if (const auto prefix = archive.read_prefix(entry.name, 16); fs::is_refpack(prefix)) {
info.unpacked_size = fs::refpack_output_size(prefix);
}
catalog.maps_.push_back(std::move(info));
}
return catalog;
}
[[nodiscard]] auto maps() const -> const std::vector<map_info> & { return maps_; }
[[nodiscard]] auto size() const -> usize { return maps_.size(); }
[[nodiscard]] auto find(std::string_view id) const -> const map_info * {
for (const auto &info: maps_) {
if (info.id == id) return &info;
}
return nullptr;
}
private:
std::vector<map_info> maps_;
};
/**
* Load a map: unwrap the `EAR\0` + RefPack container and recover the
* player start waypoints.
*
* @param archive Archive that holds the map.
* @param info Map selected from a `map_catalog`.
* @throws fs::archive_error / fs::refpack_error on malformed input.
*/
[[nodiscard]] inline auto load_map(const fs::big_archive &archive, const map_info &info) -> loaded_map {
loaded_map result;
result.info = info;
// Layer 1: BIG4 payload is RefPack, yielding the `EAR\0` wrapper.
const auto wrapper = archive.read(info.entry, true);
std::span<const uint8> payload{wrapper};
if (wrapper.size() >= 8U && std::memcmp(wrapper.data(), "EAR\0", 4) == 0) payload = payload.subspan(8);
// Layer 2: the wrapper body is RefPack again, yielding the `CkMp` tree.
auto ckmp = fs::maybe_decompress(payload);
result.ckmp_size = ckmp.size();
result.starts = detail::extract_start_positions(ckmp);
// Maps that keep starts in `MPPositionList` instead of `Player_N_Start`
// waypoints yield fewer than two distinct points; signal "unknown" so
// the caller can fall back rather than spawn everyone at the origin.
if (detail::distinct_positions(result.starts) < 2U) result.starts.clear();
return result;
}
}