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: * `\.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 starts; usize ckmp_size = 0; std::vector bytes; ///< The uncompressed `CkMp` payload (terrain/objects parsed from here). }; namespace detail { [[nodiscard]] inline auto split_path(std::string_view path) -> std::vector { std::vector 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 (`...//.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 haystack, std::string_view needle) -> usize { if (needle.empty()) return 0; const auto *needle_begin = reinterpret_cast(needle.data()); const auto it = std::search(haystack.begin(), haystack.end(), needle_begin, needle_begin + needle.size()); return it == haystack.end() ? static_cast(-1) : static_cast(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 data, usize from, usize window) -> std::optional { 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 ckmp, int max_players = 8) -> std::vector { std::vector 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(-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 &starts) -> usize { std::vector> seen; for (const auto &start: starts) { const auto key = std::pair{static_cast(start.x), static_cast(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 & { 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 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 raw) -> std::vector { std::span 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 ckmp) -> std::vector { 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 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(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 raw) -> std::string { std::string value; for (usize i = 0; i + 1U < raw.size(); i += 2U) { const auto ch = static_cast(static_cast(raw[i] ^ 0xFFU)); if (ch == '\0') break; value.push_back(ch); } return value; } } /** * Parse `MAP:` 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:` (e.g. `MAP:MAP_MP_2_FEASEL4` is * "Battlebase Beta"). Values are byte-XORed with `0xFF`. */ [[nodiscard]] inline auto parse_map_names(std::span 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(csf[p]) | (static_cast(csf[p + 1U]) << 8U) | (static_cast(csf[p + 2U]) << 16U) | (static_cast(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 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(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(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(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 `/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{std::istreambuf_iterator(in), std::istreambuf_iterator()}; }; 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(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 `idname` lines (tabs/newlines stripped). */ [[nodiscard]] inline auto serialize_map_names(const map_name_table &table) -> std::string { std::vector 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 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; } }