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.
This commit is contained in:
+217
-2
@@ -40,11 +40,12 @@ export namespace ra3::map {
|
||||
uint32 unpacked_size = 0;
|
||||
};
|
||||
|
||||
/** The result of loading a map: its metadata and recovered start positions. */
|
||||
/** 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 {
|
||||
@@ -181,6 +182,219 @@ export namespace ra3::map {
|
||||
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.
|
||||
@@ -201,7 +415,8 @@ export namespace ra3::map {
|
||||
// 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);
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user