OpenRA3 v0.0.1: C++26 modules, BIG4/RefPack reader, minimal skirmish
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
module;
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <string_view>
|
||||
|
||||
export module ra3.client;
|
||||
|
||||
import ra3.core;
|
||||
import ra3.logic;
|
||||
|
||||
/**
|
||||
* The presentation layer: an abstract output surface plus the client facade
|
||||
* that owns the simulation and drives the frame loop.
|
||||
*
|
||||
* A future W3D/D3D9 backend implements the same `display` interface; the
|
||||
* default headless backend keeps OpenRA3 runnable on a CPU-only box.
|
||||
*/
|
||||
export namespace ra3::client {
|
||||
using ra3::core::uint32;
|
||||
|
||||
/**
|
||||
* Abstract output surface presented once per logic frame.
|
||||
*/
|
||||
class display {
|
||||
public:
|
||||
virtual ~display() = default;
|
||||
|
||||
virtual auto begin_frame() -> void = 0;
|
||||
virtual auto end_frame() -> void = 0;
|
||||
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A display that renders nothing and consumes no resources.
|
||||
*/
|
||||
class headless_display final : public display {
|
||||
public:
|
||||
auto begin_frame() -> void override {}
|
||||
auto end_frame() -> void override {}
|
||||
[[nodiscard]] auto name() const -> std::string_view override { return "headless"; }
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns the simulation and its display, and drives the frame loop.
|
||||
*/
|
||||
class game_client {
|
||||
public:
|
||||
game_client() = default;
|
||||
|
||||
[[nodiscard]] auto logic() -> logic::game_logic & { return logic_; }
|
||||
[[nodiscard]] auto display() -> client::display & { return *display_; }
|
||||
|
||||
auto set_display(std::unique_ptr<client::display> value) -> void { display_ = std::move(value); }
|
||||
|
||||
/**
|
||||
* Run `frames` logic steps, bracketing each with a present.
|
||||
*
|
||||
* @param target Simulation to advance.
|
||||
* @param frames Number of 30 Hz logic frames to run.
|
||||
*/
|
||||
auto run(logic::game_logic &target, uint32 frames) -> void {
|
||||
for (uint32 i = 0; i < frames; ++i) {
|
||||
display_->begin_frame();
|
||||
target.update();
|
||||
display_->end_frame();
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
logic::game_logic logic_;
|
||||
std::unique_ptr<client::display> display_ = std::make_unique<headless_display>();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
module;
|
||||
|
||||
#include <cmath>
|
||||
#include <compare>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <limits>
|
||||
#include <optional>
|
||||
#include <ostream>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
export module ra3.core;
|
||||
|
||||
/**
|
||||
* Fundamental engine types shared by every OpenRA3 module.
|
||||
*
|
||||
* The vocabulary mirrors SAGE 2.0 (the engine Red Alert 3 runs on) as it
|
||||
* appears in the CnC Generals source, with layouts cross-checked against the
|
||||
* retail `ra3_1.12.game` binary through Ghidra.
|
||||
*/
|
||||
export namespace ra3::core {
|
||||
inline constexpr int version_major = 0;
|
||||
inline constexpr int version_minor = 0;
|
||||
inline constexpr int version_patch = 1;
|
||||
|
||||
using real = float;
|
||||
using int32 = std::int32_t;
|
||||
using uint32 = std::uint32_t;
|
||||
using uint16 = std::uint16_t;
|
||||
using uint8 = std::uint8_t;
|
||||
using usize = std::size_t;
|
||||
|
||||
/** SAGE advances the simulation on a fixed 30 Hz logic tick. */
|
||||
inline constexpr int logic_frames_per_second = 30;
|
||||
inline constexpr real logic_frame_seconds = 1.0F / static_cast<real>(logic_frames_per_second);
|
||||
|
||||
/**
|
||||
* A world-space position or direction, matching SAGE's `Coord3D`.
|
||||
*/
|
||||
struct coord3d {
|
||||
real x = 0.0F;
|
||||
real y = 0.0F;
|
||||
real z = 0.0F;
|
||||
|
||||
auto operator+(const coord3d &rhs) const -> coord3d { return {x + rhs.x, y + rhs.y, z + rhs.z}; }
|
||||
auto operator-(const coord3d &rhs) const -> coord3d { return {x - rhs.x, y - rhs.y, z - rhs.z}; }
|
||||
auto operator*(real scalar) const -> coord3d { return {x * scalar, y * scalar, z * scalar}; }
|
||||
auto operator==(const coord3d &) const -> bool = default;
|
||||
|
||||
/** Squared length, cheaper than `length()` when only comparing. */
|
||||
[[nodiscard]] auto length_squared() const -> real { return x * x + y * y + z * z; }
|
||||
|
||||
[[nodiscard]] auto length() const -> real;
|
||||
|
||||
[[nodiscard]] auto distance_to(const coord3d &other) const -> real { return (*this - other).length(); }
|
||||
|
||||
/** Unit vector, or the zero vector when this one is degenerate. */
|
||||
[[nodiscard]] auto normalized() const -> coord3d;
|
||||
};
|
||||
|
||||
/**
|
||||
* Integer grid coordinates, matching SAGE's `ICoord2D`.
|
||||
*/
|
||||
struct coord2d {
|
||||
int32 x = 0;
|
||||
int32 y = 0;
|
||||
|
||||
auto operator==(const coord2d &) const -> bool = default;
|
||||
};
|
||||
|
||||
/**
|
||||
* An 8-bit-per-channel RGB colour.
|
||||
*/
|
||||
struct rgb_color {
|
||||
uint8 r = 0;
|
||||
uint8 g = 0;
|
||||
uint8 b = 0;
|
||||
|
||||
auto operator==(const rgb_color &) const -> bool = default;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derive the 32-bit identifier SAGE associates with an ASCII name.
|
||||
*
|
||||
* The retail `NameKeyGenerator` hash is still being recovered; this
|
||||
* deterministic, case-insensitive FNV-1a stand-in keeps the interface
|
||||
* stable until exact parity is proven against the binary.
|
||||
*/
|
||||
constexpr auto make_name_key(std::string_view name) -> uint32 {
|
||||
uint32 key = 2166136261U;
|
||||
for (const auto ch: name) {
|
||||
auto c = static_cast<unsigned char>(ch);
|
||||
if (c >= 'a' && c <= 'z') c = static_cast<unsigned char>(c - 'a' + 'A');
|
||||
key ^= c;
|
||||
key *= 16777619U;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* An owned ASCII identifier carrying a cached `NameKey`.
|
||||
*/
|
||||
class ascii_string {
|
||||
public:
|
||||
ascii_string() = default;
|
||||
ascii_string(std::string_view text) : text_(text) {}
|
||||
explicit ascii_string(std::string text) : text_(std::move(text)) {}
|
||||
|
||||
[[nodiscard]] auto view() const -> std::string_view { return text_; }
|
||||
[[nodiscard]] auto empty() const -> bool { return text_.empty(); }
|
||||
[[nodiscard]] auto c_str() const -> const char * { return text_.c_str(); }
|
||||
[[nodiscard]] auto key() const -> uint32 { return make_name_key(text_); }
|
||||
|
||||
auto operator==(const ascii_string &) const -> bool = default;
|
||||
auto operator<=>(const ascii_string &) const = default;
|
||||
|
||||
friend auto operator<<(std::ostream &out, const ascii_string &value) -> std::ostream &;
|
||||
|
||||
private:
|
||||
std::string text_;
|
||||
};
|
||||
|
||||
inline auto operator<<(std::ostream &out, const ascii_string &value) -> std::ostream & {
|
||||
return out << value.text_;
|
||||
}
|
||||
|
||||
/**
|
||||
* A deterministic pseudo-random source.
|
||||
*
|
||||
* SAGE separates the logic and client random streams so replays stay in
|
||||
* sync; this is the logic-stream stand-in. The retail generator constants
|
||||
* are pending recovery, so this uses a standard 32-bit LCG.
|
||||
*/
|
||||
class random {
|
||||
public:
|
||||
explicit random(uint32 seed = 1U) : state_(seed == 0U ? 1U : seed) {}
|
||||
|
||||
auto seed(uint32 value) -> void { state_ = (value == 0U ? 1U : value); }
|
||||
[[nodiscard]] auto state() const -> uint32 { return state_; }
|
||||
|
||||
auto next_uint() -> uint32 {
|
||||
state_ = state_ * 1664525U + 1013904223U;
|
||||
return state_;
|
||||
}
|
||||
|
||||
/** Uniform integer in the inclusive range `[lo, hi]`. */
|
||||
auto next_int(int32 lo, int32 hi) -> int32 {
|
||||
if (hi <= lo) return lo;
|
||||
const auto span = static_cast<uint32>(hi - lo + 1);
|
||||
return lo + static_cast<int32>(this->next_uint() % span);
|
||||
}
|
||||
|
||||
/** Uniform real in `[0, 1)`. */
|
||||
auto next_real() -> real { return static_cast<real>(this->next_uint() >> 8) / static_cast<real>(1U << 24); }
|
||||
|
||||
private:
|
||||
uint32 state_;
|
||||
};
|
||||
|
||||
/**
|
||||
* Message types carried by the engine's `MessageStream`.
|
||||
*
|
||||
* `new_game` is confirmed against the retail binary: the skirmish BEGIN
|
||||
* handler appends type `0x2` to start a match.
|
||||
*/
|
||||
enum class message_id : uint32 {
|
||||
invalid = 0x0,
|
||||
new_game = 0x2,
|
||||
clear_game_data = 0x3,
|
||||
begin_block = 0x5,
|
||||
end_block = 0x6,
|
||||
};
|
||||
|
||||
[[nodiscard]] constexpr auto to_string(message_id id) -> std::string_view {
|
||||
switch (id) {
|
||||
case message_id::invalid:
|
||||
return "Invalid";
|
||||
case message_id::new_game:
|
||||
return "NewGame";
|
||||
case message_id::clear_game_data:
|
||||
return "ClearGameData";
|
||||
case message_id::begin_block:
|
||||
return "BeginBlock";
|
||||
case message_id::end_block:
|
||||
return "EndBlock";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* A single entry in the message stream.
|
||||
*
|
||||
* The retail `MessageStream::appendMessage` allocates a 0x74-byte node
|
||||
* (recovered at `0x0060c4a0`); this is its owning equivalent.
|
||||
*/
|
||||
struct game_message {
|
||||
message_id id = message_id::invalid;
|
||||
uint32 player_index = 0;
|
||||
std::vector<uint8> payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* A FIFO of `game_message`s drained once per logic frame.
|
||||
*
|
||||
* In the retail engine the stream is an intrusive doubly linked list
|
||||
* (head at `+0x24`, tail at `+0x28` of the stream object); this is the
|
||||
* behaviourally equivalent owning version.
|
||||
*/
|
||||
class message_stream {
|
||||
public:
|
||||
auto append(message_id id, uint32 player_index = 0U) -> game_message & {
|
||||
auto &slot = messages_.emplace_back();
|
||||
slot.id = id;
|
||||
slot.player_index = player_index;
|
||||
return slot;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto empty() const -> bool { return messages_.empty(); }
|
||||
[[nodiscard]] auto size() const -> usize { return messages_.size(); }
|
||||
[[nodiscard]] auto pending() const -> std::span<const game_message> { return messages_; }
|
||||
|
||||
auto clear() -> void { messages_.clear(); }
|
||||
|
||||
/** Remove and return the oldest message, if any. */
|
||||
auto pop() -> std::optional<game_message> {
|
||||
if (messages_.empty()) return std::nullopt;
|
||||
auto front = std::move(messages_.front());
|
||||
messages_.erase(messages_.begin());
|
||||
return front;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<game_message> messages_;
|
||||
};
|
||||
|
||||
inline auto coord3d::length() const -> real { return std::sqrt(this->length_squared()); }
|
||||
|
||||
inline auto coord3d::normalized() const -> coord3d {
|
||||
const auto len = this->length();
|
||||
if (len <= std::numeric_limits<real>::epsilon()) return {0.0F, 0.0F, 0.0F};
|
||||
return *this * (1.0F / len);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
module;
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
export module ra3.fs;
|
||||
|
||||
export import ra3.core;
|
||||
|
||||
/**
|
||||
* Reading of the retail game's on-disk assets.
|
||||
*
|
||||
* Red Alert 3 ships its data in `BIG4` archives under `<install>\Data`, with
|
||||
* individual payloads compressed by EA's RefPack codec. This module implements
|
||||
* the container and codec so OpenRA3 can read a user's own installation.
|
||||
*
|
||||
* No game data is ever written into the repository; callers point the loader at
|
||||
* their local install (`--game-dir` / `RA3_GAME_DIR`, default `C:\Red Alert 3`).
|
||||
*/
|
||||
export namespace ra3::fs {
|
||||
using ra3::core::uint32;
|
||||
using ra3::core::uint8;
|
||||
using ra3::core::usize;
|
||||
|
||||
/** Thrown when a `BIG4` archive is malformed or an entry is missing. */
|
||||
class archive_error : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
/** Thrown when a RefPack stream is malformed. */
|
||||
class refpack_error : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
inline constexpr std::array<uint8, 4> big_magic{'B', 'I', 'G', '4'};
|
||||
inline constexpr uint8 refpack_mask = 0x3EU;
|
||||
inline constexpr uint8 refpack_magic2 = 0xFBU;
|
||||
|
||||
/** One file inside a `BIG4` archive. */
|
||||
struct big_entry {
|
||||
std::string name;
|
||||
uint32 offset = 0;
|
||||
uint32 size = 0;
|
||||
};
|
||||
|
||||
/** True when `data` starts with a RefPack header (`0b??010000`, `0xFB`). */
|
||||
[[nodiscard]] inline auto is_refpack(std::span<const uint8> data) -> bool {
|
||||
return data.size() >= 2 && (data[0] & refpack_mask) == 0x10U && data[1] == refpack_magic2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress an EA RefPack stream.
|
||||
*
|
||||
* @param data Compressed stream, starting at the header byte.
|
||||
* @return The decompressed bytes.
|
||||
* @throws refpack_error if the stream is malformed or the length disagrees.
|
||||
*/
|
||||
[[nodiscard]] inline auto refpack_decompress(std::span<const uint8> data) -> std::vector<uint8> {
|
||||
if (!is_refpack(data)) throw refpack_error("not a RefPack stream");
|
||||
|
||||
usize pos = 0;
|
||||
const auto header = data[pos++];
|
||||
const bool large_files = (header & 0x80U) != 0;
|
||||
const bool compressed_size_present = (header & 0x01U) != 0;
|
||||
pos++; // 0xFB
|
||||
|
||||
const usize size_bytes = large_files ? 4U : 3U;
|
||||
auto read_size = [&]() -> uint32 {
|
||||
uint32 value = 0;
|
||||
for (usize i = 0; i < size_bytes; ++i) {
|
||||
if (pos >= data.size()) throw refpack_error("truncated RefPack size field");
|
||||
value = (value << 8) | data[pos++];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
if (compressed_size_present) (void)read_size();
|
||||
const auto out_len = read_size();
|
||||
|
||||
std::vector<uint8> out;
|
||||
out.reserve(out_len);
|
||||
|
||||
auto copy_literals = [&](usize count) {
|
||||
if (pos + count > data.size()) throw refpack_error("truncated RefPack literals");
|
||||
out.insert(out.end(), data.begin() + static_cast<std::ptrdiff_t>(pos), data.begin() + static_cast<std::ptrdiff_t>(pos + count));
|
||||
pos += count;
|
||||
};
|
||||
|
||||
auto copy_reference = [&](usize length, usize distance) {
|
||||
if (distance == 0 || distance > out.size()) throw refpack_error("RefPack back-reference out of range");
|
||||
usize start = out.size() - distance;
|
||||
for (usize i = 0; i < length; ++i) out.push_back(out[start + i]);
|
||||
};
|
||||
|
||||
while (pos < data.size()) {
|
||||
const auto cmd = data[pos++];
|
||||
if ((cmd & 0x80U) == 0) { // 2-byte command
|
||||
if (pos >= data.size()) throw refpack_error("truncated 2-byte command");
|
||||
const auto b2 = data[pos++];
|
||||
copy_literals(cmd & 0x03U);
|
||||
copy_reference(((cmd & 0x1CU) >> 2) + 3, ((cmd & 0x60U) << 3) + b2 + 1);
|
||||
} else if ((cmd & 0x40U) == 0) { // 3-byte command
|
||||
if (pos + 1 >= data.size()) throw refpack_error("truncated 3-byte command");
|
||||
const auto b2 = data[pos];
|
||||
const auto b3 = data[pos + 1];
|
||||
pos += 2;
|
||||
copy_literals((b2 & 0xC0U) >> 6);
|
||||
copy_reference((cmd & 0x3FU) + 4, ((b2 & 0x3FU) << 8) + b3 + 1);
|
||||
} else if ((cmd & 0x20U) == 0) { // 4-byte command
|
||||
if (pos + 2 >= data.size()) throw refpack_error("truncated 4-byte command");
|
||||
const auto b2 = data[pos];
|
||||
const auto b3 = data[pos + 1];
|
||||
const auto b4 = data[pos + 2];
|
||||
pos += 3;
|
||||
copy_literals(cmd & 0x03U);
|
||||
copy_reference(((cmd & 0x0CU) << 6) + b4 + 5, ((cmd & 0x10U) << 12) + (b2 << 8) + b3 + 1);
|
||||
} else if (cmd < 0xFCU) { // long literal run
|
||||
copy_literals(((cmd & 0x1FU) + 1) << 2);
|
||||
} else { // stop
|
||||
copy_literals(cmd & 0x03U);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (out.size() != out_len) throw refpack_error("RefPack length mismatch");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Decompress `data` when it is RefPack, otherwise copy it unchanged. */
|
||||
[[nodiscard]] inline auto maybe_decompress(std::span<const uint8> data) -> std::vector<uint8> {
|
||||
if (is_refpack(data)) return refpack_decompress(data);
|
||||
return {data.begin(), data.end()};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the declared output size from a RefPack header without decompressing.
|
||||
*
|
||||
* @throws refpack_error if the stream is malformed.
|
||||
*/
|
||||
[[nodiscard]] inline auto refpack_output_size(std::span<const uint8> data) -> uint32 {
|
||||
if (!is_refpack(data)) throw refpack_error("not a RefPack stream");
|
||||
usize pos = 0;
|
||||
const auto header = data[pos++];
|
||||
const bool large_files = (header & 0x80U) != 0;
|
||||
const bool compressed_size_present = (header & 0x01U) != 0;
|
||||
pos++; // 0xFB
|
||||
const usize size_bytes = large_files ? 4U : 3U;
|
||||
auto read_size = [&]() -> uint32 {
|
||||
uint32 value = 0;
|
||||
for (usize i = 0; i < size_bytes; ++i) {
|
||||
if (pos >= data.size()) throw refpack_error("truncated RefPack size field");
|
||||
value = (value << 8) | data[pos++];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
if (compressed_size_present) (void)read_size();
|
||||
return read_size();
|
||||
}
|
||||
|
||||
[[nodiscard]] constexpr auto read_be32(const uint8 *p) -> uint32 {
|
||||
return (static_cast<uint32>(p[0]) << 24U) | (static_cast<uint32>(p[1]) << 16U) | (static_cast<uint32>(p[2]) << 8U) | static_cast<uint32>(p[3]);
|
||||
}
|
||||
|
||||
[[nodiscard]] constexpr auto read_le32(const uint8 *p) -> uint32 {
|
||||
return static_cast<uint32>(p[0]) | (static_cast<uint32>(p[1]) << 8U) | (static_cast<uint32>(p[2]) << 16U) | (static_cast<uint32>(p[3]) << 24U);
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed `BIG4` archive. Only the index is held in memory; payloads are
|
||||
* read from disk on demand so multi-hundred-megabyte archives stay cheap.
|
||||
*/
|
||||
class big_archive {
|
||||
public:
|
||||
/**
|
||||
* Parse the index of a `BIG4` archive.
|
||||
*
|
||||
* @param path Archive path.
|
||||
* @throws archive_error if the file is missing, not `BIG4`, or truncated.
|
||||
*/
|
||||
[[nodiscard]] static auto open(const std::filesystem::path &path) -> big_archive {
|
||||
big_archive archive;
|
||||
archive.path_ = path;
|
||||
std::error_code ec;
|
||||
archive.file_size_ = static_cast<usize>(std::filesystem::file_size(path, ec));
|
||||
if (ec) throw archive_error("cannot stat archive: " + path.string());
|
||||
|
||||
std::ifstream in(path, std::ios::binary);
|
||||
if (!in) throw archive_error("cannot open archive: " + path.string());
|
||||
|
||||
std::array<uint8, 16> header{};
|
||||
in.read(reinterpret_cast<char *>(header.data()), static_cast<std::streamsize>(header.size()));
|
||||
if (!in || std::memcmp(header.data(), big_magic.data(), big_magic.size()) != 0) throw archive_error("not a BIG4 archive: " + path.string());
|
||||
|
||||
const auto count = read_be32(header.data() + 8);
|
||||
|
||||
// Read the variable-length index, growing the window until parsed.
|
||||
usize window = std::min(archive.file_size_, std::max<usize>(1U << 20U, static_cast<usize>(count) * 256U));
|
||||
std::vector<uint8> index;
|
||||
for (;;) {
|
||||
index.resize(window);
|
||||
in.clear();
|
||||
in.seekg(0);
|
||||
in.read(reinterpret_cast<char *>(index.data()), static_cast<std::streamsize>(window));
|
||||
const auto got = static_cast<usize>(in.gcount());
|
||||
index.resize(got);
|
||||
|
||||
archive.entries_.clear();
|
||||
archive.entries_.reserve(count);
|
||||
usize pos = 16;
|
||||
bool complete = true;
|
||||
for (uint32 i = 0; i < count; ++i) {
|
||||
if (pos + 8 > index.size()) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
big_entry entry;
|
||||
entry.offset = read_be32(index.data() + pos);
|
||||
entry.size = read_be32(index.data() + pos + 4);
|
||||
pos += 8;
|
||||
const auto *begin = reinterpret_cast<const char *>(index.data() + pos);
|
||||
const auto *end = reinterpret_cast<const char *>(std::memchr(begin, '\0', index.size() - pos));
|
||||
if (end == nullptr) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
entry.name.assign(begin, end);
|
||||
pos += static_cast<usize>(end - begin) + 1U;
|
||||
archive.entries_.push_back(std::move(entry));
|
||||
}
|
||||
if (complete) break;
|
||||
if (window >= archive.file_size_) throw archive_error("truncated BIG4 index: " + path.string());
|
||||
window = std::min(archive.file_size_, window * 2U);
|
||||
}
|
||||
|
||||
archive.index_.reserve(archive.entries_.size());
|
||||
for (usize i = 0; i < archive.entries_.size(); ++i) archive.index_.emplace(archive.entries_[i].name, i);
|
||||
return archive;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto path() const -> const std::filesystem::path & { return path_; }
|
||||
[[nodiscard]] auto entries() const -> const std::vector<big_entry> & { return entries_; }
|
||||
[[nodiscard]] auto size() const -> usize { return entries_.size(); }
|
||||
[[nodiscard]] auto contains(std::string_view name) const -> bool { return index_.contains(std::string{name}); }
|
||||
|
||||
/** Entry names whose path contains `needle`, in index order. */
|
||||
[[nodiscard]] auto find(std::string_view needle) const -> std::vector<const big_entry *> {
|
||||
std::vector<const big_entry *> matches;
|
||||
for (const auto &entry: entries_) {
|
||||
if (entry.name.find(needle) != std::string::npos) matches.push_back(&entry);
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an entry's payload.
|
||||
*
|
||||
* @param name Entry name (backslash-separated, case-sensitive).
|
||||
* @param decompress RefPack-decompress the payload when true.
|
||||
* @throws archive_error if the entry is missing or unreadable.
|
||||
*/
|
||||
[[nodiscard]] auto read(std::string_view name, bool decompress = true) const -> std::vector<uint8> {
|
||||
const auto it = index_.find(std::string{name});
|
||||
if (it == index_.end()) throw archive_error("no such entry: " + std::string{name});
|
||||
const auto &entry = entries_[it->second];
|
||||
|
||||
std::ifstream in(path_, std::ios::binary);
|
||||
if (!in) throw archive_error("cannot open archive: " + path_.string());
|
||||
in.seekg(static_cast<std::streamoff>(entry.offset));
|
||||
std::vector<uint8> raw(entry.size);
|
||||
in.read(reinterpret_cast<char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
|
||||
if (!in) throw archive_error("short read for entry: " + entry.name);
|
||||
return decompress ? maybe_decompress(raw) : raw;
|
||||
}
|
||||
|
||||
/** Read the first `count` stored bytes of an entry (no decompression). */
|
||||
[[nodiscard]] auto read_prefix(std::string_view name, usize count) const -> std::vector<uint8> {
|
||||
const auto it = index_.find(std::string{name});
|
||||
if (it == index_.end()) throw archive_error("no such entry: " + std::string{name});
|
||||
const auto &entry = entries_[it->second];
|
||||
|
||||
std::ifstream in(path_, std::ios::binary);
|
||||
if (!in) throw archive_error("cannot open archive: " + path_.string());
|
||||
in.seekg(static_cast<std::streamoff>(entry.offset));
|
||||
const auto want = std::min(count, static_cast<usize>(entry.size));
|
||||
std::vector<uint8> raw(want);
|
||||
in.read(reinterpret_cast<char *>(raw.data()), static_cast<std::streamsize>(raw.size()));
|
||||
raw.resize(static_cast<usize>(in.gcount()));
|
||||
return raw;
|
||||
}
|
||||
|
||||
private:
|
||||
big_archive() = default;
|
||||
|
||||
std::filesystem::path path_;
|
||||
usize file_size_ = 0;
|
||||
std::vector<big_entry> entries_;
|
||||
std::unordered_map<std::string, usize> index_;
|
||||
};
|
||||
|
||||
/**
|
||||
* Locate a Red Alert 3 installation.
|
||||
*
|
||||
* Resolution order: the explicit argument, then `$RA3_GAME_DIR`, then the
|
||||
* default `C:\Red Alert 3`. A directory qualifies only if it has a `Data`
|
||||
* subdirectory.
|
||||
*
|
||||
* @return The install root, or `std::nullopt` when none is found.
|
||||
*/
|
||||
[[nodiscard]] inline auto find_game_dir(const std::optional<std::filesystem::path> &explicit_dir = std::nullopt) -> std::optional<std::filesystem::path> {
|
||||
auto qualifies = [](const std::filesystem::path &candidate) {
|
||||
std::error_code ec;
|
||||
return !candidate.empty() && std::filesystem::is_directory(candidate / "Data", ec);
|
||||
};
|
||||
|
||||
if (explicit_dir && qualifies(*explicit_dir)) return explicit_dir;
|
||||
if (const auto *env = std::getenv("RA3_GAME_DIR"); env != nullptr && *env != '\0') {
|
||||
const std::filesystem::path candidate{env};
|
||||
if (qualifies(candidate)) return candidate;
|
||||
}
|
||||
const std::filesystem::path default_dir{"C:/Red Alert 3"};
|
||||
if (qualifies(default_dir)) return default_dir;
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
module;
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
export module ra3.game;
|
||||
|
||||
import ra3.core;
|
||||
import ra3.logic;
|
||||
|
||||
/**
|
||||
* Red Alert 3 game definitions: the three base-game sides and the data that
|
||||
* seeds a skirmish.
|
||||
*
|
||||
* The `faction` values are recovered from the retail skirmish setup object,
|
||||
* where a slot stores its side at `slot + 0x18`.
|
||||
*/
|
||||
export namespace ra3::game {
|
||||
using ra3::core::int32;
|
||||
using ra3::core::uint32;
|
||||
|
||||
/**
|
||||
* Faction bit flags as stored by the retail skirmish setup.
|
||||
*/
|
||||
enum class faction : uint32 {
|
||||
none = 0U,
|
||||
empire = 2U,
|
||||
allied = 4U,
|
||||
random = 7U,
|
||||
soviet = 8U,
|
||||
};
|
||||
|
||||
[[nodiscard]] constexpr auto to_string(faction value) -> std::string_view {
|
||||
switch (value) {
|
||||
case faction::empire:
|
||||
return "Empire";
|
||||
case faction::allied:
|
||||
return "Allied";
|
||||
case faction::soviet:
|
||||
return "Soviet";
|
||||
case faction::random:
|
||||
return "Random";
|
||||
case faction::none:
|
||||
return "None";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Static description of a playable side, the OpenRA3 analogue of a
|
||||
* `PlayerTemplate` entry.
|
||||
*/
|
||||
struct player_template {
|
||||
std::string name;
|
||||
faction side = faction::none;
|
||||
int32 starting_money = 10000;
|
||||
};
|
||||
|
||||
/**
|
||||
* The three base-game sides. Skirmish defaults to 10,000 credits, matching
|
||||
* the retail setup object at `setup + 0x64`.
|
||||
*/
|
||||
[[nodiscard]] inline auto base_templates() -> std::vector<player_template> {
|
||||
return {
|
||||
{"Allied", faction::allied, 10000},
|
||||
{"Soviet", faction::soviet, 10000},
|
||||
{"Empire", faction::empire, 10000},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
module;
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
export module ra3.logic;
|
||||
|
||||
import ra3.core;
|
||||
|
||||
/**
|
||||
* The deterministic simulation layer: objects, players, teams, spatial
|
||||
* partitioning and the 30 Hz game loop.
|
||||
*
|
||||
* This is the OpenRA3 counterpart of SAGE's `GameLogic` subsystem. Addresses
|
||||
* cited in comments come from the retail `ra3_1.12.game` binary (image base
|
||||
* `0x400000`); they are the anchors used to keep this reconstruction honest.
|
||||
*/
|
||||
export namespace ra3::logic {
|
||||
using ra3::core::coord3d;
|
||||
using ra3::core::int32;
|
||||
using ra3::core::message_id;
|
||||
using ra3::core::message_stream;
|
||||
using ra3::core::random;
|
||||
using ra3::core::real;
|
||||
using ra3::core::uint32;
|
||||
using ra3::core::uint8;
|
||||
using ra3::core::usize;
|
||||
|
||||
using object_id = uint32;
|
||||
|
||||
/**
|
||||
* Broad classification of a game object.
|
||||
*/
|
||||
enum class object_kind : uint8 {
|
||||
invalid = 0,
|
||||
infantry,
|
||||
vehicle,
|
||||
aircraft,
|
||||
structure,
|
||||
projectile,
|
||||
};
|
||||
|
||||
/**
|
||||
* A thing the partition manager can index (SAGE `Thing`).
|
||||
*/
|
||||
class thing {
|
||||
public:
|
||||
explicit thing(object_id id) : id_(id) {}
|
||||
virtual ~thing() = default;
|
||||
|
||||
[[nodiscard]] auto id() const -> object_id { return id_; }
|
||||
[[nodiscard]] auto position() const -> const coord3d & { return position_; }
|
||||
auto set_position(const coord3d &value) -> void { position_ = value; }
|
||||
|
||||
/** Bounding radius used by the spatial partition. */
|
||||
[[nodiscard]] virtual auto radius() const -> real { return 0.0F; }
|
||||
|
||||
protected:
|
||||
object_id id_;
|
||||
coord3d position_;
|
||||
};
|
||||
|
||||
class object;
|
||||
|
||||
/**
|
||||
* A pluggable behaviour ticked every logic frame (SAGE `UpdateModule`).
|
||||
*/
|
||||
class update_module {
|
||||
public:
|
||||
virtual ~update_module() = default;
|
||||
|
||||
virtual auto update(object &owner) -> void = 0;
|
||||
[[nodiscard]] virtual auto name() const -> std::string_view = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* A simulation entity (SAGE `Object`): identity, ownership, health and a
|
||||
* list of behaviour modules.
|
||||
*/
|
||||
class object : public thing {
|
||||
public:
|
||||
object(object_id id, object_kind kind, uint32 owner_player) : thing(id), kind_(kind), owner_(owner_player) {}
|
||||
|
||||
[[nodiscard]] auto kind() const -> object_kind { return kind_; }
|
||||
[[nodiscard]] auto owner() const -> uint32 { return owner_; }
|
||||
|
||||
[[nodiscard]] auto health() const -> real { return health_; }
|
||||
auto set_health(real value) -> void { health_ = value; }
|
||||
auto damage(real amount) -> void { health_ = health_ > amount ? health_ - amount : 0.0F; }
|
||||
[[nodiscard]] auto destroyed() const -> bool { return health_ <= 0.0F; }
|
||||
|
||||
[[nodiscard]] auto radius() const -> real override { return radius_; }
|
||||
auto set_radius(real value) -> void { radius_ = value; }
|
||||
|
||||
auto add_module(std::unique_ptr<update_module> module) -> void { modules_.push_back(std::move(module)); }
|
||||
[[nodiscard]] auto module_count() const -> usize { return modules_.size(); }
|
||||
|
||||
/** Tick every attached behaviour module. */
|
||||
auto update() -> void {
|
||||
for (const auto &module: modules_) module->update(*this);
|
||||
}
|
||||
|
||||
private:
|
||||
object_kind kind_ = object_kind::invalid;
|
||||
uint32 owner_ = 0U;
|
||||
real health_ = 1.0F;
|
||||
real radius_ = 0.0F;
|
||||
std::vector<std::unique_ptr<update_module>> modules_;
|
||||
};
|
||||
|
||||
/**
|
||||
* A participant in a match.
|
||||
*
|
||||
* The retail `Player` keeps money behind a `std::vector<Money*>` at
|
||||
* `+0xe4` and power at `+0x74`; the fields here collapse those into plain
|
||||
* values until the pointer indirection matters.
|
||||
*/
|
||||
class player {
|
||||
public:
|
||||
player(uint32 index, std::string name) : index_(index), name_(std::move(name)) {}
|
||||
|
||||
[[nodiscard]] auto index() const -> uint32 { return index_; }
|
||||
[[nodiscard]] auto name() const -> const std::string & { return name_; }
|
||||
|
||||
[[nodiscard]] auto money() const -> int32 { return money_; }
|
||||
auto set_money(int32 value) -> void { money_ = value < 0 ? 0 : value; }
|
||||
auto add_money(int32 delta) -> void { this->set_money(money_ + delta); }
|
||||
|
||||
[[nodiscard]] auto power_produced() const -> int32 { return power_produced_; }
|
||||
[[nodiscard]] auto power_consumed() const -> int32 { return power_consumed_; }
|
||||
auto set_power(int32 produced, int32 consumed) -> void {
|
||||
power_produced_ = produced;
|
||||
power_consumed_ = consumed;
|
||||
}
|
||||
|
||||
/** Faction bit flag (see `ra3::game::faction`). */
|
||||
[[nodiscard]] auto faction() const -> uint32 { return faction_; }
|
||||
auto set_faction(uint32 value) -> void { faction_ = value; }
|
||||
|
||||
[[nodiscard]] auto human() const -> bool { return human_; }
|
||||
auto set_human(bool value) -> void { human_ = value; }
|
||||
|
||||
private:
|
||||
uint32 index_;
|
||||
std::string name_;
|
||||
int32 money_ = 0;
|
||||
int32 power_produced_ = 0;
|
||||
int32 power_consumed_ = 0;
|
||||
uint32 faction_ = 0U;
|
||||
bool human_ = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* The ordered set of players in a match (SAGE `PlayerList`).
|
||||
*
|
||||
* The retail list materialises an inline array of 20 `Player*` starting at
|
||||
* `+0x30`; this owns them instead.
|
||||
*/
|
||||
class player_list {
|
||||
public:
|
||||
auto add(std::unique_ptr<player> value) -> player & {
|
||||
auto &ref = *value;
|
||||
players_.push_back(std::move(value));
|
||||
return ref;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto size() const -> usize { return players_.size(); }
|
||||
[[nodiscard]] auto at(usize index) -> player & { return *players_.at(index); }
|
||||
[[nodiscard]] auto at(usize index) const -> const player & { return *players_.at(index); }
|
||||
|
||||
/** First human-controlled player, or `nullptr` in an all-AI match. */
|
||||
[[nodiscard]] auto human() -> player * {
|
||||
for (const auto &entry: players_) {
|
||||
if (entry->human()) return entry.get();
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<std::unique_ptr<player>> players_;
|
||||
};
|
||||
|
||||
/**
|
||||
* A spatial index over `Thing`s, bucketed on a fixed-size grid.
|
||||
*
|
||||
* A stand-in for SAGE's `PartitionManager`, which indexes the world into
|
||||
* cells and answers proximity queries.
|
||||
*/
|
||||
class partition_manager {
|
||||
public:
|
||||
explicit partition_manager(real cell_size = 40.0F) : cell_size_(cell_size) {}
|
||||
|
||||
auto insert(thing &value) -> void { cells_[this->cell_of(value.position())].push_back(&value); }
|
||||
auto clear() -> void { cells_.clear(); }
|
||||
[[nodiscard]] auto cell_count() const -> usize { return cells_.size(); }
|
||||
|
||||
/** Non-owning view of everything bucketed with `position`. */
|
||||
[[nodiscard]] auto things_at(const coord3d &position) -> std::span<thing *> {
|
||||
const auto it = cells_.find(this->cell_of(position));
|
||||
if (it == cells_.end()) return {};
|
||||
return it->second;
|
||||
}
|
||||
|
||||
private:
|
||||
struct cell_key {
|
||||
int32 x = 0;
|
||||
int32 y = 0;
|
||||
auto operator==(const cell_key &) const -> bool = default;
|
||||
};
|
||||
|
||||
struct cell_hash {
|
||||
auto operator()(const cell_key &key) const -> usize {
|
||||
return (static_cast<usize>(static_cast<uint32>(key.x)) << 32U) ^ static_cast<uint32>(key.y);
|
||||
}
|
||||
};
|
||||
|
||||
[[nodiscard]] auto cell_of(const coord3d &position) const -> cell_key {
|
||||
return {static_cast<int32>(position.x / cell_size_), static_cast<int32>(position.y / cell_size_)};
|
||||
}
|
||||
|
||||
real cell_size_;
|
||||
std::unordered_map<cell_key, std::vector<thing *>, cell_hash> cells_;
|
||||
};
|
||||
|
||||
/**
|
||||
* The simulation root and frame driver (SAGE `GameLogic`, singleton at
|
||||
* `[0x00cd8ce4]` in the retail binary).
|
||||
*
|
||||
* `frame()` corresponds to the tick counter at `TheGameLogic + 0x50`, and
|
||||
* `starting()` to the guard flag at `+0xa7` that the retail start routine
|
||||
* raises while subsystems initialise.
|
||||
*/
|
||||
class game_logic {
|
||||
public:
|
||||
game_logic() = default;
|
||||
|
||||
[[nodiscard]] auto frame() const -> uint32 { return frame_; }
|
||||
[[nodiscard]] auto starting() const -> bool { return starting_; }
|
||||
|
||||
[[nodiscard]] auto players() -> player_list & { return players_; }
|
||||
[[nodiscard]] auto partition() -> partition_manager & { return partition_; }
|
||||
[[nodiscard]] auto messages() -> message_stream & { return messages_; }
|
||||
[[nodiscard]] auto random() -> ra3::core::random & { return random_; }
|
||||
|
||||
/**
|
||||
* First half of starting a match: reset subsystems and seed the random
|
||||
* stream. Mirrors the retail reset that runs before the message pump
|
||||
* drains (the retail `startNewGame` raises `TheGameLogic + 0xa7`).
|
||||
*
|
||||
* @param seed Deterministic seed for the logic random stream.
|
||||
*/
|
||||
auto prepare_new_game(uint32 seed) -> void {
|
||||
frame_ = 0U;
|
||||
starting_ = true;
|
||||
partition_.clear();
|
||||
objects_.clear();
|
||||
messages_.clear();
|
||||
random_.seed(seed);
|
||||
}
|
||||
|
||||
/** Second half: emit `new_game` so the pump starts the match. */
|
||||
auto start_new_game() -> void {
|
||||
messages_.append(message_id::new_game, 0U);
|
||||
starting_ = false;
|
||||
}
|
||||
|
||||
auto add_object(std::unique_ptr<object> value) -> object & {
|
||||
auto &ref = *value;
|
||||
partition_.insert(ref);
|
||||
objects_.push_back(std::move(value));
|
||||
return ref;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto object_count() const -> usize { return objects_.size(); }
|
||||
|
||||
/**
|
||||
* Advance the simulation by exactly one 30 Hz logic frame: drain the
|
||||
* message stream, tick every object, then bump the frame counter.
|
||||
*/
|
||||
auto update() -> void {
|
||||
while (const auto message = messages_.pop()) {
|
||||
(void)message;
|
||||
}
|
||||
for (const auto &entry: objects_) entry->update();
|
||||
++frame_;
|
||||
}
|
||||
|
||||
private:
|
||||
uint32 frame_ = 0U;
|
||||
bool starting_ = false;
|
||||
player_list players_;
|
||||
partition_manager partition_;
|
||||
message_stream messages_;
|
||||
ra3::core::random random_{1U};
|
||||
std::vector<std::unique_ptr<object>> objects_;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Umbrella module re-exporting the whole OpenRA3 SDK.
|
||||
*
|
||||
* Consumers that want everything can `import ra3;`; narrower targets should
|
||||
* import only the module they need.
|
||||
*/
|
||||
export module ra3;
|
||||
|
||||
export import ra3.core;
|
||||
export import ra3.logic;
|
||||
export import ra3.client;
|
||||
export import ra3.game;
|
||||
export import ra3.fs;
|
||||
export import ra3.map;
|
||||
export import ra3.skirmish;
|
||||
@@ -0,0 +1,371 @@
|
||||
module;
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
export module ra3.skirmish;
|
||||
|
||||
export import ra3.core;
|
||||
export import ra3.map;
|
||||
export import ra3.game;
|
||||
import ra3.logic;
|
||||
|
||||
/**
|
||||
* A minimal, headless skirmish simulation.
|
||||
*
|
||||
* Two sides start on a real map's waypoint positions, earn credits, train
|
||||
* units and fight until one side's base is destroyed. The simulation is fully
|
||||
* deterministic: fixed 30 Hz steps, no wall-clock, and a seeded logic random
|
||||
* stream. There is no networking, no online service and no EA account - this is
|
||||
* offline skirmish only.
|
||||
*/
|
||||
export namespace ra3::skirmish {
|
||||
using ra3::core::coord3d;
|
||||
using ra3::core::int32;
|
||||
using ra3::core::real;
|
||||
using ra3::core::uint32;
|
||||
using ra3::core::uint8;
|
||||
using ra3::core::usize;
|
||||
|
||||
/** Broad unit role. */
|
||||
enum class unit_class : uint8 { harvester, infantry, tank, base };
|
||||
|
||||
[[nodiscard]] constexpr auto to_string(unit_class value) -> std::string_view {
|
||||
switch (value) {
|
||||
case unit_class::harvester:
|
||||
return "Harvester";
|
||||
case unit_class::infantry:
|
||||
return "Infantry";
|
||||
case unit_class::tank:
|
||||
return "Tank";
|
||||
case unit_class::base:
|
||||
return "Base";
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Static combat/economy stats for a unit class.
|
||||
*
|
||||
* These are OpenRA3's own balance values (the retail numbers live in
|
||||
* compiled assets that are not parsed yet), chosen so a skirmish resolves.
|
||||
*/
|
||||
struct unit_type {
|
||||
unit_class cls = unit_class::infantry;
|
||||
std::string_view name;
|
||||
real max_health = 1.0F;
|
||||
real speed = 0.0F;
|
||||
real weapon_damage = 0.0F;
|
||||
real weapon_range = 0.0F;
|
||||
real weapon_cooldown = 1.0F;
|
||||
int32 cost = 0;
|
||||
real build_time = 1.0F;
|
||||
bool mobile = false;
|
||||
bool produces = false;
|
||||
};
|
||||
|
||||
inline constexpr unit_type harvester_type{unit_class::harvester, "Harvester", 300.0F, 10.0F, 0.0F, 0.0F, 0.0F, 1400, 8.0F, true, false};
|
||||
inline constexpr unit_type infantry_type{unit_class::infantry, "Infantry", 120.0F, 12.0F, 8.0F, 55.0F, 1.0F, 150, 3.0F, true, false};
|
||||
inline constexpr unit_type tank_type{unit_class::tank, "Tank", 480.0F, 9.0F, 30.0F, 70.0F, 1.6F, 900, 8.0F, true, false};
|
||||
inline constexpr unit_type base_type{unit_class::base, "Base", 2500.0F, 0.0F, 20.0F, 90.0F, 2.0F, 0, 0.0F, false, true};
|
||||
|
||||
[[nodiscard]] constexpr auto unit_stats(unit_class value) -> const unit_type & {
|
||||
switch (value) {
|
||||
case unit_class::harvester:
|
||||
return harvester_type;
|
||||
case unit_class::infantry:
|
||||
return infantry_type;
|
||||
case unit_class::tank:
|
||||
return tank_type;
|
||||
case unit_class::base:
|
||||
return base_type;
|
||||
}
|
||||
return infantry_type;
|
||||
}
|
||||
|
||||
/** A live unit instance. */
|
||||
struct unit {
|
||||
uint32 id = 0;
|
||||
uint32 owner = 0;
|
||||
unit_class cls = unit_class::infantry;
|
||||
coord3d position{};
|
||||
real health = 0.0F;
|
||||
real max_health = 0.0F;
|
||||
real cooldown = 0.0F;
|
||||
bool alive = true;
|
||||
};
|
||||
|
||||
/** Per-player state for the match. */
|
||||
struct player_state {
|
||||
uint32 index = 0;
|
||||
std::string name;
|
||||
game::faction side = game::faction::none;
|
||||
bool human = false;
|
||||
int32 money = 0;
|
||||
real money_accumulator = 0.0F;
|
||||
coord3d start{};
|
||||
uint32 base_unit = 0;
|
||||
real build_timer = 0.0F;
|
||||
int32 kills = 0;
|
||||
int32 losses = 0;
|
||||
};
|
||||
|
||||
/** Skirmish parameters. */
|
||||
struct match_config {
|
||||
std::string map_id = "builtin";
|
||||
int32 starting_money = 10000;
|
||||
uint32 seed = 1;
|
||||
uint32 max_frames = 30U * 60U * 15U;
|
||||
real income_per_second = 25.0F;
|
||||
real harvester_income_per_second = 15.0F;
|
||||
};
|
||||
|
||||
/** Outcome of a completed (or capped) match. */
|
||||
struct match_result {
|
||||
bool decided = false;
|
||||
int32 winner = -1;
|
||||
uint32 frames = 0;
|
||||
int32 units_left[2] = {0, 0};
|
||||
int32 kills[2] = {0, 0};
|
||||
int32 losses[2] = {0, 0};
|
||||
};
|
||||
|
||||
/** Fallback start positions when a map yields none (CI, no game data). */
|
||||
[[nodiscard]] inline auto builtin_start_positions() -> std::vector<ra3::map::start_position> {
|
||||
return {{300.0F, 300.0F, 0.0F}, {1500.0F, 1500.0F, 0.0F}};
|
||||
}
|
||||
|
||||
/**
|
||||
* A running skirmish. Construct with `create`, then `run` or `step`.
|
||||
*/
|
||||
class skirmish_match {
|
||||
public:
|
||||
/**
|
||||
* Set up a two-player match.
|
||||
*
|
||||
* @param config Match parameters.
|
||||
* @param starts Player start positions (at least two recommended).
|
||||
*/
|
||||
[[nodiscard]] static auto create(const match_config &config, std::span<const ra3::map::start_position> starts) -> skirmish_match {
|
||||
skirmish_match match;
|
||||
match.config_ = config;
|
||||
match.random_.seed(config.seed);
|
||||
|
||||
const coord3d first = starts.size() >= 1U ? starts[0].as_coord() : coord3d{300.0F, 300.0F, 0.0F};
|
||||
const coord3d second = starts.size() >= 2U ? starts[1].as_coord() : coord3d{1500.0F, 1500.0F, 0.0F};
|
||||
|
||||
match.spawn_player(0U, "Commander", game::faction::allied, true, first);
|
||||
match.spawn_player(1U, "AI", game::faction::soviet, false, second);
|
||||
return match;
|
||||
}
|
||||
|
||||
/** Run until a base falls or the frame cap is reached. */
|
||||
auto run() -> match_result {
|
||||
while (!this->decided() && frame_ < config_.max_frames) this->step();
|
||||
return this->result();
|
||||
}
|
||||
|
||||
/** Advance exactly one 30 Hz frame. */
|
||||
auto step() -> void {
|
||||
constexpr real dt = 1.0F / 30.0F;
|
||||
this->apply_income(dt);
|
||||
this->run_ai(dt);
|
||||
this->update_units(dt);
|
||||
this->check_victory();
|
||||
++frame_;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto frame() const -> uint32 { return frame_; }
|
||||
[[nodiscard]] auto decided() const -> bool { return decided_; }
|
||||
[[nodiscard]] auto winner() const -> int32 { return winner_; }
|
||||
[[nodiscard]] auto players() const -> const std::vector<player_state> & { return players_; }
|
||||
[[nodiscard]] auto units() const -> const std::vector<unit> & { return units_; }
|
||||
|
||||
[[nodiscard]] auto unit_count(uint32 owner) const -> usize {
|
||||
usize count = 0;
|
||||
for (const auto &entry: units_) {
|
||||
if (entry.alive && entry.owner == owner) ++count;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto result() const -> match_result {
|
||||
match_result summary;
|
||||
summary.decided = decided_;
|
||||
summary.winner = winner_;
|
||||
summary.frames = frame_;
|
||||
for (const auto &player: players_) {
|
||||
if (player.index < 2U) {
|
||||
summary.kills[player.index] = player.kills;
|
||||
summary.losses[player.index] = player.losses;
|
||||
summary.units_left[player.index] = static_cast<int32>(this->unit_count(player.index));
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr real build_interval_seconds = 4.0F;
|
||||
|
||||
auto spawn_player(uint32 index, std::string name, game::faction side, bool human, const coord3d &start) -> void {
|
||||
player_state player;
|
||||
player.index = index;
|
||||
player.name = std::move(name);
|
||||
player.side = side;
|
||||
player.human = human;
|
||||
player.money = config_.starting_money;
|
||||
player.start = start;
|
||||
player.base_unit = this->spawn_unit(index, unit_class::base, start);
|
||||
this->spawn_unit(index, unit_class::harvester, {start.x + 60.0F, start.y + 20.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::tank, {start.x + 40.0F, start.y + 50.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::tank, {start.x + 70.0F, start.y - 30.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::infantry, {start.x + 30.0F, start.y - 60.0F, 0.0F});
|
||||
this->spawn_unit(index, unit_class::infantry, {start.x + 90.0F, start.y - 60.0F, 0.0F});
|
||||
players_.push_back(std::move(player));
|
||||
}
|
||||
|
||||
auto spawn_unit(uint32 owner, unit_class cls, const coord3d &position) -> uint32 {
|
||||
const auto &stats = unit_stats(cls);
|
||||
unit created;
|
||||
created.id = next_unit_id_++;
|
||||
created.owner = owner;
|
||||
created.cls = cls;
|
||||
created.position = position;
|
||||
created.health = stats.max_health;
|
||||
created.max_health = stats.max_health;
|
||||
units_.push_back(created);
|
||||
return created.id;
|
||||
}
|
||||
|
||||
auto apply_income(real dt) -> void {
|
||||
for (auto &player: players_) {
|
||||
real rate = config_.income_per_second;
|
||||
for (const auto &entry: units_) {
|
||||
if (entry.alive && entry.owner == player.index && entry.cls == unit_class::harvester) rate += config_.harvester_income_per_second;
|
||||
}
|
||||
player.money_accumulator += rate * dt;
|
||||
const auto whole = static_cast<int32>(player.money_accumulator);
|
||||
if (whole > 0) {
|
||||
player.money += whole;
|
||||
player.money_accumulator -= static_cast<real>(whole);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto run_ai(real dt) -> void {
|
||||
for (auto &player: players_) {
|
||||
player.build_timer += dt;
|
||||
if (player.build_timer < build_interval_seconds) continue;
|
||||
player.build_timer = 0.0F;
|
||||
|
||||
const auto *base = this->find_unit(player.base_unit);
|
||||
if (base == nullptr || !base->alive) continue;
|
||||
if (player.money < tank_type.cost) continue;
|
||||
|
||||
player.money -= tank_type.cost;
|
||||
this->spawn_unit(player.index, unit_class::tank, {base->position.x + 50.0F, base->position.y - 50.0F, 0.0F});
|
||||
}
|
||||
}
|
||||
|
||||
auto update_units(real dt) -> void {
|
||||
for (auto &entry: units_) {
|
||||
if (!entry.alive) continue;
|
||||
if (entry.cooldown > 0.0F) entry.cooldown -= dt;
|
||||
if (entry.cls == unit_class::harvester) continue; // economy only, no combat
|
||||
|
||||
const auto &stats = unit_stats(entry.cls);
|
||||
auto *target = this->nearest_enemy(entry);
|
||||
if (target == nullptr) continue;
|
||||
|
||||
const auto dx = target->position.x - entry.position.x;
|
||||
const auto dy = target->position.y - entry.position.y;
|
||||
const auto dz = target->position.z - entry.position.z;
|
||||
const auto distance = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
if (distance > stats.weapon_range && stats.mobile && stats.speed > 0.0F) {
|
||||
if (distance > 1.0e-3F) {
|
||||
const auto travel = stats.speed * dt;
|
||||
entry.position.x += dx / distance * travel;
|
||||
entry.position.y += dy / distance * travel;
|
||||
}
|
||||
} else if (stats.weapon_damage > 0.0F && distance <= stats.weapon_range && entry.cooldown <= 0.0F) {
|
||||
entry.cooldown = stats.weapon_cooldown;
|
||||
target->health -= stats.weapon_damage;
|
||||
if (target->health <= 0.0F) {
|
||||
target->health = 0.0F;
|
||||
target->alive = false;
|
||||
this->player_ref(entry.owner).kills += 1;
|
||||
this->player_ref(target->owner).losses += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
auto check_victory() -> void {
|
||||
const bool first_alive = this->base_alive(0U);
|
||||
const bool second_alive = this->base_alive(1U);
|
||||
if (first_alive && second_alive) return;
|
||||
decided_ = true;
|
||||
if (first_alive) {
|
||||
winner_ = 0;
|
||||
} else if (second_alive) {
|
||||
winner_ = 1;
|
||||
} else {
|
||||
winner_ = -1;
|
||||
}
|
||||
}
|
||||
|
||||
[[nodiscard]] auto base_alive(uint32 owner) const -> bool {
|
||||
const auto &player = players_.at(owner);
|
||||
const auto *base = this->find_unit(player.base_unit);
|
||||
return base != nullptr && base->alive;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto nearest_enemy(const unit &self) -> unit * {
|
||||
unit *best = nullptr;
|
||||
real best_distance = 1.0e30F;
|
||||
for (auto &candidate: units_) {
|
||||
if (!candidate.alive || candidate.owner == self.owner) continue;
|
||||
const auto dx = candidate.position.x - self.position.x;
|
||||
const auto dy = candidate.position.y - self.position.y;
|
||||
const auto squared = dx * dx + dy * dy;
|
||||
if (squared < best_distance) {
|
||||
best_distance = squared;
|
||||
best = &candidate;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto find_unit(uint32 id) -> unit * {
|
||||
for (auto &entry: units_) {
|
||||
if (entry.id == id) return &entry;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto find_unit(uint32 id) const -> const unit * {
|
||||
for (const auto &entry: units_) {
|
||||
if (entry.id == id) return &entry;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] auto player_ref(uint32 index) -> player_state & { return players_.at(index); }
|
||||
|
||||
match_config config_;
|
||||
ra3::core::random random_{1U};
|
||||
std::vector<player_state> players_;
|
||||
std::vector<unit> units_;
|
||||
uint32 next_unit_id_ = 1U;
|
||||
uint32 frame_ = 0U;
|
||||
bool decided_ = false;
|
||||
int32 winner_ = -1;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user