- 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.
427 lines
18 KiB
C++
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;
|
|
}
|
|
}
|