Files
OpenRA3/src/map/ra3.map.cppm
T
EnderTheCoder b6d619254d v0.3.0: display abstraction, map-browser menu, SAGE terrain tiling/blends
- ra3.client::display: shared interactive loops; SDL and Vulkan backends
  implement only the primitives (init/present/poll_event/window_size/
  key_down/present_terrain). ra3.display picks the backend.
- Menu: maps by localized name (gamestrings.csf), red/gold theme, hover
  highlight, mouse + keyboard, wheel scroll, fullscreen and FPS/vsync
  options, loading progress bar.
- Terrain: continuous tile sampling via a texture array (REPEAT, uv =
  cell/(2*cellSize)) removes per-cell grid seams; SAGE blend ramp for
  material transitions; FPS label + top-right minimap overlays.
- Skip the skirmish sim for map views; reuse the Vulkan texture; no idle
  terrain redraw.
2026-09-20 02:20:39 +08:00

427 lines
18 KiB
C++

export module ra3.map;
import std;
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, recovered start positions and the raw `CkMp` tree. */
struct loaded_map {
map_info info;
std::vector<start_position> starts;
usize ckmp_size = 0;
std::vector<uint8> bytes; ///< The uncompressed `CkMp` payload (terrain/objects parsed from here).
};
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_;
};
/**
* Normalise a loose `.map` payload to bare `CkMp` bytes.
*
* Accepts either the raw `.big` payload (the `EAR\0` wrapper around a
* RefPack stream, as `ra3tools` extracts it), a bare RefPack stream, or an
* already-unwrapped `CkMp` tree (as our own `extract` writes).
*/
[[nodiscard]] inline auto to_ckmp(std::span<const uint8> raw) -> std::vector<uint8> {
std::span<const uint8> payload = raw;
if (raw.size() >= 8U && std::memcmp(raw.data(), "EAR\0", 4) == 0) payload = raw.subspan(8);
return fs::maybe_decompress(payload);
}
/** Recover `Player_N_Start` waypoints from raw, uncompressed `CkMp` bytes. */
[[nodiscard]] inline auto starts_from_ckmp(std::span<const uint8> ckmp) -> std::vector<start_position> {
auto starts = detail::extract_start_positions(ckmp);
if (detail::distinct_positions(starts) < 2U) starts.clear();
return starts;
}
/** Map id -> localized display name, keyed by lowercased id. */
struct map_name_table {
std::unordered_map<std::string, std::string> names;
[[nodiscard]] auto empty() const -> bool { return names.empty(); }
/** The display name for `id`, or `id` itself when the string table has none. */
[[nodiscard]] auto lookup(std::string_view id) const -> std::string {
auto key = std::string{id};
std::transform(key.begin(), key.end(), key.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (const auto it = names.find(key); it != names.end() && !it->second.empty()) return it->second;
return std::string{id};
}
};
namespace detail {
/**
* Decode one SAGE `.csf` string value.
*
* RA3's `gamestrings.csf` stores each UTF-16 code unit with the low byte
* XORed by `0xFF` (the high byte is the padding `0xFF`); undoing that
* yields the plain ASCII/UTF-8 text.
*/
[[nodiscard]] inline auto decode_csf_string(std::span<const uint8> raw) -> std::string {
std::string value;
for (usize i = 0; i + 1U < raw.size(); i += 2U) {
const auto ch = static_cast<char>(static_cast<uint8>(raw[i] ^ 0xFFU));
if (ch == '\0') break;
value.push_back(ch);
}
return value;
}
}
/**
* Parse `MAP:<id>` display names out of a SAGE `.csf` string table.
*
* The map list UI reads its labels from the install's `data\gamestrings.csf`
* under the key `MAP:<UPPERCASE_ID>` (e.g. `MAP:MAP_MP_2_FEASEL4` is
* "Battlebase Beta"). Values are byte-XORed with `0xFF`.
*/
[[nodiscard]] inline auto parse_map_names(std::span<const uint8> csf) -> map_name_table {
map_name_table table;
if (csf.size() < 24U || std::memcmp(csf.data(), " FSC", 4) != 0) return table;
const auto read = [&](usize p) -> uint32 {
return static_cast<uint32>(csf[p]) | (static_cast<uint32>(csf[p + 1U]) << 8U) | (static_cast<uint32>(csf[p + 2U]) << 16U) |
(static_cast<uint32>(csf[p + 3U]) << 24U);
};
usize pos = 24; // header: magic + version + label/string counts + 8 reserved bytes
while (pos + 4U <= csf.size()) {
if (std::memcmp(csf.data() + pos, " LBL", 4) != 0) {
++pos;
continue;
}
pos += 4;
if (pos + 4U > csf.size()) break;
const auto count = read(pos);
pos += 4;
std::vector<std::string> labels;
labels.reserve(count);
bool ok = true;
for (uint32 i = 0; i < count; ++i) {
if (pos + 4U > csf.size()) {
ok = false;
break;
}
const auto len = read(pos);
pos += 4;
if (pos + len > csf.size()) {
ok = false;
break;
}
labels.emplace_back(reinterpret_cast<const char *>(csf.data() + pos), len);
pos += len;
}
if (!ok) break;
for (const auto &label: labels) {
if (pos + 4U > csf.size() || std::memcmp(csf.data() + pos, " RTS", 4) != 0) {
ok = false;
break;
}
pos += 4;
if (pos + 4U > csf.size()) {
ok = false;
break;
}
const auto chars = read(pos);
pos += 4;
const auto bytes = static_cast<usize>(chars) * 2U;
if (pos + bytes > csf.size()) {
ok = false;
break;
}
auto value = detail::decode_csf_string(csf.subspan(pos, bytes));
pos += bytes;
constexpr std::string_view prefix = "MAP:";
if (label.size() > prefix.size() && label.compare(0, prefix.size(), prefix) == 0) {
auto id = label.substr(prefix.size());
std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (!value.empty()) table.names.try_emplace(std::move(id), std::move(value));
}
}
if (!ok) break;
}
return table;
}
/** Load display names from the install's newest English string table. */
[[nodiscard]] inline auto load_map_names_from_data_dir(const std::filesystem::path &data_dir) -> map_name_table {
std::error_code ec;
std::filesystem::path chosen;
int best_version = -1;
for (const auto &entry: std::filesystem::directory_iterator(data_dir, ec)) {
if (!entry.is_regular_file()) continue;
const auto name = entry.path().filename().string();
if (name.rfind("Lang-English", 0) != 0 || !name.ends_with(".big")) continue;
const auto mid = name.substr(12, name.size() - 16U);
int version = 0;
try {
version = std::stoi(mid);
} catch (const std::exception &) {
continue;
}
if (version > best_version) {
best_version = version;
chosen = entry.path();
}
}
if (chosen.empty()) chosen = data_dir / "English.big";
if (!std::filesystem::exists(chosen, ec)) return {};
try {
const auto archive = fs::big_archive::open(chosen);
const auto matches = archive.find("gamestrings.csf");
if (matches.empty()) return {};
return parse_map_names(archive.read(matches.front()->name, true));
} catch (const std::exception &) {
return {};
}
}
/** Read `<assets>/maps/map_names.tsv`, or any `gamestrings.csf` under `assets`. */
[[nodiscard]] inline auto load_map_names(const std::filesystem::path &assets) -> map_name_table {
const auto read_file = [](const std::filesystem::path &path) {
std::ifstream in(path, std::ios::binary);
return std::vector<uint8>{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
};
std::error_code ec;
const auto tsv = assets / "maps" / "map_names.tsv";
if (std::filesystem::exists(tsv, ec)) {
const auto bytes = read_file(tsv);
map_name_table table;
std::string text{bytes.begin(), bytes.end()};
for (std::size_t start = 0; start < text.size();) {
const auto end = text.find('\n', start);
const auto line = text.substr(start, end == std::string::npos ? std::string::npos : end - start);
start = end == std::string::npos ? text.size() : end + 1U;
const auto tab = line.find('\t');
if (tab == std::string::npos || tab == 0U) continue;
auto id = line.substr(0, tab);
std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
table.names.try_emplace(std::move(id), line.substr(tab + 1U));
}
if (!table.empty()) return table;
}
for (const auto &entry: std::filesystem::recursive_directory_iterator(assets, ec)) {
if (!entry.is_regular_file() || entry.path().filename() != "gamestrings.csf") continue;
return parse_map_names(read_file(entry.path()));
}
return {};
}
/** Serialize a name table as `id<TAB>name` lines (tabs/newlines stripped). */
[[nodiscard]] inline auto serialize_map_names(const map_name_table &table) -> std::string {
std::vector<std::string> ids;
ids.reserve(table.names.size());
for (const auto &[id, _]: table.names) ids.push_back(id);
std::sort(ids.begin(), ids.end());
std::string out;
for (const auto &id: ids) {
auto name = table.names.at(id);
std::replace(name.begin(), name.end(), '\t', ' ');
std::replace(name.begin(), name.end(), '\n', ' ');
out += id;
out += '\t';
out += name;
out += '\n';
}
return out;
}
/**
* 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 = starts_from_ckmp(ckmp);
result.bytes = std::move(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;
}
}