6 Commits
Author SHA1 Message Date
EnderTheCoder 2e284a16e5 chore: release v0.10.1 2026-10-01 01:10:42 +08:00
EnderTheCoder 2f6ff74670 chore(vendor): update vendored libenderlog to v0.0.3
Dated record timestamps, per-sink timestamp format, and archive names
that keep the extension last (<stem>.<timestamp>.<ext>).
2026-10-01 01:05:21 +08:00
EnderTheCoder 2c348cb590 chore: release v0.10.0 2026-09-30 22:18:27 +08:00
EnderTheCoder 26e1934a5d feat(render): SAGE water/ocean shaders, per-user logs, camera & culling
Terrain pass now ports the SAGE water model (Ocean.fx / OpenSAGE Water.frag): a de-gridded procedural wave normal combined with the retail ra3_deepocean flow and ra3_deepocean_nrm bump maps (appended as the last two terrain-atlas layers, no new backend binding), Schlick fresnel, sky reflection + depth-graded refraction, SAGE diffuse/specular lighting, depth-based transparency, and an underwater tint (UnderwaterDeferred.fx). Mirrored across terrain.frag / dx_terrain.hlsl / webgl_terrain_frag.glsl / webgpu_terrain.wgsl.

Also: logs move to the per-user state dir (%LOCALAPPDATA%\\OpenRA3\\logs, else XDG) and archive as openra3.<stamp>.log; the FPS label shows the active backend; middle-drag camera reset; objects and roads below the water plane are culled.
2026-09-30 22:18:08 +08:00
EnderTheCoder 10a1963eec chore: release v0.9.0 2026-09-29 23:59:32 +08:00
EnderTheCoder 23f8be394c feat(assets): read retail assets through the vendored libra3assets library
Vendor libra3assets (C++26 modules: BIG4, RefPack, BinaryAsset, CSF, CkMp map)
under third_party/ and add the ra3_assets target. ra3.fs, ra3.map and ra3.terrain
become thin adapters over it:

- ra3.fs delegates RefPack and the BIG4 index/payload reads (index-only, payloads
  read on demand).
- ra3.map decodes ObjectsList and CSF via map_document/csf_table; starts come from
  player_starts(), replacing the off-by-one whole-buffer scan.
- ra3.terrain takes the CkMp container and HeightMapData from map_document, with
  BlendTileData (not modelled by the library) parsed from the chunk payload.

Adds an opt-in real-asset check (OPENRA3_TEST_ASSETS) plus the library's own unit
suite as ra3assets_unit.
2026-09-29 23:58:13 +08:00
32 changed files with 4093 additions and 785 deletions
+36 -5
View File
@@ -26,7 +26,7 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_SCAN_FOR_MODULES ON) set(CMAKE_CXX_SCAN_FOR_MODULES ON)
project(OpenRA3 VERSION 0.8.0 LANGUAGES C CXX) project(OpenRA3 VERSION 0.9.0 LANGUAGES C CXX)
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
@@ -196,6 +196,25 @@ target_sources(ra3_enderlog PUBLIC FILE_SET CXX_MODULES FILES third_party/libend
target_compile_features(ra3_enderlog PUBLIC cxx_std_26) target_compile_features(ra3_enderlog PUBLIC cxx_std_26)
openra3_target_defaults(ra3_enderlog) openra3_target_defaults(ra3_enderlog)
# --- RA3 built-in asset containers (vendored libra3assets) --------------------
# Reader/writer for the files the retail game ships: `BIG4` archives, the EA
# RefPack codec, SAGE `.csf` string tables and `.map` (`CkMp`) containers, plus
# the compiled `BinaryAsset` streams. A sibling of libenderlog: C++26 modules
# (`import ra3.assets;`) importing the standard library. It owns the on-disk
# format knowledge, so `ra3.fs` / `ra3.map` / `ra3.terrain` become thin adapters.
add_library(ra3_assets STATIC)
target_sources(ra3_assets PUBLIC FILE_SET CXX_MODULES FILES
third_party/libra3assets/src/assets.cppm
third_party/libra3assets/src/error.cppm
third_party/libra3assets/src/bytes.cppm
third_party/libra3assets/src/refpack.cppm
third_party/libra3assets/src/big.cppm
third_party/libra3assets/src/binary.cppm
third_party/libra3assets/src/csf.cppm
third_party/libra3assets/src/map.cppm)
target_compile_features(ra3_assets PUBLIC cxx_std_26)
openra3_target_defaults(ra3_assets)
# --- engine core ------------------------------------------------------------- # --- engine core -------------------------------------------------------------
add_library(ra3_core STATIC) add_library(ra3_core STATIC)
target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm) target_sources(ra3_core PUBLIC FILE_SET CXX_MODULES FILES src/core/ra3.core.cppm)
@@ -225,16 +244,16 @@ target_sources(ra3_game PUBLIC FILE_SET CXX_MODULES FILES src/game/ra3.game.cppm
target_link_libraries(ra3_game PUBLIC ra3_core ra3_logic) target_link_libraries(ra3_game PUBLIC ra3_core ra3_logic)
openra3_target_defaults(ra3_game) openra3_target_defaults(ra3_game)
# --- filesystem: BIG4 + RefPack --------------------------------------------- # --- filesystem: BIG4 + RefPack (adapter over libra3assets) -------------------
add_library(ra3_fs STATIC) add_library(ra3_fs STATIC)
target_sources(ra3_fs PUBLIC FILE_SET CXX_MODULES FILES src/fs/ra3.fs.cppm) target_sources(ra3_fs PUBLIC FILE_SET CXX_MODULES FILES src/fs/ra3.fs.cppm)
target_link_libraries(ra3_fs PUBLIC ra3_core) target_link_libraries(ra3_fs PUBLIC ra3_core ra3_assets)
openra3_target_defaults(ra3_fs) openra3_target_defaults(ra3_fs)
# --- map discovery/loading --------------------------------------------------- # --- map discovery/loading ---------------------------------------------------
add_library(ra3_map STATIC) add_library(ra3_map STATIC)
target_sources(ra3_map PUBLIC FILE_SET CXX_MODULES FILES src/map/ra3.map.cppm) target_sources(ra3_map PUBLIC FILE_SET CXX_MODULES FILES src/map/ra3.map.cppm)
target_link_libraries(ra3_map PUBLIC ra3_core ra3_fs) target_link_libraries(ra3_map PUBLIC ra3_core ra3_fs ra3_assets)
openra3_target_defaults(ra3_map) openra3_target_defaults(ra3_map)
# --- minimal skirmish simulation -------------------------------------------- # --- minimal skirmish simulation --------------------------------------------
@@ -252,7 +271,7 @@ openra3_target_defaults(ra3_render)
# --- real map terrain (HeightMapData / BlendTileData) ------------------------ # --- real map terrain (HeightMapData / BlendTileData) ------------------------
add_library(ra3_terrain STATIC) add_library(ra3_terrain STATIC)
target_sources(ra3_terrain PUBLIC FILE_SET CXX_MODULES FILES src/terrain/ra3.terrain.cppm) target_sources(ra3_terrain PUBLIC FILE_SET CXX_MODULES FILES src/terrain/ra3.terrain.cppm)
target_link_libraries(ra3_terrain PUBLIC ra3_core ra3_fs ra3_render) target_link_libraries(ra3_terrain PUBLIC ra3_core ra3_fs ra3_render ra3_assets)
openra3_target_defaults(ra3_terrain) openra3_target_defaults(ra3_terrain)
# --- map static art (compiled W3D meshes) ------------------------------------ # --- map static art (compiled W3D meshes) ------------------------------------
@@ -580,3 +599,15 @@ add_executable(ra3_tests tests/ra3_tests.cpp)
target_link_libraries(ra3_tests PRIVATE ra3) target_link_libraries(ra3_tests PRIVATE ra3)
openra3_target_defaults(ra3_tests) openra3_target_defaults(ra3_tests)
add_test(NAME ra3_tests COMMAND ra3_tests) add_test(NAME ra3_tests COMMAND ra3_tests)
# libra3assets' own dependency-free unit tests (RefPack + BIG4 + BinaryAsset +
# CSF + CkMp round-trips), built against the vendored copy so the upstream
# suite keeps guarding the readers OpenRA3 now relies on.
add_executable(ra3assets_unit
third_party/libra3assets/tests/test_main.cpp
third_party/libra3assets/tests/test_assets.cpp)
target_include_directories(ra3assets_unit PRIVATE third_party/libra3assets/tests)
target_link_libraries(ra3assets_unit PRIVATE ra3_assets)
target_compile_features(ra3assets_unit PRIVATE cxx_std_26)
openra3_target_defaults(ra3assets_unit)
add_test(NAME ra3assets_unit COMMAND ra3assets_unit)
+7 -4
View File
@@ -243,14 +243,17 @@ wixl 0.106 has no arm64 support, so Windows/ARM64 ships the portable `.zip`
## Logging ## Logging
Every run writes `openra3.log` next to the executable through the vendored Every run writes `openra3.log` in a per-user `logs/` folder — on Windows
`%LOCALAPPDATA%\OpenRA3\logs`, elsewhere `$XDG_STATE_HOME/openra3/logs`
(falling back to `~/.local/state/openra3/logs`) — created on first use and kept
out of the binary's own directory, through the vendored
[`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file [`libenderlog`](third_party/libenderlog) module (`import ender.log;`). A file
sink archives the previous log to `openra3.log.<YYYYmmdd-HHMMSS>` on open, so sink archives the previous log to `openra3.<YYYYmmdd-HHMMSS>.log` on open, so
each run gets its own file; the active file rotates at 4 MiB and the last 10 each run gets its own file; the active file rotates at 4 MiB and the last 10
archives are kept. Records at **`warn` and above** carry a call stack (Windows archives are kept. Records at **`warn` and above** carry a call stack (Windows
`CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no `CaptureStackBackTrace` / POSIX `execinfo`, because libc++ has no
`<stacktrace>`). A hard crash also writes `openra3_crash.log` with the faulting `<stacktrace>`). A hard crash also writes `openra3_crash.log` alongside it with
module and a raw backtrace. the faulting module and a raw backtrace.
## Running a skirmish ## Running a skirmish
+1 -1
View File
@@ -1 +1 @@
0.8.0 0.10.1
+99 -21
View File
@@ -10,14 +10,56 @@ import ender.log;
namespace { namespace {
#if defined(_WIN32) #if defined(_WIN32)
/** Path of the crash report written next to the executable. */ /** `%LOCALAPPDATA%` as a wide path, or empty when the variable is unset. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & { auto local_appdata() -> std::filesystem::path {
static const auto path = [] { const DWORD needed = GetEnvironmentVariableW(L"LOCALAPPDATA", nullptr, 0U);
std::wstring buffer(32768U, L'\0'); if (needed == 0U || needed > 32768U) return {};
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size())); std::wstring buffer(needed, L'\0');
buffer.resize(length); const DWORD written = GetEnvironmentVariableW(L"LOCALAPPDATA", buffer.data(), needed);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log"; if (written == 0U || written >= needed) return {};
buffer.resize(written);
return std::filesystem::path{buffer};
}
#endif
/**
* Directory that holds per-run logs and crash reports: a `logs` folder
* under the platform's per-user state location, created on first use. It
* deliberately does not sit beside the executable, which may be read-only
* or a shared build tree.
*
* Windows: `%LOCALAPPDATA%\OpenRA3\logs`; elsewhere
* `$XDG_STATE_HOME/openra3/logs` (falling back to
* `~/.local/state/openra3/logs`).
*/
[[maybe_unused]] auto log_directory() -> const std::filesystem::path & {
static const auto directory = [] {
#if defined(_WIN32)
auto base = local_appdata();
if (base.empty()) base = std::filesystem::temp_directory_path();
base /= L"OpenRA3";
#else
std::filesystem::path base;
if (const char *state = std::getenv("XDG_STATE_HOME"); state != nullptr && *state != '\0')
base = state;
else if (const char *home = std::getenv("HOME"); home != nullptr && *home != '\0')
base = std::filesystem::path{home} / ".local" / "state";
else
base = std::filesystem::temp_directory_path();
base /= "openra3";
#endif
auto result = base / "logs";
std::error_code ec;
std::filesystem::create_directories(result, ec);
return result;
}(); }();
return directory;
}
#if defined(_WIN32)
/** Path of the crash report, under the per-user `logs` folder. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = log_directory() / L"openra3_crash.log";
return path; return path;
} }
@@ -92,7 +134,7 @@ namespace {
* timestamped file on open, so every run gets its own log and the previous * timestamped file on open, so every run gets its own log and the previous
* run's log is preserved. * run's log is preserved.
*/ */
auto setup_logging(const std::filesystem::path &exe_dir) -> void { auto setup_logging() -> void {
namespace log = ender::log; namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn}); log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
#if defined(__EMSCRIPTEN__) #if defined(__EMSCRIPTEN__)
@@ -100,9 +142,8 @@ namespace {
// console.error regardless of level; use stdout so INFO/WARN appear at // console.error regardless of level; use stdout so INFO/WARN appear at
// their real level. There is no file sink on the web. // their real level. There is no file sink on the web.
log::set_sinks({std::make_shared<log::console_sink>(std::cout)}); log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
(void) exe_dir;
#else #else
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U}); log::add_file_sink(log_directory() / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
#endif #endif
log::info("OpenRA3 started"); log::info("OpenRA3 started");
} }
@@ -190,6 +231,32 @@ namespace {
return maps; return maps;
} }
/** Case-insensitive ASCII ordering (`a` before `b`). */
auto name_less(std::string_view a, std::string_view b) -> bool {
const auto length = std::min(a.size(), b.size());
for (std::size_t i = 0; i < length; ++i) {
const auto ca = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(a[i])));
const auto cb = static_cast<unsigned char>(std::tolower(static_cast<unsigned char>(b[i])));
if (ca != cb) return ca < cb;
}
return a.size() < b.size();
}
/**
* Order the map list the way the retail skirmish screen does: alphabetically
* by the localized display name (e.g. "Battlebase Beta" before "Cabana
* Republic"), falling back to the id for entries with equal names.
*/
auto sort_maps_by_name(std::vector<asset_map> &maps, const ra3::map::map_name_table &names) -> void {
std::sort(maps.begin(), maps.end(), [&](const asset_map &a, const asset_map &b) {
const auto name_a = names.lookup(a.id);
const auto name_b = names.lookup(b.id);
if (name_less(name_a, name_b)) return true;
if (name_less(name_b, name_a)) return false;
return a.id < b.id;
});
}
/** Find `<id>_art.tga` anywhere under `root`. */ /** Find `<id>_art.tga` anywhere under `root`. */
auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional<std::filesystem::path> { auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional<std::filesystem::path> {
const auto want = std::string{id} + "_art.tga"; const auto want = std::string{id} + "_art.tga";
@@ -259,14 +326,18 @@ namespace {
} }
const auto world_w = terrain.world_width(); const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height(); const auto world_h = terrain.world_height();
// An opaque water surface hides anything below it, so cull objects
// and roads submerged under the map's water plane (sunken ships,
// underwater props, ...) instead of drawing them on top of the sea.
const auto cull_below_z = terrain.has_water ? terrain.water_plane_z : -3.4e38F;
scene = ra3::models::build_scene(stream, placements, [&](float x, float y) -> float { scene = ra3::models::build_scene(stream, placements, [&](float x, float y) -> float {
if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F; if (x < 0.0F || y < 0.0F || x >= world_w || y >= world_h) return 0.0F;
const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size)); const auto cx = std::min(terrain.width - 1U, static_cast<ra3::core::uint32>(x / ra3::terrain::cell_size));
const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size)); const auto cy = std::min(terrain.height - 1U, static_cast<ra3::core::uint32>((world_h - y) / ra3::terrain::cell_size));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale; return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
}); }, 128U, cull_below_z);
std::printf("objects: %zu placed, %zu missing, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing, std::printf("objects: %zu placed, %zu missing, %zu hidden, %zu road segments, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing,
scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str()); scene.hidden, scene.roads, scene.triangle_count(), scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) { } catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what()); std::printf("objects: failed to build scene (%s)\n", error.what());
} }
@@ -410,8 +481,9 @@ namespace {
auto command_maps(const std::filesystem::path &assets) -> int { auto command_maps(const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets); const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
std::printf("maps: %zu\n", maps.size()); std::printf("maps: %zu\n", maps.size());
for (const auto &m: maps) { for (const auto &m: maps) {
std::error_code ec; std::error_code ec;
@@ -424,8 +496,9 @@ namespace {
auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3; using namespace ra3;
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
if (maps.empty()) return 1; if (maps.empty()) return 1;
sort_maps_by_name(maps, map::load_map_names(assets));
const auto requested = option_value(args, "--map"); const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front(); const asset_map *picked = &maps.front();
if (requested) { if (requested) {
@@ -463,8 +536,10 @@ namespace {
auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3; using namespace ra3;
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
if (maps.empty()) return 1; if (maps.empty()) return 1;
const auto names = map::load_map_names(assets);
sort_maps_by_name(maps, names);
const auto requested = option_value(args, "--map"); const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front(); const asset_map *picked = &maps.front();
if (requested) { if (requested) {
@@ -473,7 +548,6 @@ namespace {
} }
render::scene_options scene; render::scene_options scene;
const auto names = map::load_map_names(assets);
scene.title = "OpenRA3 - " + names.lookup(picked->id); scene.title = "OpenRA3 - " + names.lookup(picked->id);
if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size); if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size);
@@ -1141,7 +1215,9 @@ namespace {
const bool want_gpu = s.mode == 0 && s.out.empty() && s.thumbnail == false; const bool want_gpu = s.mode == 0 && s.out.empty() && s.thumbnail == false;
if (want_gpu) { if (want_gpu) {
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); }); report(0.62F, "Loading objects...");
const auto objects = build_object_scene(assets, map_file.stem().string(), view.map, terrain::render_options{}, bytes);
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, objects, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); });
if (!starts.empty()) { if (!starts.empty()) {
view.camera3d.target_x = starts[0].x; view.camera3d.target_x = starts[0].x;
view.camera3d.target_y = starts[0].y; view.camera3d.target_y = starts[0].y;
@@ -1185,12 +1261,13 @@ namespace {
auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
if (maps.empty()) { if (maps.empty()) {
std::puts("no maps found"); std::puts("no maps found");
return 1; return 1;
} }
const auto names = ra3::map::load_map_names(assets); const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st; menu_state st;
// Seed the menu from any command-line flags so they are all visible/editable. // Seed the menu from any command-line flags so they are all visible/editable.
@@ -1307,8 +1384,9 @@ namespace {
/** Render one menu frame to a BMP (headless preview of the menu layout). */ /** Render one menu frame to a BMP (headless preview of the menu layout). */
auto command_menu_preview(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int { auto command_menu_preview(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1; if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets); auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets); const auto names = ra3::map::load_map_names(assets);
sort_maps_by_name(maps, names);
menu_state st; menu_state st;
if (const auto requested = option_value(args, "--map")) { if (const auto requested = option_value(args, "--map")) {
for (std::size_t i = 0; i < maps.size(); ++i) { for (std::size_t i = 0; i < maps.size(); ++i) {
@@ -1338,7 +1416,7 @@ auto main(int argc, char **argv) -> int {
const std::vector<std::string> args{argv + 1, argv + argc}; const std::vector<std::string> args{argv + 1, argv + argc};
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : "."); const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets"; const auto assets = exe_dir / "assets";
setup_logging(exe_dir); setup_logging();
#if defined(__EMSCRIPTEN__) #if defined(__EMSCRIPTEN__)
// The wasm build runs on a Web Worker (its runtime's main thread lives // The wasm build runs on a Web Worker (its runtime's main thread lives
// there), where synchronous XHR is legal, so the asset tree is mounted // there), where synchronous XHR is legal, so the asset tree is mounted
+29 -1
View File
@@ -533,7 +533,8 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)` - `[ ]` pass scheduling, barriers, transient/aliased resources `(v0.6)`
- **F3 Terrain render** `[~]` - **F3 Terrain render** `[~]`
- `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas - `[x]` top-down software + GPU heightfield with blend ramp + gutter atlas
- `[ ]` perspective terrain mesh, LOD, cliff, water `(v0.6)` - `[ ]` perspective terrain mesh, LOD, cliff `(v0.6)`
- `[x]` water surface in the raymarch: SAGE `Water.frag` port (ocean/river) `[~]`
- **F4 Model render** `[~]` - **F4 Model render** `[~]`
- `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software) - `[x]` static map-object triangle soup, world-space, depth-tested over the terrain (Vulkan + software)
- `[x]` bind-pose skinning (bone-space vertices) + ground-decal depth bias - `[x]` bind-pose skinning (bone-space vertices) + ground-decal depth bias
@@ -550,8 +551,35 @@ Mirrors SAGE `GameEngine/Common`. Everything else speaks this.
- `[x]` ARGB framebuffer, blit/line/circle/text, TGA decode, BMP encode - `[x]` ARGB framebuffer, blit/line/circle/text, TGA decode, BMP encode
- `[x]` map compositing, grid, markers; headless output - `[x]` map compositing, grid, markers; headless output
- **F9 Post-processing** `[ ]` - **F9 Post-processing** `[ ]`
- `[x]` underwater tint/fog in the terrain pass (retail `UnderwaterDeferred.fx`) `[~]`
- `[ ]` bloom, color grading, AA, resolution scaling `(v0.7)` - `[ ]` bloom, color grading, AA, resolution scaling `(v0.7)`
### M16b shader porting status (retail `Data\Shaders.big` → `*.fxo`)
The retail set is **88 compiled effects** (`Core12\shaders\compiled`, duplicated
in `Misc`/`Shaders`; `Core5`/`Core8` ship only `terrain`; plus a 60-byte
`null` stub). OpenRA3 implements the static-map subset only:
- **Ported (approximate stand-ins, shared by all backends):** `Terrain.fx`
(`terrain.*`), the opaque diffuse subset of `BuildingsGeneric.fx` /
`BasicW3D.fx` / `ObjectsGeneric.fx` (`object.*`), and the SAGE water model
`Ocean.fx` (+ `OceanDisplacement`/`OceanNoVertexTexture`/`RiverWater`/
`RiverReflection`/`UnderwaterDeferred`, folded into the `terrain.*` water
branch — see `docs/REVERSE_ENGINEERING.md`). The retail water flow and bump
maps (`art/terrain/ra3_deepocean.tga`, `ra3_deepocean_nrm.tga`) ride as the
last two terrain-atlas layers, so no backend adds a binding.
- **Not ported:** all faction/variant model shaders (`buildings*`, `objects*`,
`basicw3d*`, `defaultw3d*`, `normalmapped`, `tree`/`treesway`), instances/
animation (`infantry*`), particles and beams (`cpuparticle`, `gpuparticle*`,
`swarmparticle`, `laser*`, `lightning`, `fx*`, `tracer`, `trail`,
`connectionline`, `linerenderers`, `stream`, `rain`, `simple*`), shadows and
ground decals (`shadow`, `decal`, `outlines`, `occlusion`, `terraintracks`),
post-processing (`postfx_*`), and the 2D/misc shaders (`render2d`, `video`,
`bootupscreen`, `debug`, `errormissing`, `rotateenvironmentmap`,
`distortingobject`). The shared retail includes `shadowmap.fxh`, `ssao.fxh`,
`macrotexture.fxh`, `gamma.fxh` are likewise absent (`skinning.fxh` is done at
bind pose only).
### M17 `ra3.ui` — platform layer & backends `[D]` ### M17 `ra3.ui` — platform layer & backends `[D]`
- **F1 Display abstraction** `[D]` - **F1 Display abstraction** `[D]`
+42
View File
@@ -324,6 +324,48 @@ vertex color and `TintColor`; `basicw3d.fxo` instead modulates a single
macro/lightmap with `(vertexColor + additive) * diffuse * 2` and has no normal macro/lightmap with `(vertexColor + additive) * diffuse * 2` and has no normal
map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.) map. (Recovered by disassembling the embedded `vs_3_0`/`ps_3_0` bytecode.)
### Water / ocean (`Ocean.fx`, `OceanDisplacement.fx`, `RiverWater.fx`, `UnderwaterDeferred.fx`)
The SAGE water surface is reconstructed by OpenSAGE as
`Assets/Shaders/Water.vert`+`Water.frag` (same family as retail `Ocean.fx`). The
surface is a world-space mesh drawn with alpha blending, fed by two render
targets rendered before it: a **reflection** map (scene from the mirrored camera
about `GlobalWaterSettings.ReflectionPlaneZ`) and a **refraction** map + depth.
The fragment model (`Water.frag`) is:
- `waterUV = worldPos.xy / 320`; a scrolling `WaterTexture` supplies both a
flow distortion (`(tex.xy*2-1)*0.05`) and the flow layer; a `BumpTexture`
supplies the surface `worldNormal`.
- `fresnelFactor = dot(viewVector, +Z)`; reflection/refraction are sampled in
screen space (`gl_FragCoord / ViewportSize`), each displaced by the distortion.
- `linearWaterDepth = linearize(RefractionDepth) - linearize(gl_FragCoord.z)`;
`alpha = clamp((linearWaterDepth/2)/TransparentWaterDepth, 0, TransparentWaterMinOpacity)`.
- `final = diffuseColor * textureColor * cloudColor`, then mixed with
`mix(reflectionColor, refractionColor, fresnelFactor)` (both maps on) or just
one of them, per `IsRenderReflection` / `IsRenderRefraction`.
- Per-time-of-day `WaterSet`: `WaterTexture`, `UScrollPerMS`/`VScrollPerMS`,
`DiffuseColor`, `TransparentDiffuseColor`; `WaterTransparency` supplies
`TransparentWaterDepth`/`TransparentWaterMinOpacity`, the skybox faces,
`RiverTransparencyMultiplier`, `ReflectionPlaneZ`/`ReflectionOn`.
OpenRA3 has no water mesh or reflection/refraction targets (its terrain is
raymarched), so the model is folded into the terrain pass (`shaders/terrain.frag`
and its HLSL/GLSL-ES/WGSL twins): the ray's water-plane hit takes a scrolling
wave normal built from the retail bump map combined with a de-gridded procedural
wave, Schlick fresnel (F0 = 0.02), a sky reflection and a depth-graded
refraction, SAGE diffuse + specular lighting, a depth-based transparency fade,
and — when the camera is below `ReflectionPlaneZ` — an underwater tint/fog
(`UnderwaterDeferred.fx`). The two retail maps
`art/terrain/ra3_deepocean.tga` (flow/distortion) and `ra3_deepocean_nrm.tga`
(bump normal) are appended as the **last two layers of the terrain atlas**
(`ra3::terrain::build_gpu_terrain`, `water_flow = layer_count - 2`,
`water_normal = layer_count - 1`), so every backend samples them with the
existing atlas binding; absent maps fall back to a neutral layer. True
reflection/refraction render targets are still the next step.
`GPUParticleOceanDisplacement.fx` drives wave displacement from a GPU particle
buffer and has no analogue here.
The map's `ObjectsList` chunk is a list of nested `Object` assets — The map's `ObjectsList` chunk is a list of nested `Object` assets —
`Coord3D`, Z `angle`, `RoadType` u32, a `u16`-prefixed type-name and a property `Coord3D`, Z `angle`, `RoadType` u32, a `u16`-prefixed type-name and a property
list whose keys index the shared name table (`ra3.map::parse_objects`). Each list whose keys index the shared name table (`ra3.map::parse_objects`). Each
+162 -42
View File
@@ -8,6 +8,10 @@
// `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell // `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a cell
// edge; material boundaries cross-fade with the SAGE blend ramp. // edge; material boundaries cross-fade with the SAGE blend ramp.
// //
// The water plane is shaded with a port of the SAGE water effect
// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in
// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md.
//
// The Vulkan push constants (20 floats) become a constant buffer. // The Vulkan push constants (20 floats) become a constant buffer.
cbuffer TerrainCB : register(b0) { cbuffer TerrainCB : register(b0) {
@@ -15,7 +19,7 @@ cbuffer TerrainCB : register(b0) {
float4 params; // x=pitch, y=fov, z=water_z, w=has_water float4 params; // x=pitch, y=fov, z=water_z, w=has_water
float4 sun; // xyz=sun dir, w=ambient float4 sun; // xyz=sun dir, w=ambient
float4 mapinfo; // x=W, y=H, z=unused, w=z_scale float4 mapinfo; // x=W, y=H, z=unused, w=z_scale
float4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect float4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect
}; };
Texture2D<float> heightmap : register(t0); Texture2D<float> heightmap : register(t0);
@@ -27,6 +31,12 @@ SamplerState atlas_smp : register(s2);
static const float CELL = 10.0; // must match ra3::terrain::cell_size static const float CELL = 10.0; // must match ra3::terrain::cell_size
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
static const float WATER_SCALE = 1.0 / 320.0;
static const float WATER_TRANSPARENT_DEPTH = 10.0;
static const float WATER_MIN_OPACITY = 0.70;
static const float WATER_RIVER_MULTIPLIER = 1.0;
struct VSOut { struct VSOut {
float4 pos : SV_Position; float4 pos : SV_Position;
float2 uv : TEXCOORD0; float2 uv : TEXCOORD0;
@@ -104,6 +114,105 @@ float3 sample_layer(uint layer, float wx, float wy) {
return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb; return atlas.Sample(atlas_smp, float3(float2(wx, wy) / span, l)).rgb;
} }
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit layer index (the water flow/bump maps
// are appended as the last two layers of the tile atlas).
float3 water_tex(int layer, float2 uv) {
uint w = 0;
uint h = 0;
uint lc = 0;
atlas.GetDimensions(w, h, lc);
float l = (float) clamp(layer, 0, (int) lc - 1);
return atlas.Sample(atlas_smp, float3(uv, l)).rgb;
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
float3 water_normal(float2 world_xy, float time) {
float2 q = world_xy * (WATER_SCALE * 6.0);
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
uint w = 0;
uint h = 0;
uint lc = 0;
atlas.GetDimensions(w, h, lc);
float3 flow = water_tex((int) lc - 2, q - float2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
float3 bump = water_tex((int) lc - 1, q + flow.xy * 0.05 + float2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
float sx = -dx * 0.30 + bump.x * 0.45;
float sy = -dy * 0.30 + bump.y * 0.45;
return normalize(float3(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
float water_distortion(float2 world_xy, float time) {
float2 q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency
// scroll stands in.
float3 water_cloud(float2 world_xy, float time) {
return float3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to
// every output while the camera is below the water plane.
float3 apply_underwater(float3 color, float dist, float cam_z, float water_z) {
if (cam_z >= water_z - 0.5) return color;
const float3 absorb = float3(0.35, 0.62, 0.75);
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
return lerp(color * absorb, float3(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded
// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based
// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier.
float3 water_shade(float3 hitpos, float3 dir, float dist) {
float time = misc.x;
float river = misc.y;
float seabed = world_height(hitpos.x, hitpos.y);
float depth = max(0.0, params.z - seabed);
float3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
float3 sun_dir = normalize(sun.xyz);
// Schlick fresnel, water F0 = 0.02.
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
// Reflection: the sky the surface mirrors. Refraction: the seabed, graded
// from shallow to deep and lit by the SAGE diffuse + specular model.
float3 reflection = sky_color(reflect(dir, n));
float3 shallow = float3(0.10, 0.34, 0.38);
float3 deep = float3(0.02, 0.12, 0.22);
float3 refraction = lerp(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
float ndotl = max(dot(n, sun_dir), 0.0);
float ambient = sun.w;
float3 diffuse = float3(ambient + (1.0 - ambient) * ndotl);
float3 half_v = normalize(sun_dir - dir);
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
float3 color = lerp(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += float3(1.0, 0.97, 0.9) * spec * 0.45;
// Depth-based transparency: shallow water shows the seabed, deep water goes
// opaque toward the deep colour.
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
color = lerp(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
// Distance haze toward the horizon, as the terrain.
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
return lerp(color, sky_color(float3(dir.x, dir.y, 0.0)), wfog);
}
float4 PSMain(VSOut input) : SV_Target { float4 PSMain(VSOut input) : SV_Target {
float4 p = cam; float4 p = cam;
float pitch = clamp(params.x, 0.15, 1.45); float pitch = clamp(params.x, 0.15, 1.45);
@@ -127,50 +236,75 @@ float4 PSMain(VSOut input) : SV_Target {
float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); float3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
return float4(sky_color(dir), 1.0); return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0);
} }
// March the heightfield. The step grows quickly: the map diagonal is only // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail, and // so the silhouette there stays razor-sharp instead of stair-stepping
// a 6% growth rate more than doubles the worst-case iteration count. // across it. Outside the map is sky.
float t = CELL * 0.5; float t_enter = 0.0;
float dt = CELL * 0.5; float t_exit = 1.0e30;
bool inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam_pos.x >= 0.0 && cam_pos.x <= world_w);
} else {
float a = (0.0 - cam_pos.x) / dir.x;
float b = (world_w - cam_pos.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam_pos.y >= 0.0 && cam_pos.y <= world_h);
} else {
float a = (0.0 - cam_pos.y) / dir.y;
float b = (world_h - cam_pos.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0);
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
float horiz = max(abs(dir.x), abs(dir.y));
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
float t = max(t_enter, CELL * 0.5);
float prev = t; float prev = t;
bool hit = false; bool hit = false;
float hit_t = 0.0; float hit_t = 0.0;
for (int i = 0; i < 256 && t < 20000.0; ++i) { for (int i = 0; i < 1024 && t <= t_exit; ++i) {
float3 w = cam_pos + dir * t; float3 w = cam_pos + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { float h = world_height(w.x, w.y);
prev = t; float surface = (params.w > 0.5) ? max(h, params.z) : h;
dt *= 1.10; if (w.z <= surface) {
t += dt;
continue;
}
if (params.w > 0.5 && w.z <= params.z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t; prev = t;
dt *= 1.10; t += clamp(clearance, cell_step, cell_step * 8.0);
t += dt;
} }
if (!hit) { if (!hit) {
return float4(sky_color(dir), 1.0); return float4(apply_underwater(sky_color(dir), 0.0, cam_pos.z, params.z), 1.0);
} }
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev; float lo = prev;
float hi = hit_t; float hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi); float mid = 0.5 * (lo + hi);
float3 w = cam_pos + dir * mid; float3 w = cam_pos + dir * mid;
bool water = params.w > 0.5 && w.z <= params.z; float h = world_height(w.x, w.y);
if (water || w.z <= world_height(w.x, w.y)) { float surface = (params.w > 0.5) ? max(h, params.z) : h;
if (w.z <= surface) {
hi = mid; hi = mid;
} else { } else {
lo = mid; lo = mid;
@@ -182,21 +316,7 @@ float4 PSMain(VSOut input) : SV_Target {
float ambient = sun.w; float ambient = sun.w;
if (params.w > 0.5 && hitpos.z <= params.z + 0.01) { if (params.w > 0.5 && hitpos.z <= params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel. return float4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam_pos.z, params.z), 1.0);
float time = misc.x;
float2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
float3 n = normalize(float3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
float3 deep = float3(0.03, 0.16, 0.28);
float3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun_dir));
float3 water = lerp(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += float3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = lerp(water, sky_color(float3(dir.x, dir.y, 0.0)), wfog);
return float4(water, 1.0);
} }
// Terrain: read the per-cell blend record, sample the base/blend/three-way // Terrain: read the per-cell blend record, sample the base/blend/three-way
@@ -234,5 +354,5 @@ float4 PSMain(VSOut input) : SV_Target {
// Distance haze toward the horizon so the map edge blends into the sky. // Distance haze toward the horizon so the map edge blends into the sky.
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog); lit = lerp(lit, sky_color(float3(dir.x, dir.y, 0.0)), fog);
return float4(lit, 1.0); return float4(apply_underwater(lit, hi, cam_pos.z, params.z), 1.0);
} }
Binary file not shown.
+159 -34
View File
@@ -8,6 +8,10 @@
// The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the // The material is sampled **continuously** (`uv = cell / (2 * cellSize)`), as the
// retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a // retail `Terrain.fx` / OpenSAGE `Terrain.frag` do, so it never restarts at a
// cell edge; material boundaries cross-fade with the SAGE blend ramp. // cell edge; material boundaries cross-fade with the SAGE blend ramp.
//
// The water plane is shaded with a port of the SAGE water effect
// (`Ocean.fx` / OpenSAGE `Water.frag`); the camera-below-water tint stands in
// for `UnderwaterDeferred.fx`. See docs/REVERSE_ENGINEERING.md.
layout(binding = 0) uniform sampler2D heightmap; layout(binding = 0) uniform sampler2D heightmap;
layout(binding = 1) uniform sampler2D celldata; layout(binding = 1) uniform sampler2D celldata;
layout(binding = 2) uniform sampler2DArray atlas; layout(binding = 2) uniform sampler2DArray atlas;
@@ -17,7 +21,7 @@ layout(push_constant) uniform Push {
vec4 params; // x=pitch, y=fov, z=water_z, w=has_water vec4 params; // x=pitch, y=fov, z=water_z, w=has_water
vec4 sun; // xyz=sun dir, w=ambient vec4 sun; // xyz=sun dir, w=ambient
vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale vec4 mapinfo; // x=W, y=H, z=unused, w=z_scale
vec4 misc; // x=time, y=unused, z=cells per texture repeat, w=aspect vec4 misc; // x=time, y=water kind (0=ocean,1=river), z=cells per repeat, w=aspect
} pc; } pc;
layout(location = 0) in vec2 in_uv; layout(location = 0) in vec2 in_uv;
@@ -30,6 +34,12 @@ const float CELL = 10.0; // must match ra3::terrain::cell_size
const float NEAR = 10.0; const float NEAR = 10.0;
const float FAR = 60000.0; const float FAR = 60000.0;
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
const float WATER_SCALE = 1.0 / 320.0; // Water.frag: worldPos.xy / 320
const float WATER_TRANSPARENT_DEPTH = 10.0; // WaterTransparency.TransparentWaterDepth
const float WATER_MIN_OPACITY = 0.70; // WaterTransparency.TransparentWaterMinOpacity
const float WATER_RIVER_MULTIPLIER = 1.0; // WaterTransparency.RiverTransparencyMultiplier
float height_at(ivec2 c) { float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1); c = clamp(c, ivec2(0), ivec2(pc.mapinfo.xy) - 1);
return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w; return texelFetch(heightmap, c, 0).r * 65535.0 * pc.mapinfo.w;
@@ -92,6 +102,99 @@ vec3 sample_layer(uint layer, float wx, float wy) {
return texture(atlas, vec3(vec2(wx, wy) / span, l)).rgb; return texture(atlas, vec3(vec2(wx, wy) / span, l)).rgb;
} }
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit float layer index (the water flow/bump
// maps are appended as the last two layers of the tile atlas).
vec3 water_tex(int layer, vec2 uv) {
int lc = textureSize(atlas, 0).z;
float l = float(clamp(layer, 0, max(lc - 1, 0)));
return texture(atlas, vec3(uv, l)).rgb;
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
vec3 water_normal(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
int lc = textureSize(atlas, 0).z;
vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
float sx = -dx * 0.30 + bump.x * 0.45;
float sy = -dy * 0.30 + bump.y * 0.45;
return normalize(vec3(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
float water_distortion(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow low-frequency
// scroll stands in.
vec3 water_cloud(vec2 world_xy, float time) {
return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance. Applied to
// every output while the camera is below the water plane.
vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) {
if (cam_z >= water_z - 0.5) return color;
const vec3 absorb = vec3(0.35, 0.62, 0.75);
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed reflection (sky) and refraction (depth-graded
// seabed), SAGE diffuse + specular lighting, cloud term, and a depth-based
// transparency fade. `river` (>0.5) applies RiverTransparencyMultiplier.
vec3 water_shade(vec3 hitpos, vec3 dir, float dist) {
float time = pc.misc.x;
float river = pc.misc.y;
float seabed = world_height(hitpos.x, hitpos.y);
float depth = max(0.0, pc.params.z - seabed);
vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
vec3 sun = normalize(pc.sun.xyz);
// Schlick fresnel, water F0 = 0.02.
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
// Reflection: the sky the surface mirrors. Refraction: the seabed, graded
// from shallow to deep and lit by the SAGE diffuse + specular model.
vec3 reflection = sky_color(reflect(dir, n));
vec3 shallow = vec3(0.10, 0.34, 0.38);
vec3 deep = vec3(0.02, 0.12, 0.22);
vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
float ndotl = max(dot(n, sun), 0.0);
float ambient = pc.sun.w;
vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl);
vec3 half_v = normalize(sun - dir);
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += vec3(1.0, 0.97, 0.9) * spec * 0.45;
// Depth-based transparency: shallow water shows the seabed, deep water goes
// opaque toward the deep colour.
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
// Distance haze toward the horizon, as the terrain.
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
}
void main() { void main() {
vec4 p = pc.cam; vec4 p = pc.cam;
float pitch = clamp(pc.params.x, 0.15, 1.45); float pitch = clamp(pc.params.x, 0.15, 1.45);
@@ -118,35 +221,71 @@ void main() {
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
gl_FragDepth = 1.0; gl_FragDepth = 1.0;
out_color = vec4(sky_color(dir), 1.0); out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0);
return; return;
} }
// March the heightfield. The step grows quickly: the map diagonal is only // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail, and // so the silhouette there stays razor-sharp instead of stair-stepping
// a 6% growth rate more than doubles the worst-case iteration count. // across it. Outside the map is sky.
float t = CELL * 0.5; float t_enter = 0.0;
float dt = CELL * 0.5; float t_exit = 1.0e30;
bool inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam.x >= 0.0 && cam.x <= world_w);
} else {
float a = (0.0 - cam.x) / dir.x;
float b = (world_w - cam.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam.y >= 0.0 && cam.y <= world_h);
} else {
float a = (0.0 - cam.y) / dir.y;
float b = (world_h - cam.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
gl_FragDepth = 1.0;
out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0);
return;
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
float horiz = max(abs(dir.x), abs(dir.y));
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
float t = max(t_enter, CELL * 0.5);
float prev = t; float prev = t;
bool hit = false; bool hit = false;
float hit_t = 0.0; float hit_t = 0.0;
for (int i = 0; i < 256 && t < 20000.0; ++i) { for (int i = 0; i < 1024 && t <= t_exit; ++i) {
vec3 w = cam + dir * t; vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { float h = world_height(w.x, w.y);
prev = t; dt *= 1.10; t += dt; continue; float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
} if (w.z <= surface) { hit = true; hit_t = t; break; }
if (pc.params.w > 0.5 && w.z <= pc.params.z) { hit = true; hit_t = t; break; } float clearance = (w.z - surface) / max(-dir.z, 1e-4);
if (w.z <= world_height(w.x, w.y)) { hit = true; hit_t = t; break; } prev = t;
prev = t; dt *= 1.10; t += dt; t += clamp(clearance, cell_step, cell_step * 8.0);
} }
if (!hit) { gl_FragDepth = 1.0; out_color = vec4(sky_color(dir), 1.0); return; } if (!hit) { gl_FragDepth = 1.0; out_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, pc.params.z), 1.0); return; }
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev, hi = hit_t; float lo = prev, hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi); float mid = 0.5 * (lo + hi);
vec3 w = cam + dir * mid; vec3 w = cam + dir * mid;
bool water = pc.params.w > 0.5 && w.z <= pc.params.z; float h = world_height(w.x, w.y);
if (water || w.z <= world_height(w.x, w.y)) hi = mid; else lo = mid; float surface = (pc.params.w > 0.5) ? max(h, pc.params.z) : h;
if (w.z <= surface) hi = mid; else lo = mid;
} }
vec3 hitpos = cam + dir * hi; vec3 hitpos = cam + dir * hi;
@@ -158,21 +297,7 @@ void main() {
float ambient = pc.sun.w; float ambient = pc.sun.w;
if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) { if (pc.params.w > 0.5 && hitpos.z <= pc.params.z + 0.01) {
// Water: animated normal from a procedural wave, sky reflection + fresnel. out_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, pc.params.z), 1.0);
float time = pc.misc.x;
vec2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
vec3 deep = vec3(0.03, 0.16, 0.28);
vec3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun));
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6; // sun glint
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
out_color = vec4(water, 1.0);
return; return;
} }
@@ -211,5 +336,5 @@ void main() {
// Distance haze toward the horizon so the map edge blends into the sky. // Distance haze toward the horizon so the map edge blends into the sky.
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog); lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
out_color = vec4(lit, 1.0); out_color = vec4(apply_underwater(lit, hi, cam.z, pc.params.z), 1.0);
} }
+144 -40
View File
@@ -24,6 +24,12 @@ out vec4 frag_color;
const float CELL = 10.0; // must match ra3::terrain::cell_size const float CELL = 10.0; // must match ra3::terrain::cell_size
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
const float WATER_SCALE = 1.0 / 320.0;
const float WATER_TRANSPARENT_DEPTH = 10.0;
const float WATER_MIN_OPACITY = 0.70;
const float WATER_RIVER_MULTIPLIER = 1.0;
float height_at(ivec2 c) { float height_at(ivec2 c) {
c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1); c = clamp(c, ivec2(0), ivec2(u_mapinfo.xy) - 1);
return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w; return texelFetch(u_heightmap, c, 0).r * 65535.0 * u_mapinfo.w;
@@ -82,6 +88,91 @@ vec3 sample_layer(uint layer, float wx, float wy) {
return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr; return texture(u_atlas, vec3(vec2(wx, wy) / span, l)).bgr;
} }
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit layer index (the water flow/bump maps
// are appended as the last two layers of the tile atlas). The atlas is
// 0xAARRGGBB, so `.bgr` restores RGB (as `sample_layer`).
vec3 water_tex(int layer, vec2 uv) {
int lc = textureSize(u_atlas, 0).z;
float l = float(clamp(layer, 0, max(lc - 1, 0)));
return texture(u_atlas, vec3(uv, l)).bgr;
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
vec3 water_normal(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
float a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
float a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
float a3 = (q.x + q.y) * 1.60 + time * 2.10;
float a4 = (q.x - q.y) * 2.30 - time * 1.70;
float dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
float dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
int lc = textureSize(u_atlas, 0).z;
vec3 flow = water_tex(lc - 2, q - vec2(time * 0.010, time * 0.014)) * 2.0 - 1.0;
vec3 bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2(time * 0.006, time * 0.008)) * 2.0 - 1.0;
float sx = -dx * 0.30 + bump.x * 0.45;
float sy = -dy * 0.30 + bump.y * 0.45;
return normalize(vec3(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
float water_distortion(vec2 world_xy, float time) {
vec2 q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow scroll stands in.
vec3 water_cloud(vec2 world_xy, float time) {
return vec3(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance.
vec3 apply_underwater(vec3 color, float dist, float cam_z, float water_z) {
if (cam_z >= water_z - 0.5) return color;
const vec3 absorb = vec3(0.35, 0.62, 0.75);
float fog = clamp(1.0 - exp(-dist * 0.00022), 0.0, 0.9);
return mix(color * absorb, vec3(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed sky reflection and depth-graded seabed refraction,
// SAGE diffuse + specular lighting, cloud term, depth-based transparency fade.
vec3 water_shade(vec3 hitpos, vec3 dir, float dist) {
float time = u_misc.x;
float river = u_misc.y;
float seabed = world_height(hitpos.x, hitpos.y);
float depth = max(0.0, u_params.z - seabed);
vec3 n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
vec3 sun_dir = normalize(u_sun.xyz);
float cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
float fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
vec3 reflection = sky_color(reflect(dir, n));
vec3 shallow = vec3(0.10, 0.34, 0.38);
vec3 deep = vec3(0.02, 0.12, 0.22);
vec3 refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
float ndotl = max(dot(n, sun_dir), 0.0);
float ambient = u_sun.w;
vec3 diffuse = vec3(ambient + (1.0 - ambient) * ndotl);
vec3 half_v = normalize(sun_dir - dir);
float spec = pow(max(dot(n, half_v), 0.0), 90.0);
vec3 color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += vec3(1.0, 0.97, 0.9) * spec * 0.45;
float alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) alpha *= WATER_RIVER_MULTIPLIER;
color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
float wfog = clamp(1.0 - exp(-dist * 0.00009), 0.0, 0.75);
return mix(color, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
}
void main() { void main() {
vec4 p = u_cam; vec4 p = u_cam;
float pitch = clamp(u_params.x, 0.15, 1.45); float pitch = clamp(u_params.x, 0.15, 1.45);
@@ -105,52 +196,78 @@ void main() {
vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); vec3 dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
frag_color = vec4(sky_color(dir), 1.0); frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0);
return; return;
} }
// March the heightfield. The step grows quickly: the map diagonal is only // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail, and // so the silhouette there stays razor-sharp instead of stair-stepping
// a 6% growth rate more than doubles the worst-case iteration count. // across it. Outside the map is sky.
float t = CELL * 0.5; float t_enter = 0.0;
float dt = CELL * 0.5; float t_exit = 1.0e30;
bool inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam.x >= 0.0 && cam.x <= world_w);
} else {
float a = (0.0 - cam.x) / dir.x;
float b = (world_w - cam.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam.y >= 0.0 && cam.y <= world_h);
} else {
float a = (0.0 - cam.y) / dir.y;
float b = (world_h - cam.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0);
return;
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
float horiz = max(abs(dir.x), abs(dir.y));
float cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
float t = max(t_enter, CELL * 0.5);
float prev = t; float prev = t;
bool hit = false; bool hit = false;
float hit_t = 0.0; float hit_t = 0.0;
for (int i = 0; i < 256 && t < 20000.0; ++i) { for (int i = 0; i < 1024 && t <= t_exit; ++i) {
vec3 w = cam + dir * t; vec3 w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { float h = world_height(w.x, w.y);
prev = t; float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h;
dt *= 1.10; if (w.z <= surface) {
t += dt;
continue;
}
if (u_params.w > 0.5 && w.z <= u_params.z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
float clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t; prev = t;
dt *= 1.10; t += clamp(clearance, cell_step, cell_step * 8.0);
t += dt;
} }
if (!hit) { if (!hit) {
frag_color = vec4(sky_color(dir), 1.0); frag_color = vec4(apply_underwater(sky_color(dir), 0.0, cam.z, u_params.z), 1.0);
return; return;
} }
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
float lo = prev; float lo = prev;
float hi = hit_t; float hi = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 18; ++i) {
float mid = 0.5 * (lo + hi); float mid = 0.5 * (lo + hi);
vec3 w = cam + dir * mid; vec3 w = cam + dir * mid;
bool water = u_params.w > 0.5 && w.z <= u_params.z; float h = world_height(w.x, w.y);
if (water || w.z <= world_height(w.x, w.y)) { float surface = (u_params.w > 0.5) ? max(h, u_params.z) : h;
if (w.z <= surface) {
hi = mid; hi = mid;
} else { } else {
lo = mid; lo = mid;
@@ -162,20 +279,7 @@ void main() {
float ambient = u_sun.w; float ambient = u_sun.w;
if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) { if (u_params.w > 0.5 && hitpos.z <= u_params.z + 0.01) {
float time = u_misc.x; frag_color = vec4(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, u_params.z), 1.0);
vec2 q = hitpos.xy * 0.015;
float nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
float ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
vec3 n = normalize(vec3(nx * 0.06, ny * 0.06, 1.0));
float fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
vec3 deep = vec3(0.03, 0.16, 0.28);
vec3 refl = sky_color(reflect(dir, n));
float lam = max(0.0, dot(n, sun));
vec3 water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += vec3(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
float wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = mix(water, sky_color(vec3(dir.x, dir.y, 0.0)), wfog);
frag_color = vec4(water, 1.0);
return; return;
} }
@@ -210,5 +314,5 @@ void main() {
vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert); vec3 lit = albedo * (ambient + (1.0 - ambient) * lambert);
float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); float fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog); lit = mix(lit, sky_color(vec3(dir.x, dir.y, 0.0)), fog);
frag_color = vec4(lit, 1.0); frag_color = vec4(apply_underwater(lit, hi, cam.z, u_params.z), 1.0);
} }
+144 -39
View File
@@ -17,6 +17,12 @@ struct TerrainUniforms {
const CELL: f32 = 10.0; // must match ra3::terrain::cell_size const CELL: f32 = 10.0; // must match ra3::terrain::cell_size
// SAGE water model constants (see docs/REVERSE_ENGINEERING.md).
const WATER_SCALE: f32 = 1.0 / 320.0;
const WATER_TRANSPARENT_DEPTH: f32 = 10.0;
const WATER_MIN_OPACITY: f32 = 0.70;
const WATER_RIVER_MULTIPLIER: f32 = 1.0;
fn cam_uniform() -> vec4<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height fn cam_uniform() -> vec4<f32> { return u.data[0]; } // x=target_x, y=target_y, z=yaw, w=height
fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water fn params_uniform() -> vec4<f32> { return u.data[1]; } // x=pitch, y=fov, z=water_z, w=has_water
fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient fn sun_uniform() -> vec4<f32> { return u.data[2]; } // xyz=sun dir, w=ambient
@@ -100,6 +106,92 @@ fn sample_layer(layer: u32, wx: f32, wy: f32) -> vec3<f32> {
return vec3<f32>(c.b, c.g, c.r); return vec3<f32>(c.b, c.g, c.r);
} }
// ---- SAGE water (Ocean.fx / RiverWater.fx / Water.frag port) ----------------
// Sample an atlas layer by an explicit layer index (the water flow/bump maps
// are appended as the last two layers of the tile atlas). The atlas is
// 0xAARRGGBB, so the b/g/r swap restores RGB (as `sample_layer`).
fn water_tex(layer: i32, uv: vec2<f32>) -> vec3<f32> {
let lc = i32(textureNumLayers(u_atlas));
let l = clamp(layer, 0, max(lc - 1, 0));
let c = textureSampleLevel(u_atlas, u_atlas_samp, uv, l, 0.0);
return vec3<f32>(c.b, c.g, c.r);
}
// Scrolling wave normal on the water plane: the retail bump map (atlas's last
// layer), offset by the flow map (second-last layer) and combined with a
// de-gridded procedural wave so the sun glint is irregular and always moving.
fn water_normal(world_xy: vec2<f32>, time: f32) -> vec3<f32> {
let q = world_xy * (WATER_SCALE * 6.0);
let a1 = q.x * 0.80 + q.y * 0.30 + time * 1.10;
let a2 = q.y * 0.95 - q.x * 0.45 - time * 1.30;
let a3 = (q.x + q.y) * 1.60 + time * 2.10;
let a4 = (q.x - q.y) * 2.30 - time * 1.70;
let dx = 0.224 * cos(a1) - 0.099 * cos(a2) + 0.256 * cos(a3) + 0.230 * cos(a4);
let dy = 0.084 * cos(a1) + 0.209 * cos(a2) + 0.256 * cos(a3) - 0.230 * cos(a4);
let lc = i32(textureNumLayers(u_atlas));
let flow = water_tex(lc - 2, q - vec2<f32>(time * 0.010, time * 0.014)) * 2.0 - 1.0;
let bump = water_tex(lc - 1, q + flow.xy * 0.05 + vec2<f32>(time * 0.006, time * 0.008)) * 2.0 - 1.0;
let sx = -dx * 0.30 + bump.x * 0.45;
let sy = -dy * 0.30 + bump.y * 0.45;
return normalize(vec3<f32>(sx, sy, 1.0));
}
// Water.frag distortionPower * the flow texture: a small scrolling UV offset.
fn water_distortion(world_xy: vec2<f32>, time: f32) -> f32 {
let q = world_xy * (WATER_SCALE * 6.0);
return 0.05 * (sin(q.x * 0.9 + time * 0.7) + sin(q.y * 1.1 - time * 0.5));
}
// Water.frag GetCloudColor: no cloud texture is bound, so a slow scroll stands in.
fn water_cloud(world_xy: vec2<f32>, time: f32) -> vec3<f32> {
return vec3<f32>(0.85 + 0.15 * sin((world_xy.x + world_xy.y) * 0.0007 - time * 0.05));
}
// Retail UnderwaterDeferred.fx: absorbs red and fogs with distance.
fn apply_underwater(color: vec3<f32>, distance: f32, cam_z: f32, water_z: f32) -> vec3<f32> {
if (cam_z >= water_z - 0.5) { return color; }
let absorb = vec3<f32>(0.35, 0.62, 0.75);
let fog = clamp(1.0 - exp(-distance * 0.00022), 0.0, 0.9);
return mix(color * absorb, vec3<f32>(0.02, 0.10, 0.16), fog);
}
// Water.frag: fresnel-mixed sky reflection and depth-graded seabed refraction,
// SAGE diffuse + specular lighting, cloud term, depth-based transparency fade.
fn water_shade(hitpos: vec3<f32>, dir: vec3<f32>, distance: f32) -> vec3<f32> {
let time = misc_uniform().x;
let river = misc_uniform().y;
let seabed = world_height(hitpos.x, hitpos.y);
let depth = max(0.0, params_uniform().z - seabed);
let n = water_normal(hitpos.xy, time + water_distortion(hitpos.xy, time));
let sun_dir = normalize(sun_uniform().xyz);
let cos_theta = clamp(dot(-dir, n), 0.0, 1.0);
let fresnel = 0.02 + 0.98 * pow(1.0 - cos_theta, 5.0);
let reflection = sky_color(reflect(dir, n));
let shallow = vec3<f32>(0.10, 0.34, 0.38);
let deep = vec3<f32>(0.02, 0.12, 0.22);
let refraction = mix(shallow, deep, clamp(depth / 40.0, 0.0, 1.0));
let ndotl = max(dot(n, sun_dir), 0.0);
let ambient = sun_uniform().w;
let diffuse = vec3<f32>(ambient + (1.0 - ambient) * ndotl);
let half_v = normalize(sun_dir - dir);
let spec = pow(max(dot(n, half_v), 0.0), 90.0);
var color = mix(refraction, reflection, clamp(fresnel, 0.0, 1.0)) * diffuse * water_cloud(hitpos.xy, time);
color += vec3<f32>(1.0, 0.97, 0.9) * spec * 0.45;
var alpha = clamp(depth / WATER_TRANSPARENT_DEPTH, 0.0, 1.0) * WATER_MIN_OPACITY;
if (river > 0.5) { alpha *= WATER_RIVER_MULTIPLIER; }
color = mix(refraction, color, clamp(alpha + 0.15, 0.0, 1.0));
let wfog = clamp(1.0 - exp(-distance * 0.00009), 0.0, 0.75);
return mix(color, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), wfog);
}
@fragment @fragment
fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> { fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let p = cam_uniform(); let p = cam_uniform();
@@ -124,49 +216,75 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th); let dir = normalize(fwd + right * ndc.x * th * aspect + up * ndc.y * th);
if (dir.z >= -1e-4) { if (dir.z >= -1e-4) {
return vec4<f32>(sky_color(dir), 1.0); return vec4<f32>(apply_underwater(sky_color(dir), 0.0, cam.z, params_uniform().z), 1.0);
} }
// March the heightfield. The step grows quickly: the map diagonal is only // Clip the ray to the map's XY rectangle: the boundary is an exact plane,
// ~9000 world units, so marching past ~20000 adds cost without detail. // so the silhouette there stays razor-sharp instead of stair-stepping
var t = CELL * 0.5; // across it. Outside the map is sky.
var dt = CELL * 0.5; var t_enter = 0.0;
var t_exit = 1.0e30;
var inside = true;
if (abs(dir.x) < 1e-6) {
inside = (cam.x >= 0.0 && cam.x <= world_w);
} else {
let a = (0.0 - cam.x) / dir.x;
let b = (world_w - cam.x) / dir.x;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
if (inside) {
if (abs(dir.y) < 1e-6) {
inside = (cam.y >= 0.0 && cam.y <= world_h);
} else {
let a = (0.0 - cam.y) / dir.y;
let b = (world_h - cam.y) / dir.y;
t_enter = max(t_enter, min(a, b));
t_exit = min(t_exit, max(a, b));
}
}
if (!inside || t_exit <= 0.0) {
return vec4<f32>(apply_underwater(sky_color(dir), 0.0, cam.z, params_uniform().z), 1.0);
}
// March the heightfield cell by cell: the step is never longer than the
// time to cross one cell (dominant horizontal axis), while a clearance term
// lets the ray skip the empty air above the surface. Resolving every cell is
// what keeps cliff and map-edge silhouettes from quantising into huge
// stair-steps that crawl as the camera pans.
let horiz = max(abs(dir.x), abs(dir.y));
let cell_step = min(CELL / max(horiz, 1e-4), CELL * 32.0);
var t = max(t_enter, CELL * 0.5);
var prev = t; var prev = t;
var hit = false; var hit = false;
var hit_t = 0.0; var hit_t = 0.0;
for (var i = 0; i < 256 && t < 20000.0; i = i + 1) { for (var i = 0; i < 1024 && t <= t_exit; i = i + 1) {
let w = cam + dir * t; let w = cam + dir * t;
if (w.x < 0.0 || w.y < 0.0 || w.x >= world_w || w.y >= world_h) { let h = world_height(w.x, w.y);
prev = t; let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5);
dt *= 1.10; if (w.z <= surface) {
t += dt;
continue;
}
if (params_uniform().w > 0.5 && w.z <= params_uniform().z) {
hit = true;
hit_t = t;
break;
}
if (w.z <= world_height(w.x, w.y)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
let clearance = (w.z - surface) / max(-dir.z, 1e-4);
prev = t; prev = t;
dt *= 1.10; t += clamp(clearance, cell_step, cell_step * 8.0);
t += dt;
} }
if (!hit) { if (!hit) {
return vec4<f32>(sky_color(dir), 1.0); return vec4<f32>(apply_underwater(sky_color(dir), 0.0, cam.z, params_uniform().z), 1.0);
} }
// Refine the first crossing; with a sub-cell bracket this converges to the
// exact surface point.
var lo = prev; var lo = prev;
var hi = hit_t; var hi = hit_t;
for (var i = 0; i < 6; i = i + 1) { for (var i = 0; i < 18; i = i + 1) {
let mid = 0.5 * (lo + hi); let mid = 0.5 * (lo + hi);
let w = cam + dir * mid; let w = cam + dir * mid;
let water = params_uniform().w > 0.5 && w.z <= params_uniform().z; let h = world_height(w.x, w.y);
if (water || w.z <= world_height(w.x, w.y)) { let surface = select(h, max(h, params_uniform().z), params_uniform().w > 0.5);
if (w.z <= surface) {
hi = mid; hi = mid;
} else { } else {
lo = mid; lo = mid;
@@ -178,20 +296,7 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
let ambient = sun_uniform().w; let ambient = sun_uniform().w;
if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) { if (params_uniform().w > 0.5 && hitpos.z <= params_uniform().z + 0.01) {
let time = misc_uniform().x; return vec4<f32>(apply_underwater(water_shade(hitpos, dir, hi), hi, cam.z, params_uniform().z), 1.0);
let q = hitpos.xy * 0.015;
let nx = sin(q.x * 1.3 + time * 1.7) + 0.5 * sin(q.x * 3.1 - time * 2.3);
let ny = sin(q.y * 1.1 - time * 1.3) + 0.5 * sin(q.y * 2.7 + time * 1.9);
let n = normalize(vec3<f32>(nx * 0.06, ny * 0.06, 1.0));
let fres = pow(1.0 - clamp(-dir.z, 0.0, 1.0), 3.0);
let deep = vec3<f32>(0.03, 0.16, 0.28);
let refl = sky_color(reflect(dir, n));
let lam = max(0.0, dot(n, sun));
var water = mix(deep, refl, clamp(0.25 + 0.55 * fres, 0.0, 0.9));
water += vec3<f32>(1.0, 0.98, 0.9) * pow(lam, 64.0) * 0.6;
let wfog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
water = mix(water, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), wfog);
return vec4<f32>(water, 1.0);
} }
let wx = hitpos.x / CELL; let wx = hitpos.x / CELL;
@@ -225,5 +330,5 @@ fn fs_main(in: TerrainOut) -> @location(0) vec4<f32> {
var lit = albedo * (ambient + (1.0 - ambient) * lambert); var lit = albedo * (ambient + (1.0 - ambient) * lambert);
let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75); let fog = clamp(1.0 - exp(-hi * 0.00009), 0.0, 0.75);
lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog); lit = mix(lit, sky_color(vec3<f32>(dir.x, dir.y, 0.0)), fog);
return vec4<f32>(lit, 1.0); return vec4<f32>(apply_underwater(lit, hi, cam.z, params_uniform().z), 1.0);
} }
+25 -13
View File
@@ -277,13 +277,15 @@ export namespace ra3::client {
} else if (event.type == ui_event_type::mouse_move) { } else if (event.type == ui_event_type::mouse_move) {
mouse_x = event.x; mouse_x = event.x;
mouse_y = event.y; mouse_y = event.y;
if (event.left) { if (event.middle) {
drag_x += event.dx; drag_x += event.dx;
drag_y += event.dy; drag_y += event.dy;
} }
} else if (event.type == ui_event_type::wheel) { } else if (event.type == ui_event_type::wheel) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height); if (event.wheel != 0.0F) {
dirty = true; camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
dirty = true;
}
} }
} }
if (!running) break; if (!running) break;
@@ -345,8 +347,9 @@ export namespace ra3::client {
/** /**
* GPU terrain viewer. `present_terrain` draws the heightfield; the loop * GPU terrain viewer. `present_terrain` draws the heightfield; the loop
* here owns the camera controls. The top-left shows the FPS (current / * here owns the camera controls. The top-left shows the FPS (current /
* cap) and, when `minimap_overview` is not empty, a corner minimap with * cap) tagged with the active backend name (e.g. `[vulkan]`) and, when
* the camera location is drawn. * `minimap_overview` is not empty, a corner minimap with the camera
* location is drawn.
*/ */
[[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool { [[nodiscard]] auto terrain_loop(const ra3::terrain::gpu_terrain &terrain, ra3::render::camera3d camera, const image &minimap_overview) -> bool {
if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false; if (terrain.width == 0U || terrain.height == 0U || !this->supports_terrain()) return false;
@@ -372,6 +375,7 @@ export namespace ra3::client {
bool presented = false; bool presented = false;
const auto default_camera = camera; const auto default_camera = camera;
bool middle_dragged = false; bool middle_dragged = false;
bool middle_down = false;
while (running) { while (running) {
ui_event event; ui_event event;
float drag_x = 0.0F; float drag_x = 0.0F;
@@ -398,15 +402,23 @@ export namespace ra3::client {
if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true; if (event.dx != 0.0F || event.dy != 0.0F) middle_dragged = true;
} }
} else if (event.type == ui_event_type::wheel) { } else if (event.type == ui_event_type::wheel) {
camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height); if (event.wheel != 0.0F) {
camera_moved = true; camera.height = std::clamp(camera.height * (event.wheel > 0.0F ? (1.0F / 1.15F) : 1.15F), camera.min_height, camera.max_height);
} else if (event.type == ui_event_type::mouse_button && event.middle && event.released) {
if (!middle_dragged) {
camera.yaw = default_camera.yaw;
camera.pitch = default_camera.pitch;
camera.height = default_camera.height;
camera_moved = true; camera_moved = true;
} }
} else if (event.type == ui_event_type::mouse_button && event.middle) {
if (event.released) {
if (middle_down && !middle_dragged) {
camera.yaw = default_camera.yaw;
camera.pitch = default_camera.pitch;
camera.height = default_camera.height;
camera_moved = true;
}
middle_down = false;
} else {
middle_down = true;
middle_dragged = false;
}
} }
} }
if (!running) break; if (!running) break;
@@ -466,7 +478,7 @@ export namespace ra3::client {
fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s)); fps = static_cast<uint32>(std::lround(static_cast<float>(fps_frames) / window_s));
fps_frames = 0; fps_frames = 0;
fps_window = now; fps_window = now;
overlay.label = ra3::render::compose_fps_label(fps, fps_limit_); overlay.label = ra3::render::compose_fps_label(fps, fps_limit_, this->name());
overlay.label_changed = true; overlay.label_changed = true;
} }
} else { } else {
+58 -190
View File
@@ -3,13 +3,16 @@ export module ra3.fs;
import std; import std;
export import ra3.core; export import ra3.core;
import ra3.assets;
/** /**
* Reading of the retail game's on-disk assets. * Reading of the retail game's on-disk assets.
* *
* Red Alert 3 ships its data in `BIG4` archives under `<install>\Data`, with * Red Alert 3 ships its data in `BIG4` archives under `<install>\Data`, with
* individual payloads compressed by EA's RefPack codec. This module implements * individual payloads compressed by EA's RefPack codec. The container and codec
* the container and codec so OpenRA3 can read a user's own installation. * are implemented by the vendored `libra3assets` (`ra3.assets`, a sibling of
* `libenderlog`); this module is the thin adapter OpenRA3's loader talks to, so
* the format knowledge lives in one place.
* *
* No game data is ever written into the repository; callers point the loader at * 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`). * their local install (`--game-dir` / `RA3_GAME_DIR`, default `C:\Red Alert 3`).
@@ -42,11 +45,21 @@ export namespace ra3::fs {
uint32 size = 0; uint32 size = 0;
}; };
/** True when `data` starts with a RefPack header (`0b??010000`, `0xFB`). */ namespace detail {
[[nodiscard]] inline auto is_refpack(std::span<const uint8> data) -> bool { /** View a `uint8` range as bytes, the currency of `libra3assets`. */
return data.size() >= 2 && (data[0] & refpack_mask) == 0x10U && data[1] == refpack_magic2; [[nodiscard]] inline auto as_bytes(std::span<const uint8> data) -> std::span<const std::byte> { return std::as_bytes(data); }
/** Copy a `libra3assets` byte buffer into OpenRA3's `uint8` vector. */
[[nodiscard]] inline auto to_u8(std::vector<std::byte> bytes) -> std::vector<uint8> {
std::vector<uint8> out(bytes.size());
if (!bytes.empty()) std::memcpy(out.data(), bytes.data(), bytes.size());
return out;
}
} }
/** True when `data` starts with a RefPack header (`0b??010000`, `0xFB`). */
[[nodiscard]] inline auto is_refpack(std::span<const uint8> data) -> bool { return ra3::assets::is_refpack(detail::as_bytes(data)); }
/** /**
* Decompress an EA RefPack stream. * Decompress an EA RefPack stream.
* *
@@ -55,80 +68,17 @@ export namespace ra3::fs {
* @throws refpack_error if the stream is malformed or the length disagrees. * @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> { [[nodiscard]] inline auto refpack_decompress(std::span<const uint8> data) -> std::vector<uint8> {
if (!is_refpack(data)) throw refpack_error("not a RefPack stream"); try {
return detail::to_u8(ra3::assets::refpack_decompress(detail::as_bytes(data)));
usize pos = 0; } catch (const ra3::assets::refpack_error &error) {
const auto header = data[pos++]; throw refpack_error(error.what());
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. */ /** Decompress `data` when it is RefPack, otherwise copy it unchanged. */
[[nodiscard]] inline auto maybe_decompress(std::span<const uint8> data) -> std::vector<uint8> { [[nodiscard]] inline auto maybe_decompress(std::span<const uint8> data) -> std::vector<uint8> {
if (is_refpack(data)) return refpack_decompress(data); if (!is_refpack(data)) return {data.begin(), data.end()};
return {data.begin(), data.end()}; return refpack_decompress(data);
} }
/** /**
@@ -137,23 +87,11 @@ export namespace ra3::fs {
* @throws refpack_error if the stream is malformed. * @throws refpack_error if the stream is malformed.
*/ */
[[nodiscard]] inline auto refpack_output_size(std::span<const uint8> data) -> uint32 { [[nodiscard]] inline auto refpack_output_size(std::span<const uint8> data) -> uint32 {
if (!is_refpack(data)) throw refpack_error("not a RefPack stream"); try {
usize pos = 0; return ra3::assets::refpack_output_size(detail::as_bytes(data));
const auto header = data[pos++]; } catch (const ra3::assets::refpack_error &error) {
const bool large_files = (header & 0x80U) != 0; throw refpack_error(error.what());
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 { [[nodiscard]] constexpr auto read_be32(const uint8 *p) -> uint32 {
@@ -165,81 +103,32 @@ export namespace ra3::fs {
} }
/** /**
* A parsed `BIG4` archive. Only the index is held in memory; payloads are * A parsed `BIG4` archive.
* read from disk on demand so multi-hundred-megabyte archives stay cheap. *
* The index and payloads are held by a `ra3::assets::big_archive`; this
* adapter exposes the OpenRA3-facing surface (`big_entry`, `uint8` buffers)
* over it. Payloads are RefPack-decompressed on request.
*/ */
class big_archive { class big_archive {
public: public:
/** /**
* Parse the index of a `BIG4` archive. * Parse a `BIG4` archive.
* *
* @param path Archive path. * @param path Archive path.
* @throws archive_error if the file is missing, not `BIG4`, or truncated. * @throws archive_error if the file is missing, not `BIG4`, or truncated.
*/ */
[[nodiscard]] static auto open(const std::filesystem::path &path) -> big_archive { [[nodiscard]] static auto open(const std::filesystem::path &path) -> big_archive {
big_archive archive; try {
archive.path_ = path; return big_archive{ra3::assets::big_archive::open(path), path};
std::error_code ec; } catch (const ra3::assets::asset_error &error) {
archive.file_size_ = static_cast<usize>(std::filesystem::file_size(path, ec)); throw archive_error(error.what());
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 path() const -> const std::filesystem::path & { return path_; }
[[nodiscard]] auto entries() const -> const std::vector<big_entry> & { return entries_; } [[nodiscard]] auto entries() const -> const std::vector<big_entry> & { return entries_; }
[[nodiscard]] auto size() const -> usize { return entries_.size(); } [[nodiscard]] auto size() const -> usize { return entries_.size(); }
[[nodiscard]] auto contains(std::string_view name) const -> bool { return index_.contains(std::string{name}); } [[nodiscard]] auto contains(std::string_view name) const -> bool { return archive_.contains(name); }
/** Entry names whose path contains `needle`, in index order. */ /** Entry names whose path contains `needle`, in index order. */
[[nodiscard]] auto find(std::string_view needle) const -> std::vector<const big_entry *> { [[nodiscard]] auto find(std::string_view needle) const -> std::vector<const big_entry *> {
@@ -258,42 +147,33 @@ export namespace ra3::fs {
* @throws archive_error if the entry is missing or unreadable. * @throws archive_error if the entry is missing or unreadable.
*/ */
[[nodiscard]] auto read(std::string_view name, bool decompress = true) const -> std::vector<uint8> { [[nodiscard]] auto read(std::string_view name, bool decompress = true) const -> std::vector<uint8> {
const auto it = index_.find(std::string{name}); try {
if (it == index_.end()) throw archive_error("no such entry: " + std::string{name}); return detail::to_u8(archive_.read(name, decompress));
const auto &entry = entries_[it->second]; } catch (const ra3::assets::refpack_error &error) {
throw refpack_error(error.what());
std::ifstream in(path_, std::ios::binary); } catch (const ra3::assets::asset_error &error) {
if (!in) throw archive_error("cannot open archive: " + path_.string()); throw archive_error(error.what());
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). */ /** 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> { [[nodiscard]] auto read_prefix(std::string_view name, usize count) const -> std::vector<uint8> {
const auto it = index_.find(std::string{name}); try {
if (it == index_.end()) throw archive_error("no such entry: " + std::string{name}); return detail::to_u8(archive_.read_prefix(name, count));
const auto &entry = entries_[it->second]; } catch (const ra3::assets::asset_error &error) {
throw archive_error(error.what());
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: private:
big_archive() = default; big_archive(ra3::assets::big_archive archive, std::filesystem::path path) : archive_(std::move(archive)), path_(std::move(path)) {
entries_.reserve(archive_.size());
for (const auto &entry: archive_.entries()) entries_.push_back({entry.name, entry.offset, entry.size});
}
ra3::assets::big_archive archive_;
std::filesystem::path path_; std::filesystem::path path_;
usize file_size_ = 0;
std::vector<big_entry> entries_; std::vector<big_entry> entries_;
std::unordered_map<std::string, usize> index_;
}; };
/** /**
@@ -306,18 +186,6 @@ export namespace ra3::fs {
* @return The install root, or `std::nullopt` when none is found. * @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> { [[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) { return ra3::assets::find_game_dir(explicit_dir);
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;
} }
} }
+55 -233
View File
@@ -4,6 +4,7 @@ import std;
export import ra3.core; export import ra3.core;
export import ra3.fs; export import ra3.fs;
import ra3.assets;
/** /**
* Red Alert 3 map discovery and loading. * Red Alert 3 map discovery and loading.
@@ -50,6 +51,9 @@ export namespace ra3::map {
}; };
namespace detail { namespace detail {
/** View a `uint8` range as bytes, the currency of `libra3assets`. */
[[nodiscard]] inline auto as_bytes(std::span<const uint8> data) -> std::span<const std::byte> { return std::as_bytes(data); }
[[nodiscard]] inline auto split_path(std::string_view path) -> std::vector<std::string> { [[nodiscard]] inline auto split_path(std::string_view path) -> std::vector<std::string> {
std::vector<std::string> parts; std::vector<std::string> parts;
usize start = 0; usize start = 0;
@@ -82,54 +86,6 @@ export namespace ra3::map {
return file; 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). */ /** Number of distinct integer positions (used to reject degenerate sets). */
[[nodiscard]] inline auto distinct_positions(const std::vector<start_position> &starts) -> usize { [[nodiscard]] inline auto distinct_positions(const std::vector<start_position> &starts) -> usize {
std::vector<std::pair<int, int>> seen; std::vector<std::pair<int, int>> seen;
@@ -196,9 +152,24 @@ export namespace ra3::map {
return fs::maybe_decompress(payload); return fs::maybe_decompress(payload);
} }
/** Recover `Player_N_Start` waypoints from raw, uncompressed `CkMp` bytes. */ /**
* Recover `Player_N_Start` waypoints from raw, uncompressed `CkMp` bytes.
*
* `libra3assets` decodes the `ObjectsList` `*Waypoints/Waypoint` objects and
* returns the position of each `waypointName == "Player_<n>_Start"`. This
* replaces the previous whole-buffer heuristic, which scanned for the string
* and then took the first plausible float triple *after* it — off by one
* (the position precedes the name in each object) and prone to matching
* arbitrary bytes.
*/
[[nodiscard]] inline auto starts_from_ckmp(std::span<const uint8> ckmp) -> std::vector<start_position> { [[nodiscard]] inline auto starts_from_ckmp(std::span<const uint8> ckmp) -> std::vector<start_position> {
auto starts = detail::extract_start_positions(ckmp); std::vector<start_position> starts;
try {
const auto document = ra3::assets::map_document::parse(detail::as_bytes(ckmp));
for (const auto &start: document.player_starts()) starts.push_back({start.position.x, start.position.y, start.position.z});
} catch (const std::exception &) {
return {};
}
if (detail::distinct_positions(starts) < 2U) starts.clear(); if (detail::distinct_positions(starts) < 2U) starts.clear();
return starts; return starts;
} }
@@ -221,117 +192,34 @@ export namespace ra3::map {
uint32 road_type = 0U; ///< SAGE `RoadType` flags; 0 for a non-road object. uint32 road_type = 0U; ///< SAGE `RoadType` flags; 0 for a non-road object.
}; };
namespace detail {
struct ckmp_chunk {
std::string name;
uint16 version = 0;
usize offset = 0;
usize size = 0;
};
/** Parse the `CkMp` chunk tree and its `index -> name` table. */
[[nodiscard]] inline auto parse_ckmp_chunks(std::span<const uint8> data) -> std::pair<std::vector<std::string>, std::vector<ckmp_chunk>> {
if (data.size() < 8U || std::memcmp(data.data(), "CkMp", 4) != 0) return {};
usize pos = 4;
const auto read_u32 = [&](usize at) {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U) | (static_cast<uint32>(data[at + 2U]) << 16U) |
(static_cast<uint32>(data[at + 3U]) << 24U);
};
const auto read_u16 = [&](usize at) { return static_cast<uint16>(static_cast<uint32>(data[at]) | (static_cast<uint32>(data[at + 1U]) << 8U)); };
const auto count = read_u32(pos);
pos += 4;
std::vector<std::string> names(count + 1U);
for (uint32 i = count; i >= 1U && pos < data.size(); --i) {
const auto len = data[pos++];
if (pos + len + 4U > data.size()) break;
names[i] = std::string{reinterpret_cast<const char *>(data.data() + pos), len};
pos += len + 4U;
}
std::vector<ckmp_chunk> chunks;
while (pos + 10U <= data.size()) {
const auto index = read_u32(pos);
const auto version = read_u16(pos + 4U);
const auto size = read_u32(pos + 6U);
pos += 10U;
if (index >= names.size() || pos + size > data.size()) break;
chunks.push_back({names[index], version, pos, size});
pos += size;
}
return {std::move(names), std::move(chunks)};
}
}
/** /**
* Every object the map places (the `ObjectsList` chunk). * Every object the map places (the `ObjectsList` chunk).
* *
* Layout (OpenSAGE `Data/Map/{ObjectsList,MapObject,AssetProperty}.cs`): * Decoded by `libra3assets` (`ra3.assets`): the map is parsed as a
* the chunk is a list of nested `Object` assets, each a `Coord3D`, a Z * `map_document`, whose `ObjectsList` accessor walks the nested `Object`
* `angle`, a `RoadType`, a `u16`-prefixed type-name and a property list * assets (a `Coord3D`, a Z `angle`, a `RoadType`, a `u16`-prefixed
* whose keys index the shared name table. * type-name and a property list keyed into the shared name table).
* *
* @return The objects in chunk order; empty when the map has no object list. * @return The objects in chunk order; empty when the map is malformed or has
* no object list.
*/ */
[[nodiscard]] inline auto parse_objects(std::span<const uint8> ckmp) -> std::vector<map_object> { [[nodiscard]] inline auto parse_objects(std::span<const uint8> ckmp) -> std::vector<map_object> {
const auto [names, chunks] = detail::parse_ckmp_chunks(ckmp);
(void) names;
std::vector<map_object> objects; std::vector<map_object> objects;
const auto read_u16 = [&](usize at) { return static_cast<uint16>(static_cast<uint32>(ckmp[at]) | (static_cast<uint32>(ckmp[at + 1U]) << 8U)); }; try {
const auto read_u32 = [&](usize at) { const auto document = ra3::assets::map_document::parse(detail::as_bytes(ckmp));
return static_cast<uint32>(ckmp[at]) | (static_cast<uint32>(ckmp[at + 1U]) << 8U) | (static_cast<uint32>(ckmp[at + 2U]) << 16U) | for (const auto &object: document.objects()) {
(static_cast<uint32>(ckmp[at + 3U]) << 24U); map_object out;
}; out.type = object.type_name;
const auto read_f32 = [&](usize at) { out.x = object.position.x;
const auto bits = read_u32(at); out.y = object.position.y;
real value = 0.0F; out.z = object.position.z;
std::memcpy(&value, &bits, sizeof(value)); out.angle = object.angle;
return value; out.scale = 1.0F;
}; out.road_type = static_cast<uint32>(object.road);
objects.push_back(std::move(out));
for (const auto &chunk: chunks) {
if (chunk.name != "ObjectsList") continue;
usize p = chunk.offset;
const auto end = chunk.offset + chunk.size;
while (p + 6U <= end) {
p += 4U; // asset index (always `Object`)
p += 2U; // asset version
const auto asset_size = read_u32(p);
p += 4U;
if (asset_size < 24U || p + asset_size > end) break;
const auto asset_end = p + asset_size;
map_object object;
object.x = read_f32(p);
object.y = read_f32(p + 4U);
object.z = read_f32(p + 8U);
object.angle = read_f32(p + 12U);
object.road_type = read_u32(p + 16U);
p += 20U; // Coord3D + angle + road type
const auto name_len = read_u16(p);
p += 2U;
if (p + name_len > asset_end) break;
object.type = std::string{reinterpret_cast<const char *>(ckmp.data() + p), name_len};
p += name_len;
const auto property_count = static_cast<uint32>(read_u16(p));
p += 2U;
for (uint32 i = 0; i < property_count && p + 4U <= asset_end; ++i) {
const auto type = ckmp[p++];
p += 3U; // property name index (u24) into the shared name table
usize value_size = 0;
if (type == 0U) {
value_size = 1U; // boolean
} else if (type == 1U || type == 2U) {
value_size = 4U; // integer / real
} else if (p + 2U <= asset_end) {
const auto len = read_u16(p);
value_size = 2U + static_cast<usize>(len) * ((type == 4U) ? 2U : 1U); // ascii/unicode/unknown
}
p += value_size;
if (p > asset_end) break;
}
objects.push_back(std::move(object));
p = asset_end;
} }
} catch (const std::exception &) {
return {};
} }
return objects; return objects;
} }
@@ -351,96 +239,30 @@ export namespace ra3::map {
} }
}; };
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. * 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` * 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 * under the key `MAP:<UPPERCASE_ID>` (e.g. `MAP:MAP_MP_2_FEASEL4` is
* "Battlebase Beta"). Values are byte-XORed with `0xFF`. * "Battlebase Beta"). Decoding is `libra3assets`' `csf_table` (each UTF-16
* code unit is bit-inverted; the table lookup is case-insensitive).
*/ */
[[nodiscard]] inline auto parse_map_names(std::span<const uint8> csf) -> map_name_table { [[nodiscard]] inline auto parse_map_names(std::span<const uint8> csf) -> map_name_table {
map_name_table table; map_name_table table;
if (csf.size() < 24U || std::memcmp(csf.data(), " FSC", 4) != 0) return table; try {
const auto strings = ra3::assets::csf_table::parse(detail::as_bytes(csf));
const auto read = [&](usize p) -> uint32 { for (const auto &entry: strings.entries()) {
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:"; constexpr std::string_view prefix = "MAP:";
if (label.size() > prefix.size() && label.compare(0, prefix.size(), prefix) == 0) { if (entry.label.size() <= prefix.size() || entry.label.compare(0, prefix.size(), prefix) != 0) continue;
auto id = label.substr(prefix.size()); auto id = entry.label.substr(prefix.size());
std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); }); 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)); auto value = ra3::assets::utf16_to_utf8(entry.value());
} // CSF strings are NUL-terminated; the terminator is not part of the text.
if (const auto nul = value.find('\0'); nul != std::string::npos) value.resize(nul);
if (!value.empty()) table.names.try_emplace(std::move(id), std::move(value));
} }
if (!ok) break; } catch (const std::exception &) {
return {};
} }
return table; return table;
} }
+17 -1
View File
@@ -765,6 +765,7 @@ export namespace ra3::models {
usize placed = 0; usize placed = 0;
usize missing = 0; usize missing = 0;
usize roads = 0; usize roads = 0;
usize hidden = 0; ///< Objects/roads below the water plane, not drawn.
[[nodiscard]] auto empty() const -> bool { return indices.empty(); } [[nodiscard]] auto empty() const -> bool { return indices.empty(); }
[[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; } [[nodiscard]] auto triangle_count() const -> usize { return indices.size() / 3U; }
@@ -823,9 +824,14 @@ export namespace ra3::models {
* @param ground_height Terrain height (world Z) at a world `(x, y)`, so * @param ground_height Terrain height (world Z) at a world `(x, y)`, so
* objects sit on the relief instead of a flat plane. * objects sit on the relief instead of a flat plane.
* @param texture_size Edge length of the shared texture array (0 = auto). * @param texture_size Edge length of the shared texture array (0 = auto).
* @param cull_below_z Skip placements whose base sits below this world Z
* (the water plane): an opaque water surface hides
* submerged objects, so drawing them would float them
* on top of the sea.
*/ */
[[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<const placement> placements, [[nodiscard]] inline auto build_scene(const asset_stream &stream, std::span<const placement> placements,
const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U) -> scene { const std::function<float(float, float)> &ground_height = {}, uint32 texture_size = 128U,
float cull_below_z = -3.4e38F) -> scene {
scene out; scene out;
out.texture_size = texture_size == 0U ? 128U : texture_size; out.texture_size = texture_size == 0U ? 128U : texture_size;
std::unordered_map<std::string, uint32> texture_layers; std::unordered_map<std::string, uint32> texture_layers;
@@ -972,6 +978,12 @@ export namespace ra3::models {
if ((a.road_type & 2U) == 0U) continue; // RoadType::Start if ((a.road_type & 2U) == 0U) continue; // RoadType::Start
const auto &b = placements[i + 1U]; const auto &b = placements[i + 1U];
if ((b.road_type & 4U) == 0U || b.type != a.type) continue; // RoadType::End if ((b.road_type & 4U) == 0U || b.type != a.type) continue; // RoadType::End
const auto road_base = (ground_height ? ground_height(a.x, a.y) : 0.0F) + a.z;
if (road_base < cull_below_z) {
++out.hidden;
++i;
continue;
}
emit_road(a, b); emit_road(a, b);
++i; ++i;
} }
@@ -986,6 +998,10 @@ export namespace ra3::models {
const auto cos_a = std::cos(item.angle); const auto cos_a = std::cos(item.angle);
const auto sin_a = std::sin(item.angle); const auto sin_a = std::sin(item.angle);
const auto base_z = (ground_height ? ground_height(item.x, item.y) : 0.0F) + item.z; const auto base_z = (ground_height ? ground_height(item.x, item.y) : 0.0F) + item.z;
if (base_z < cull_below_z) {
++out.hidden;
continue;
}
bool drawn = false; bool drawn = false;
for (const auto *mesh_asset: meshes) { for (const auto *mesh_asset: meshes) {
// Only opaque material parts are drawn; meshes with no diffuse // Only opaque material parts are drawn; meshes with no diffuse
+12 -5
View File
@@ -314,10 +314,11 @@ export namespace ra3::render {
} }
/** /**
* A small translucent label for the top-left corner, e.g. `FPS: 155/160`. * A small translucent label for the top-left corner, e.g.
* `cap == 0` means vertical sync, `cap < 0` means uncapped. * `FPS: 155/160 [vulkan]`. `cap == 0` means vertical sync, `cap < 0` means
* uncapped. `backend` is the active renderer backend name (empty omits it).
*/ */
[[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap) -> image { [[nodiscard]] inline auto compose_fps_label(uint32 fps, int cap, std::string_view backend = {}) -> image {
char text[64]; char text[64];
if (cap == 0) { if (cap == 0) {
std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps); std::snprintf(text, sizeof(text), "FPS: %u/vsync", fps);
@@ -326,10 +327,16 @@ export namespace ra3::render {
} else { } else {
std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap); std::snprintf(text, sizeof(text), "FPS: %u/%d", fps, cap);
} }
const auto w = text_width(text, 1U) + 8U; std::string label{text};
if (!backend.empty()) {
label += " [";
label += backend;
label += ']';
}
const auto w = text_width(label, 1U) + 8U;
const auto h = detail::glyph_height + 6U; const auto h = detail::glyph_height + 6U;
image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing image img(w, h, argb(30, 6, 6, 200)); // warm translucent backing
draw_text(img, 4, 3, text, argb(240, 200, 90), 1U); draw_text(img, 4, 3, label, argb(240, 200, 90), 1U);
return img; return img;
} }
+157 -134
View File
@@ -6,6 +6,7 @@ export import ra3.core;
export import ra3.fs; export import ra3.fs;
export import ra3.render; export import ra3.render;
export import ra3.models; export import ra3.models;
import ra3.assets;
/** /**
* The map's real terrain, read from the compiled `CkMp` chunk tree. * The map's real terrain, read from the compiled `CkMp` chunk tree.
@@ -91,82 +92,24 @@ export namespace ra3::terrain {
}; };
namespace detail { namespace detail {
/** View a `uint8` range as bytes, the currency of `libra3assets`. */
[[nodiscard]] inline auto as_bytes(std::span<const uint8> data) -> std::span<const std::byte> { return std::as_bytes(data); }
/** View a `libra3assets` byte range as OpenRA3's `uint8`. */
[[nodiscard]] inline auto as_u8(std::span<const std::byte> data) -> std::span<const uint8> {
return {reinterpret_cast<const uint8 *>(data.data()), data.size()};
}
[[nodiscard]] inline auto read_u16(const uint8 *p) -> uint16 { return static_cast<uint16>(p[0]) | (static_cast<uint16>(p[1]) << 8U); } [[nodiscard]] inline auto read_u16(const uint8 *p) -> uint16 { return static_cast<uint16>(p[0]) | (static_cast<uint16>(p[1]) << 8U); }
[[nodiscard]] inline auto read_u32(const uint8 *p) -> uint32 { [[nodiscard]] inline auto read_u32(const uint8 *p) -> uint32 {
return static_cast<uint32>(p[0]) | (static_cast<uint32>(p[1]) << 8U) | (static_cast<uint32>(p[2]) << 16U) | return static_cast<uint32>(p[0]) | (static_cast<uint32>(p[1]) << 8U) | (static_cast<uint32>(p[2]) << 16U) |
(static_cast<uint32>(p[3]) << 24U); (static_cast<uint32>(p[3]) << 24U);
} }
struct chunk { // The `CkMp` container and `HeightMapData` chunk are modelled by
std::string name; // `libra3assets` (`ra3.assets`); `parse_map` reads them through a
uint16 version = 0; // `map_document`. Only `BlendTileData` (which the library does not type
usize offset = 0; // yet) is parsed here, over the chunk payload the document exposes.
usize size = 0;
};
/**
* Parse the `CkMp` chunk tree into a flat chunk list.
*
* Layout: `"CkMp"`, `u32 assetCount`, then `assetCount` entries of
* `{ u8 nameLen, name, u32 index }` (index descending from the count),
* then `{ u32 index, u16 version, u32 dataSize, data[dataSize] }` per
* chunk until the end.
*/
[[nodiscard]] inline auto parse_chunks(std::span<const uint8> data) -> std::vector<chunk> {
if (data.size() < 8U || std::memcmp(data.data(), "CkMp", 4) != 0) throw terrain_error("not a CkMp map");
usize pos = 4;
const auto count = read_u32(data.data() + pos);
pos += 4;
std::vector<std::string> names(count + 1U);
for (uint32 i = count; i >= 1U; --i) {
if (pos >= data.size()) throw terrain_error("truncated asset-name table");
const auto len = data[pos++];
if (pos + len + 4U > data.size()) throw terrain_error("truncated asset-name");
names[i] = std::string{reinterpret_cast<const char *>(data.data() + pos), len};
pos += len;
pos += 4; // asset index (== i)
}
std::vector<chunk> chunks;
while (pos + 10U <= data.size()) {
const auto index = read_u32(data.data() + pos);
const auto version = read_u16(data.data() + pos + 4U);
const auto size = read_u32(data.data() + pos + 6U);
pos += 10;
if (index >= names.size() || pos + size > data.size()) throw terrain_error("bad chunk header");
chunks.push_back({names[index], version, pos, size});
pos += size;
}
return chunks;
}
[[nodiscard]] inline auto find(const std::vector<chunk> &chunks, std::string_view name) -> const chunk * {
for (const auto &c: chunks) {
if (c.name == name) return &c;
}
return nullptr;
}
inline auto parse_heightmap(std::span<const uint8> data, const chunk &c, map_data &out) -> void {
usize p = c.offset;
out.width = read_u32(data.data() + p);
out.height = read_u32(data.data() + p + 4U);
out.border_width = read_u32(data.data() + p + 8U);
const auto border_count = read_u32(data.data() + p + 12U);
p += 16;
p += static_cast<usize>(border_count) * (c.version >= 6U ? 16U : 8U);
p += 4U; // area
if (out.width == 0U || out.height == 0U) throw terrain_error("empty heightmap");
const auto area = static_cast<usize>(out.width) * out.height;
out.elevations.resize(area);
for (usize i = 0; i < area; ++i) {
if (p >= data.size()) throw terrain_error("truncated heightmap");
out.elevations[i] = c.version >= 5U ? read_u16(data.data() + p) : data[p];
p += c.version >= 5U ? 2U : 1U;
}
}
/** Where the texture table ends and how many `BlendDescription`s follow. */ /** Where the texture table ends and how many `BlendDescription`s follow. */
struct texture_table_info { struct texture_table_info {
@@ -246,20 +189,20 @@ export namespace ra3::terrain {
} }
} }
inline auto parse_blend(std::span<const uint8> data, const chunk &c, map_data &out) -> void { inline auto parse_blend(std::span<const uint8> payload, uint16 version, map_data &out) -> void {
const auto area = static_cast<usize>(out.width) * out.height; const auto area = static_cast<usize>(out.width) * out.height;
usize p = c.offset; usize p = 0;
const auto num_tiles = read_u32(data.data() + p); const auto num_tiles = read_u32(payload.data() + p);
p += 4; p += 4;
if (num_tiles != area) throw terrain_error("BlendTileData tile count mismatch"); if (num_tiles != area) throw terrain_error("BlendTileData tile count mismatch");
out.tiles.resize(area); out.tiles.resize(area);
for (usize i = 0; i < area; ++i) out.tiles[i] = read_u16(data.data() + p + i * 2U); for (usize i = 0; i < area; ++i) out.tiles[i] = read_u16(payload.data() + p + i * 2U);
p += area * 2U; p += area * 2U;
const auto bits = (c.version >= 14U && c.version < 24U) ? 32U : 16U; const auto bits = (version >= 14U && version < 24U) ? 32U : 16U;
const auto word = bits / 8U; const auto word = bits / 8U;
const auto read_index = [&](usize off) -> uint16 { const auto read_index = [&](usize off) -> uint16 {
return word == 4U ? static_cast<uint16>(read_u32(data.data() + off)) : read_u16(data.data() + off); return word == 4U ? static_cast<uint16>(read_u32(payload.data() + off)) : read_u16(payload.data() + off);
}; };
out.blends.resize(area); out.blends.resize(area);
for (usize i = 0; i < area; ++i) out.blends[i] = read_index(p + i * word); for (usize i = 0; i < area; ++i) out.blends[i] = read_index(p + i * word);
@@ -269,38 +212,55 @@ export namespace ra3::terrain {
p += area * word; p += area * word;
p += area * word; // CliffTextures (not rendered yet) p += area * word; // CliffTextures (not rendered yet)
const auto chunk_end = c.offset + c.size; const auto chunk_end = payload.size();
const auto table = parse_textures(data, p, chunk_end, out); const auto table = parse_textures(payload, p, chunk_end, out);
parse_blend_descriptions(data, table, chunk_end, out); parse_blend_descriptions(payload, table, chunk_end, out);
} }
} }
/** /**
* Parse the terrain chunks out of a `CkMp` (uncompressed) map payload. * Parse the terrain chunks out of a `CkMp` (uncompressed) map payload.
* *
* The container and `HeightMapData` come from `libra3assets`
* (`map_document`); `BlendTileData` (which the library does not model yet)
* is decoded here from the chunk payload the document exposes.
*
* @throws terrain_error if the chunk tree or terrain chunks are malformed. * @throws terrain_error if the chunk tree or terrain chunks are malformed.
*/ */
[[nodiscard]] inline auto parse_map(std::span<const uint8> ckmp) -> map_data { [[nodiscard]] inline auto parse_map(std::span<const uint8> ckmp) -> map_data {
const auto chunks = detail::parse_chunks(ckmp); try {
const auto *heightmap = detail::find(chunks, "HeightMapData"); auto document = ra3::assets::map_document::parse(detail::as_bytes(ckmp));
const auto *blend = detail::find(chunks, "BlendTileData");
if (heightmap == nullptr || blend == nullptr) throw terrain_error("map has no terrain chunks");
map_data out; map_data out;
detail::parse_heightmap(ckmp, *heightmap, out); const auto height = document.height_map();
detail::parse_blend(ckmp, *blend, out); if (!height) throw terrain_error("map has no HeightMapData chunk");
if (const auto *water = detail::find(chunks, "GlobalWaterSettings"); water != nullptr && water->size >= 8U) { out.width = height->width;
out.has_water = detail::read_u32(ckmp.data() + water->offset) != 0U; out.height = height->height;
const auto bits = detail::read_u32(ckmp.data() + water->offset + 4U); out.border_width = height->border_width;
std::memcpy(&out.water_plane_z, &bits, sizeof(out.water_plane_z)); out.elevations.assign(height->elevations.begin(), height->elevations.end());
const auto *blend = document.find_chunk("BlendTileData");
if (blend == nullptr) throw terrain_error("map has no BlendTileData chunk");
detail::parse_blend(detail::as_u8(blend->payload), blend->version, out);
if (const auto *water = document.find_chunk("GlobalWaterSettings"); water != nullptr && water->payload.size() >= 8U) {
const auto *bytes = reinterpret_cast<const uint8 *>(water->payload.data());
out.has_water = detail::read_u32(bytes) != 0U;
const auto bits = detail::read_u32(bytes + 4U);
std::memcpy(&out.water_plane_z, &bits, sizeof(out.water_plane_z));
}
out.valid = true;
return out;
} catch (const ra3::assets::asset_error &error) {
throw terrain_error(error.what());
} }
out.valid = true;
return out;
} }
/** The decoded terrain textures, parallel to `map_data::textures`. */ /** The decoded terrain textures, parallel to `map_data::textures`. */
struct texture_set { struct texture_set {
std::vector<image> images; std::vector<image> images;
image water_flow; ///< `ra3_deepocean.tga`: SAGE water flow/distortion (RG), optional.
image water_normal; ///< `ra3_deepocean_nrm.tga`: SAGE water bump normal, optional.
[[nodiscard]] auto resolved() const -> usize { [[nodiscard]] auto resolved() const -> usize {
usize n = 0; usize n = 0;
@@ -336,6 +296,10 @@ export namespace ra3::terrain {
}; };
std::vector<fs::big_archive> archives; std::vector<fs::big_archive> archives;
std::unordered_map<std::string, source> files; std::unordered_map<std::string, source> files;
source flow_src{};
source nrm_src{};
bool has_flow = false;
bool has_nrm = false;
for (const auto &name: {"Terrain.big", "Core11.big"}) { for (const auto &name: {"Terrain.big", "Core11.big"}) {
const auto path = data_dir / name; const auto path = data_dir / name;
std::error_code ec; std::error_code ec;
@@ -344,9 +308,13 @@ export namespace ra3::terrain {
} }
for (const auto &archive: archives) { for (const auto &archive: archives) {
for (const auto &entry: archive.entries()) { for (const auto &entry: archive.entries()) {
auto stem = detail::tga_stem(entry.name);
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
if (!entry.name.ends_with(".tga") && !entry.name.ends_with(".TGA")) continue; if (!entry.name.ends_with(".tga") && !entry.name.ends_with(".TGA")) continue;
auto stem = detail::tga_stem(entry.name);
// The global ocean flow/normal pair is grabbed separately: the
// tile index deliberately drops `_nrm` files.
if (stem == "ra3_deepocean") { flow_src = source{&archive, entry.name}; has_flow = true; continue; }
if (stem == "ra3_deepocean_nrm") { nrm_src = source{&archive, entry.name}; has_nrm = true; continue; }
if (stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, source{&archive, entry.name}); files.try_emplace(stem, source{&archive, entry.name});
} }
} }
@@ -378,6 +346,16 @@ export namespace ra3::terrain {
// Leave the slot empty; the renderer falls back to a palette. // Leave the slot empty; the renderer falls back to a palette.
} }
} }
const auto decode_water = [](const source &src, bool present) -> image {
if (!present) return {};
try {
return ra3::render::decode_tga(src.archive->read(src.entry, true));
} catch (const std::exception &) {
return {};
}
};
set.water_flow = decode_water(flow_src, has_flow);
set.water_normal = decode_water(nrm_src, has_nrm);
return set; return set;
} }
@@ -396,8 +374,8 @@ export namespace ra3::terrain {
}; };
namespace detail { namespace detail {
/** Index `*.tga` under a terrain dir by stem (lower-cased), ignoring normals. */ /** Index `*.tga` under a terrain dir by stem (lower-cased). */
[[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir) [[nodiscard]] inline auto terrain_file_index(const std::filesystem::path &dir, bool include_normals = false)
-> std::unordered_map<std::string, std::filesystem::path> { -> std::unordered_map<std::string, std::filesystem::path> {
std::unordered_map<std::string, std::filesystem::path> files; std::unordered_map<std::string, std::filesystem::path> files;
std::error_code ec; std::error_code ec;
@@ -412,7 +390,7 @@ export namespace ra3::terrain {
std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); }); std::transform(parent.begin(), parent.end(), parent.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (parent != "terrain") continue; if (parent != "terrain") continue;
auto stem = tga_stem(path.filename().string()); auto stem = tga_stem(path.filename().string());
if (stem.size() > 4U && stem.ends_with("_nrm")) continue; if (!include_normals && stem.size() > 4U && stem.ends_with("_nrm")) continue;
files.try_emplace(stem, path); files.try_emplace(stem, path);
} }
return files; return files;
@@ -438,11 +416,16 @@ export namespace ra3::terrain {
* Used to stage just the tiles a single map needs (e.g. the wasm preload). * Used to stage just the tiles a single map needs (e.g. the wasm preload).
*/ */
[[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> { [[nodiscard]] inline auto resolve_texture_files(const map_data &map, const std::filesystem::path &dir) -> std::vector<std::filesystem::path> {
const auto files = detail::terrain_file_index(dir); // Include normals so the global ocean flow/normal pair is staged too:
// the terrain pass appends them to the atlas.
const auto files = detail::terrain_file_index(dir, true);
std::vector<std::filesystem::path> resolved; std::vector<std::filesystem::path> resolved;
for (const auto &texture: map.textures) { for (const auto &texture: map.textures) {
if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found)); if (auto found = detail::match_terrain_file(files, texture.name); !found.empty()) resolved.push_back(std::move(found));
} }
for (const auto *water: {"ra3_deepocean", "ra3_deepocean_nrm"}) {
if (const auto it = files.find(water); it != files.end()) resolved.push_back(it->second);
}
std::sort(resolved.begin(), resolved.end()); std::sort(resolved.begin(), resolved.end());
resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end()); resolved.erase(std::unique(resolved.begin(), resolved.end()), resolved.end());
return resolved; return resolved;
@@ -451,21 +434,26 @@ export namespace ra3::terrain {
/** Load tile textures from a directory of loose `*.tga` files (extracted assets). */ /** Load tile textures from a directory of loose `*.tga` files (extracted assets). */
[[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir, [[nodiscard]] inline auto load_textures_from_dir(const map_data &map, const std::filesystem::path &dir,
const std::function<void(float)> &progress = {}) -> texture_set { const std::function<void(float)> &progress = {}) -> texture_set {
const auto files = detail::terrain_file_index(dir); const auto files = detail::terrain_file_index(dir, true);
texture_set set; texture_set set;
set.images.resize(map.textures.size()); set.images.resize(map.textures.size());
const auto decode_file = [](const std::filesystem::path &path) -> image {
if (path.empty()) return {};
try {
std::ifstream in(path, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
return ra3::render::decode_tga(raw);
} catch (const std::exception &) {
return {};
}
};
for (usize i = 0; i < map.textures.size(); ++i) { for (usize i = 0; i < map.textures.size(); ++i) {
const auto found = detail::match_terrain_file(files, map.textures[i].name); const auto found = detail::match_terrain_file(files, map.textures[i].name);
if (!found.empty()) { set.images[i] = decode_file(found);
try {
std::ifstream in(found, std::ios::binary);
std::vector<uint8> raw((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
set.images[i] = ra3::render::decode_tga(raw);
} catch (const std::exception &) {
}
}
if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size())); if (progress && !map.textures.empty()) progress(static_cast<float>(i + 1U) / static_cast<float>(map.textures.size()));
} }
if (const auto it = files.find("ra3_deepocean"); it != files.end()) set.water_flow = decode_file(it->second);
if (const auto it = files.find("ra3_deepocean_nrm"); it != files.end()) set.water_normal = decode_file(it->second);
return set; return set;
} }
@@ -539,7 +527,12 @@ export namespace ra3::terrain {
out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed); out.cell_data[i * 4U + 3U] = static_cast<uint16>(packed);
} }
out.layer_count = static_cast<uint32>(std::max<usize>(1U, map.textures.size())); // Two extra atlas layers hold the SAGE water flow map and bump normal so
// the water shader can sample them without a new binding on any backend:
// they are always the last two layers (water_flow = layer_count - 2,
// water_normal = layer_count - 1).
const auto tile_layers = static_cast<uint32>(std::max<usize>(1U, map.textures.size()));
out.layer_count = tile_layers + 2U;
uint32 layer_size = 64U; uint32 layer_size = 64U;
for (const auto &img: set.images) { for (const auto &img: set.images) {
if (!img.empty()) layer_size = std::max(layer_size, img.width()); if (!img.empty()) layer_size = std::max(layer_size, img.width());
@@ -554,17 +547,26 @@ export namespace ra3::terrain {
} }
out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U); out.layers.assign(static_cast<usize>(out.layer_count) * layer_size * layer_size, 0xFF3A4550U);
for (usize i = 0; i < map.textures.size(); ++i) { // Copy `img` into atlas layer `index`, box-nearest downscaled to
const auto &img = set.images[i]; // `layer_size`; `fallback` is the ARGB used when the image is absent.
if (img.empty()) continue; const auto blit_layer = [&](uint32 index, const image &img, uint32 fallback) {
for (uint32 y = 0; y < layer_size; ++y) { for (uint32 y = 0; y < layer_size; ++y) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
for (uint32 x = 0; x < layer_size; ++x) { for (uint32 x = 0; x < layer_size; ++x) {
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size); uint32 px = fallback;
out.layers[(i * layer_size + y) * layer_size + x] = img.data()[static_cast<usize>(sy) * img.width() + sx]; if (!img.empty()) {
const auto sy = std::min(img.height() - 1U, y * img.height() / layer_size);
const auto sx = std::min(img.width() - 1U, x * img.width() / layer_size);
px = img.data()[static_cast<usize>(sy) * img.width() + sx];
}
out.layers[(static_cast<usize>(index) * layer_size + y) * layer_size + x] = px;
} }
} }
};
for (usize i = 0; i < map.textures.size(); ++i) {
blit_layer(static_cast<uint32>(i), set.images[i], 0xFF3A4550U);
} }
blit_layer(tile_layers, set.water_flow, 0xFF808080U); // neutral flow (0, 0)
blit_layer(tile_layers + 1U, set.water_normal, 0xFF8080FFU); // flat normal (0, 0, 1)
if (progress) progress(1.0F); if (progress) progress(1.0F);
return out; return out;
} }
@@ -1138,49 +1140,70 @@ export namespace ra3::terrain {
continue; continue;
} }
auto t = cell_size * 0.5F; // Clip the ray to the map's XY rectangle. The boundary is an
auto dt = cell_size * 0.5F; // exact plane, so the silhouette there stays razor-sharp
// instead of stair-stepping across it; outside the map is sky.
auto t_enter = 0.0F;
auto t_exit = 1.0e30F;
const auto slab = [](float origin, float dir, float span, float &lo_t, float &hi_t) -> bool {
if (std::abs(dir) < 1.0e-6F) return origin >= 0.0F && origin <= span;
const auto a = (0.0F - origin) / dir;
const auto b = (span - origin) / dir;
lo_t = std::max(lo_t, std::min(a, b));
hi_t = std::min(hi_t, std::max(a, b));
return true;
};
if (!slab(cam_x, dx, world_w, t_enter, t_exit) || !slab(cam_y, dy, world_h, t_enter, t_exit) || t_exit <= 0.0F) {
hi.data()[pixel] = argb(150, 170, 200);
continue;
}
const auto surface_at = [&](float wx, float wy) -> float {
const auto h = sample_height(wx, wy);
return map.has_water ? std::max(h, static_cast<float>(map.water_plane_z)) : h;
};
// March the heightfield cell by cell: the step is never longer
// than the time to cross one cell (in the dominant horizontal
// axis), while a clearance term lets the ray skip the empty air
// above the surface. Resolving every cell is what keeps cliff and
// map-edge silhouettes from quantising into huge stair-steps that
// crawl/wave as the camera pans.
const auto horiz = std::max(std::abs(dx), std::abs(dy));
const auto cell_step = std::min(cell_size / std::max(horiz, 1.0e-4F), cell_size * 32.0F);
auto t = std::max(t_enter, cell_size * 0.5F);
auto prev_t = t; auto prev_t = t;
bool hit = false; bool hit = false;
float hit_t = 0.0F; float hit_t = 0.0F;
for (int iter = 0; iter < 4000 && t < 60000.0F; ++iter) { for (int iter = 0; iter < 4096 && t <= t_exit; ++iter) {
const auto wx = cam_x + dx * t; const auto wx = cam_x + dx * t;
const auto wy = cam_y + dy * t; const auto wy = cam_y + dy * t;
const auto wz = cam_z + dz * t; const auto wz = cam_z + dz * t;
if (wx < 0.0F || wy < 0.0F || wx >= world_w || wy >= world_h) { const auto surface = surface_at(wx, wy);
prev_t = t; if (wz <= surface) {
dt *= 1.03F;
t += dt;
continue;
}
if (map.has_water && wz <= map.water_plane_z) {
hit = true;
hit_t = t;
break;
}
if (wz <= sample_height(wx, wy)) {
hit = true; hit = true;
hit_t = t; hit_t = t;
break; break;
} }
const auto clearance = (wz - surface) / std::max(-dz, 1.0e-4F);
prev_t = t; prev_t = t;
dt *= 1.03F; t += std::clamp(clearance, cell_step, cell_step * 8.0F);
t += dt;
} }
if (!hit) { if (!hit) {
hi.data()[pixel] = argb(150, 170, 200); hi.data()[pixel] = argb(150, 170, 200);
continue; continue;
} }
// Refine the first crossing; with a sub-cell bracket this
// converges to the exact surface point.
auto lo = prev_t; auto lo = prev_t;
auto up = hit_t; auto up = hit_t;
for (int i = 0; i < 6; ++i) { for (int i = 0; i < 18; ++i) {
const auto mid = 0.5F * (lo + up); const auto mid = 0.5F * (lo + up);
const auto wx = cam_x + dx * mid; const auto wx = cam_x + dx * mid;
const auto wy = cam_y + dy * mid; const auto wy = cam_y + dy * mid;
const auto wz = cam_z + dz * mid; const auto wz = cam_z + dz * mid;
const auto water = map.has_water && wz <= map.water_plane_z; if (wz <= surface_at(wx, wy)) {
if (water || wz <= sample_height(wx, wy)) {
up = mid; up = mid;
} else { } else {
lo = mid; lo = mid;
+101
View File
@@ -1,5 +1,6 @@
import std; import std;
import ra3; import ra3;
import ra3.assets;
namespace { namespace {
int failures = 0; int failures = 0;
@@ -193,6 +194,12 @@ auto main() -> int {
} }
check(!space_lit, "a space paints nothing"); check(!space_lit, "a space paints nothing");
// FPS label: the active backend name is appended (e.g. `[vulkan]`).
const auto fps_label = render::compose_fps_label(155U, 0, "vulkan");
const auto fps_plain = render::compose_fps_label(155U, 0);
check(fps_label.width() > fps_plain.width(), "FPS label grows when the backend is shown");
check(fps_label.width() == render::text_width("FPS: 155/vsync [vulkan]") + 8U, "FPS label includes the backend name");
const auto box = render::fit_rect(1280.0F, 720.0F, 1920.0F, 1080.0F); const auto box = render::fit_rect(1280.0F, 720.0F, 1920.0F, 1080.0F);
check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window"); check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window");
@@ -300,6 +307,100 @@ auto main() -> int {
check(models::decode_dds(std::vector<core::uint8>{1U, 2U, 3U}).empty(), "a non-DDS payload yields no image"); check(models::decode_dds(std::vector<core::uint8>{1U, 2U, 3U}).empty(), "a non-DDS payload yields no image");
// Real-asset checks (opt-in): set OPENRA3_TEST_ASSETS to an extracted assets
// directory. They run the libra3assets-backed readers over the retail data and
// compare the map names against the golden `maps/map_names.tsv` written by the
// previous hand-rolled parser.
if (const char *assets_env = std::getenv("OPENRA3_TEST_ASSETS"); assets_env != nullptr && *assets_env != '\0') {
const std::filesystem::path assets{assets_env};
const auto read_all = [](const std::filesystem::path &path) {
std::ifstream in(path, std::ios::binary);
return std::vector<core::uint8>{std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
};
const auto golden = assets / "maps" / "map_names.tsv";
// The golden table came from the newest `Lang-English<N>.big`; mirror that.
const auto pick_english_csf = [](const std::filesystem::path &raw) -> std::filesystem::path {
std::filesystem::path best;
int best_version = -1;
std::error_code ec;
for (const auto &entry: std::filesystem::directory_iterator(raw, ec)) {
if (!entry.is_directory()) continue;
const auto name = entry.path().filename().string();
if (name.rfind("Lang-English", 0) != 0) continue;
int version = 0;
try {
version = std::stoi(name.substr(12));
} catch (const std::exception &) {
continue;
}
if (version > best_version) {
best_version = version;
best = entry.path() / "data" / "gamestrings.csf";
}
}
if (!best.empty() && std::filesystem::exists(best)) return best;
return raw / "English" / "data" / "gamestrings.csf";
};
const auto csf_path = pick_english_csf(assets / "raw");
if (std::filesystem::exists(golden) && std::filesystem::exists(csf_path)) {
const auto table = map::parse_map_names(read_all(csf_path));
std::ifstream in(golden, std::ios::binary);
std::string line;
std::size_t matched = 0;
std::size_t mismatched = 0;
while (std::getline(in, line)) {
const auto tab = line.find('\t');
if (tab == std::string::npos) continue;
const auto id = line.substr(0, tab);
const auto expected = line.substr(tab + 1U);
if (table.lookup(id) == expected) {
++matched;
} else {
++mismatched;
if (mismatched <= 5U) std::printf(" name mismatch %s: golden '%s' new '%s'\n", id.c_str(), expected.c_str(), table.lookup(id).c_str());
}
}
std::printf(" real CSF: %zu names, golden %zu matched, %zu mismatched\n", table.names.size(), matched, mismatched);
check(mismatched == 0U, "map names reproduce the golden map_names.tsv");
}
std::size_t maps_ok = 0;
std::size_t maps_bad = 0;
std::size_t library_starts = 0;
std::size_t no_starts = 0;
std::size_t roundtrip_bad = 0;
const auto to_bytes = [](std::span<const core::uint8> data) {
return std::span<const std::byte>{reinterpret_cast<const std::byte *>(data.data()), data.size()};
};
for (const auto &entry: std::filesystem::directory_iterator(assets / "maps")) {
if (!entry.is_regular_file() || entry.path().extension() != ".map") continue;
try {
const auto ckmp = map::to_ckmp(read_all(entry.path()));
const auto parsed = terrain::parse_map(ckmp);
const auto starts = map::starts_from_ckmp(ckmp);
const auto document = ra3::assets::map_document::parse(to_bytes(ckmp));
if (document.player_starts().size() >= 2U) ++library_starts; else ++no_starts;
if (!std::ranges::equal(document.to_ckmp(), to_bytes(ckmp))) ++roundtrip_bad;
if (parsed.width > 0U && parsed.height > 0U && parsed.elevations.size() == static_cast<std::size_t>(parsed.width) * parsed.height && starts.size() >= 2U) {
++maps_ok;
} else {
++maps_bad;
if (maps_bad <= 5U)
std::printf(" map oddity %s: %ux%u starts %zu\n", entry.path().filename().string().c_str(), parsed.width, parsed.height, starts.size());
}
} catch (const std::exception &error) {
++maps_bad;
if (maps_bad <= 5U) std::printf(" map FAIL %s: %s\n", entry.path().filename().string().c_str(), error.what());
}
}
std::printf(" real maps: %zu ok, %zu bad; player_starts resolved %zu, unresolved %zu; round-trip mismatched %zu\n", maps_ok, maps_bad, library_starts,
no_starts, roundtrip_bad);
check(maps_bad == 0U, "every extracted map parses terrain + starts through libra3assets");
check(no_starts == 0U, "libra3assets player_starts() resolves every retail map's start waypoints");
check(roundtrip_bad == 0U, "libra3assets re-serialises every retail map byte-for-byte");
}
if (failures == 0) { if (failures == 0) {
std::puts("ra3_tests: OK"); std::puts("ra3_tests: OK");
} }
+14 -7
View File
@@ -23,8 +23,8 @@ engine, not on a logging framework.
(for tests and in-game consoles) ship; `sink` is a small interface. (for tests and in-game consoles) ship; `sink` is a small interface.
- **File output with archiving.** `file_sink` writes to a file and, on open, - **File output with archiving.** `file_sink` writes to a file and, on open,
moves an existing log aside to a timestamped archive, so a run never appends moves an existing log aside to a timestamped archive, so a run never appends
onto a previous run's log. It can also rotate by size and bound how many onto a previous run's log. It can also rotate by size, bound how many archives
archives are kept. are kept, and format both the record timestamp and the archive names.
- **No stacktrace? No problem.** Where `<stacktrace>` is missing (libc++, and - **No stacktrace? No problem.** Where `<stacktrace>` is missing (libc++, and
therefore every cross target), the module still compiles and records still therefore every cross target), the module still compiles and records still
carry their call site — they simply have no stack. carry their call site — they simply have no stack.
@@ -104,7 +104,7 @@ auto main() -> int {
Example output: Example output:
``` ```
[11:32:18] ERROR example: a body left the world (examples/main.cpp:8) [2026-10-01 11:32:18] ERROR example: a body left the world (examples/main.cpp:8)
#0 simulate_one_step (examples/main.cpp:8) #0 simulate_one_step (examples/main.cpp:8)
#1 main (examples/main.cpp:20) #1 main (examples/main.cpp:20)
#2 <unknown> #2 <unknown>
@@ -131,20 +131,27 @@ and `log::set_sinks({...})` replaces them.
```cpp ```cpp
namespace log = ender::log; namespace log = ender::log;
// Archive any existing enderlog.log to enderlog.log.<timestamp>, then start a // Archive any existing enderlog.log to enderlog.<timestamp>.log, then start a
// fresh file for this run. Rotate at 64 KiB and keep the last 5 archives. // fresh file for this run. Rotate at 64 KiB and keep the last 5 archives.
auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5}); auto sink = log::add_file_sink("enderlog.log", {.max_file_size = 64 * 1024, .max_archives = 5});
``` ```
- **No appending onto a previous run.** On open, an existing non-empty - **No appending onto a previous run.** On open, an existing non-empty
`enderlog.log` is renamed to `enderlog.log.<YYYYmmdd-HHMMSS>` before the new `enderlog.log` is renamed to `enderlog.<timestamp>.log` before the new file is
file is created, so every run gets its own file and the previous run's log is created, so every run gets its own file and the previous run's log is
preserved. A leftover empty file is simply replaced. preserved. The timestamp is inserted before the extension, which stays last
(`.log` when the active file has none). A leftover empty file is simply
replaced.
- `file_options::max_file_size` (0 disables) rotates the active file mid-run the - `file_options::max_file_size` (0 disables) rotates the active file mid-run the
same way, and never archives an empty file. `file_options::max_archives` same way, and never archives an empty file. `file_options::max_archives`
(0 keeps all) deletes the oldest archives beyond the limit. (0 keeps all) deletes the oldest archives beyond the limit.
- `file_options::flush_each_record` (on by default) flushes after every record so - `file_options::flush_each_record` (on by default) flushes after every record so
a crash keeps the tail. a crash keeps the tail.
- `file_options::timestamp_format` (chrono syntax, default `%Y-%m-%d %H:%M:%S`)
controls the timestamp on each record's header line;
`file_options::archive_time_format` (chrono syntax, default `%Y%m%d-%H%M%S`)
controls the timestamp inserted into archive names. A chrono format string must
begin with `%` (e.g. `%Y-%m-%d_%H%M%S`).
- `add_file_sink` adds the sink to the global logger and returns it; `path()` and - `add_file_sink` adds the sink to the global logger and returns it; `path()` and
`archives()` expose what it wrote. The `file_sink` class can also be used `archives()` expose what it wrote. The `file_sink` class can also be used
directly and installed with `set_sinks`. directly and installed with `set_sinks`.
+46 -15
View File
@@ -54,6 +54,9 @@ export namespace ender::log {
return "?"; return "?";
} }
/** Default timestamp rendered on a record, chrono format syntax. */
inline constexpr std::string_view default_time_format{"%Y-%m-%d %H:%M:%S"};
/** Logger configuration. */ /** Logger configuration. */
struct options { struct options {
/** Records below this level are dropped before anything is built. */ /** Records below this level are dropped before anything is built. */
@@ -90,15 +93,32 @@ export namespace ender::log {
}; };
namespace detail { namespace detail {
/**
* Render a time point with a runtime chrono format string.
*
* `std::format`'s format string is compile-time only, so the spec is
* wrapped in a replacement field and fed to `std::vformat`; a bare spec
* would be read as literal text rather than a chrono conversion.
*/
[[nodiscard]] inline auto format_time(const std::chrono::system_clock::time_point time,
const std::string_view time_format) -> std::string {
const auto moment = std::chrono::floor<std::chrono::seconds>(time);
const auto pattern = std::string{"{:"}.append(time_format).append("}");
return std::vformat(pattern, std::make_format_args(moment));
}
/** /**
* Render one record as a human-readable block: a header line and, when * Render one record as a human-readable block: a header line and, when
* present, the indented stack frames. Shared by the stream sinks. * present, the indented stack frames. Shared by the stream sinks.
*
* @param time_format A chrono format string applied to the record's
* timestamp; defaults to the date and time of day.
*/ */
[[nodiscard]] inline auto format_record(const record &entry) -> std::string { [[nodiscard]] inline auto format_record(const record &entry,
auto text = std::format("[{:%H:%M:%S}] {:<8} {}", const std::string_view time_format = default_time_format)
std::chrono::floor<std::chrono::seconds>(entry.time), -> std::string {
to_string(entry.severity), const auto stamp = format_time(entry.time, time_format);
entry.message); auto text = std::format("[{}] {:<8} {}", stamp, to_string(entry.severity), entry.message);
if (!entry.file.empty()) { if (!entry.file.empty()) {
text += std::format(" ({}:{})", entry.file, entry.line); text += std::format(" ({}:{})", entry.file, entry.line);
} }
@@ -125,15 +145,18 @@ export namespace ender::log {
/** Writes a human-readable line per record to a stream (stderr by default). */ /** Writes a human-readable line per record to a stream (stderr by default). */
class console_sink final: public sink { class console_sink final: public sink {
public: public:
explicit console_sink(std::ostream &stream = std::cerr): stream_(&stream) {} explicit console_sink(std::ostream &stream = std::cerr,
std::string time_format = std::string{default_time_format})
: stream_(&stream), time_format_(std::move(time_format)) {}
auto write(const record &entry) -> void override { auto write(const record &entry) -> void override {
*stream_ << detail::format_record(entry); *stream_ << detail::format_record(entry, time_format_);
stream_->flush(); stream_->flush();
} }
private: private:
std::ostream *stream_; std::ostream *stream_;
std::string time_format_;
}; };
/** Keeps every record in memory; useful for tests and in-game consoles. */ /** Keeps every record in memory; useful for tests and in-game consoles. */
@@ -174,6 +197,10 @@ export namespace ender::log {
std::size_t max_file_size{0}; std::size_t max_file_size{0};
/** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */ /** Keep at most this many archives, dropping the oldest first; 0 keeps them all. */
std::size_t max_archives{0}; std::size_t max_archives{0};
/** Timestamp format used on each record's header line (chrono syntax). */
std::string timestamp_format{std::string{default_time_format}};
/** Chrono format for the timestamp inserted into archive names. */
std::string archive_time_format{"%Y%m%d-%H%M%S"};
}; };
/** /**
@@ -182,7 +209,9 @@ export namespace ender::log {
* `path` is the active file. When the sink opens it and the file already * `path` is the active file. When the sink opens it and the file already
* holds data, that file is renamed to a timestamped archive first, so a run * holds data, that file is renamed to a timestamped archive first, so a run
* never appends onto a previous run's log: every start begins a fresh file * never appends onto a previous run's log: every start begins a fresh file
* and the old one is preserved as `<path>.<YYYYmmdd-HHMMSS>`. The same * and the old one is preserved as `<stem>.<timestamp><extension>` (`.log`
* when the active file has no extension), where the timestamp is rendered by
* `file_options::archive_time_format` (default `<YYYYmmdd-HHMMSS>`). The same
* happens mid-run once the active file passes `file_options::max_file_size`. * happens mid-run once the active file passes `file_options::max_file_size`.
* `file_options::max_archives` bounds how many archives are kept. * `file_options::max_archives` bounds how many archives are kept.
* *
@@ -205,7 +234,7 @@ export namespace ender::log {
} }
auto write(const record &entry) -> void override { auto write(const record &entry) -> void override {
const auto block = detail::format_record(entry); const auto block = detail::format_record(entry, options_.timestamp_format);
// Rotate before writing, but never rotate an empty file: that would // Rotate before writing, but never rotate an empty file: that would
// archive nothing and lose the record that is about to be written. // archive nothing and lose the record that is about to be written.
if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) { if (options_.max_file_size > 0 && size_ > 0 && size_ + block.size() > options_.max_file_size) {
@@ -236,15 +265,17 @@ export namespace ender::log {
auto archive_current() -> void { auto archive_current() -> void {
if (stream_.is_open()) stream_.close(); if (stream_.is_open()) stream_.close();
const auto stamp = std::format("{:%Y%m%d-%H%M%S}", const auto stamp = detail::format_time(std::chrono::system_clock::now(), options_.archive_time_format);
std::chrono::floor<std::chrono::seconds>(std::chrono::system_clock::now())); // Keep the extension last: `<stem>.<timestamp><extension>`, falling
auto archive = path_; // back to `.log` when the active file has none.
archive += "." + stamp; const auto stem = path_.stem().string();
const auto extension = path_.has_extension() ? path_.extension().string() : std::string{".log"};
auto archive = path_.parent_path() / (stem + "." + stamp + extension);
// Two rotations can land in the same second; disambiguate with a // Two rotations can land in the same second; disambiguate with a
// counter rather than overwrite the earlier archive. // counter rather than overwrite the earlier archive.
for (auto counter = 1; std::filesystem::exists(archive); ++counter) { for (auto counter = 1; std::filesystem::exists(archive); ++counter) {
archive = path_; archive = path_.parent_path() /
archive += std::format(".{}.{}", stamp, counter); (stem + "." + stamp + "." + std::to_string(counter) + extension);
} }
std::filesystem::rename(path_, archive); std::filesystem::rename(path_, archive);
archives_.push_back(archive); archives_.push_back(archive);
+134
View File
@@ -0,0 +1,134 @@
# libra3assets
A dependency-free C++26 library that reads **and writes** the resource files
Red Alert 3 ships, so tooling (map editors in particular) can work with the
retail assets directly. No engine, no game install required to build - only to
feed it data.
It is a sub-project of this repository, a sibling of
[`libra3replay`](../libra3replay/README.md), and follows the same build
conventions (C++26 modules, `import std;`, GCC 16, CMake 4 + Ninja).
## What it handles
| Module | Format | Read | Write |
| ------------------ | ------------------------------------------------------------------- | :--: | :---: |
| `ra3.assets:big` | `BIG4` archives (`Data\*.big`) | yes | yes |
| `ra3.assets:refpack` | EA RefPack codec (`10 FB`) | yes | yes |
| `ra3.assets:binary` | compiled `BinaryAsset` streams (`.bin` + `.manifest`, `cdata`) | yes | - |
| `ra3.assets:csf` | SAGE `.csf` string tables (`gamestrings.csf`) | yes | yes |
| `ra3.assets:map` | SAGE `.map` containers (`CkMp`) incl. `HeightMapData`/objects | yes | yes |
| `ra3.assets:bytes` | bounds-checked little-/big-endian readers and writers | yes | yes |
The **map** module is the centrepiece for a map editor. A `.map` is modelled as
an ordered list of named, versioned `CkMp` chunks; chunks the library does not
type (e.g. `BlendTileData`, `SidesList`) are preserved byte-for-byte and the
asset-name table keeps its original indices, so an edit/ serialise cycle is
lossless. Typed accessors cover the terrain grid (`HeightMapData`), every placed
object (`ObjectsList`, including the `*Waypoints/Waypoint` objects that carry
`Player_N_Start`), `MPPositionList`, `WorldInfo` and `WaypointsList`.
The **binary** module parses the compiled `BinaryAsset` streams that
BinaryAssetBuilder produces (`data\static.bin`, `data\global.bin`, ...): the
manifest index, each asset's instance slice, its relocation/import sidecars and
its `cdata` blob, plus the hash used to name assets.
## Build
```bash
cmake -S libra3assets -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=g++-16
cmake --build build
ctest --test-dir build --output-on-failure
```
The library is a set of C++20/26 modules (`ra3.assets` plus the `:error`,
`:bytes`, `:refpack`, `:big`, `:binary`, `:csf` and `:map` partitions) that
imports the standard library (`import std;`). That needs **CMake 4.0+ with the
Ninja generator** and a compiler whose standard library ships a `std` module -
**GCC 16** in practice. The tests need nothing but the library itself.
> Building with the distro GCC 16: pass `-DCMAKE_CXX_COMPILER=g++-16`.
## Use
```cpp
import std;
import ra3.assets;
using namespace ra3::assets;
// --- a map, for a map editor -------------------------------------------------
auto document = map_document::open("map_mp_2_black1b.map");
if (const auto height = document.height_map()) {
std::println("terrain {}x{}", height->width, height->height);
auto grid = *height; // copy, then edit
grid.set(10, 10, grid.at(10, 10) + 1); // raise a cell
document.set_height_map(std::move(grid)); // re-encode the chunk
}
for (const auto &start: document.player_starts())
std::println("Player {} at ({}, {})", start.index, start.position.x, start.position.y);
for (const auto &object: document.objects())
if (object.type_name == "*Waypoints/Waypoint")
std::println("{} -> {}", object.property("waypointName")->as_ascii(), object.position.y);
// Lossless: unknown chunks and the name table survive the round-trip.
write_file("edited.map", document.serialize(/* compress = */ true));
// --- a BIG4 archive ----------------------------------------------------------
const auto archive = big_archive::open("Data/GlobalStream.big");
std::println("{} entries", archive.size());
for (const auto *entry: archive.find("audio"))
std::println("{} ({} bytes)", entry->name, entry->size);
// --- a compiled BinaryAsset stream ------------------------------------------
const auto stream = binary_stream_from_big(archive, binary_stream::global, /* need_data = */ false);
for (const auto &[type, count]: stream.type_counts())
std::println("{:6} {}", count, type);
```
The API deliberately avoids integer IDs for anything selectable: chunk kinds are
`chunk_kind` (e.g. `chunk_kind::height_map_data`), streams are `binary_stream`
(`binary_stream::global`), property types are `property_type`, and an
`asset_property` carries a `std::variant<bool, std::int32_t, float, std::string,
std::u16string>`.
## Command-line tool
`ra3assets-cli` is built alongside the library:
```
ra3assets-cli big list <archive.big> [match]
ra3assets-cli big extract <archive.big> <out-dir> [match]
ra3assets-cli binary list <path> [type]
ra3assets-cli binary types <path> [static|global|locale|static_l|static_m]
ra3assets-cli binary cat <path> <Type:Instance|#index> <out-file>
ra3assets-cli map info <map-file>
ra3assets-cli map starts <map-file>
ra3assets-cli map repack <map-file> <out-file> [--compress]
ra3assets-cli csf <csf-or-game-dir-or-big> [label]
ra3assets-cli hash <text>...
```
## Notes and limits
- A `BIG4` archive opened from disk keeps only its index in memory and reads
payloads on demand, so enumerating the ~700 MB retail `StaticStream.big` costs
a few megabytes; pass `need_data = false` to `binary_stream_from_big` when you
only need the manifest. A parsed `.map` document is held in full - that is what
its typed accessors edit.
- `set_height_map` keeps the on-disk `HeightMapData` version (RA3 uses 6, i.e.
16-bit elevations). Older 8-bit maps serialise back as 8-bit.
- RefPack compression is a plain greedy LZ77 matcher. It is lossless against the
bundled decoder and produces streams the game's decoder accepts, but it is
slightly less dense than the retail compressor (≈3% on a typical map).
- `BlendTileData` (terrain texture blending) is preserved but not yet
type-modelled; a map editor should treat the chunk as opaque for now. The
planned follow-up covers `BlendTileData` and `SidesList`.
- Only `BIG4` is supported (RA3's format); older `BIGF` archives are not.
The on-disk layouts were reverse engineered from the shipped files and
cross-checked against [`ra3tools`](../ra3tools/), OpenRA3's `ra3.fs`/`ra3.map`
modules and the OpenSAGE re-implementation (`reference/OpenSAGE`).
+65
View File
@@ -0,0 +1,65 @@
/**
* libra3assets - read (and write) Red Alert 3's built-in resource files.
*
* The primary module interface re-exports every partition:
*
* - `:error` - the `asset_error` exception family
* - `:bytes` - bounds-checked little-/big-endian readers and writers
* - `:refpack` - EA's RefPack codec (decompress and compress)
* - `:big` - `BIG4` archives (`Data\*.big`), read and write
* - `:binary` - compiled `BinaryAsset` streams (`*.bin` + `.manifest`)
* - `:csf` - SAGE `.csf` string tables
* - `:map` - SAGE `.map` containers (`CkMp`), read and write
*
* Nothing here reads game data on its own: callers point the library at their
* own installation (`RA3_GAME_DIR` / `C:\Red Alert 3`).
*/
export module ra3.assets;
export import :error;
export import :bytes;
export import :refpack;
export import :big;
export import :binary;
export import :csf;
export import :map;
import std;
export namespace ra3::assets {
/**
* 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.
*/
[[nodiscard]] inline auto find_game_dir(const std::optional<std::filesystem::path> &explicit_dir = std::nullopt) -> std::optional<std::filesystem::path> {
const 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;
}
/** Every `Data\*.big` archive in an installation, sorted by name. */
[[nodiscard]] inline auto list_archives(const std::filesystem::path &game_dir) -> std::vector<std::filesystem::path> {
std::vector<std::filesystem::path> archives;
std::error_code ec;
for (const auto &entry: std::filesystem::directory_iterator(game_dir / "Data", ec)) {
if (entry.is_regular_file() && entry.path().extension() == ".big") archives.push_back(entry.path());
}
std::ranges::sort(archives);
return archives;
}
} // namespace ra3::assets
+274
View File
@@ -0,0 +1,274 @@
/**
* `BIG4` archives - Red Alert 3's on-disk asset container.
*
* Retail RA3 ships its data as `Data\*.big`: a 16-byte header, a variable-
* length entry index and the 64-byte-aligned payloads. The count and the entry
* offsets/sizes are **big-endian** (the header size is little-endian, matching
* the retail files); each payload may itself be RefPack-compressed, which is
* detected from its own magic rather than flagged in the index.
*
* Both directions are implemented so a map editor can repack an archive.
*/
export module ra3.assets:big;
import std;
import :bytes;
import :error;
import :refpack;
export namespace ra3::assets {
/** One file inside a `BIG4` archive. */
struct big_entry {
std::string name;
std::uint32_t offset = 0;
std::uint32_t size = 0;
};
namespace detail {
[[nodiscard]] inline auto align_up(std::uint64_t value, std::uint64_t alignment) -> std::uint64_t {
return (value + alignment - 1U) / alignment * alignment;
}
[[nodiscard]] inline constexpr auto bswap32(std::uint32_t value) -> std::uint32_t {
return ((value & 0x000000FFU) << 24U) | ((value & 0x0000FF00U) << 8U) | ((value & 0x00FF0000U) >> 8U) | ((value & 0xFF000000U) >> 24U);
}
}
/**
* A parsed `BIG4` archive.
*
* Opening from disk parses only the index; payloads are read (and
* RefPack-decompressed) on demand, so a multi-hundred-megabyte archive costs
* a few megabytes to enumerate. An archive built from an in-memory image
* (`from_bytes`) slices its payloads out of that buffer instead.
*/
class big_archive {
public:
/** Read and parse an archive from disk, keeping only the index in memory. */
[[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<std::uint64_t>(std::filesystem::file_size(path, ec));
if (ec) throw big_error("cannot stat archive: " + path.string());
std::ifstream in(path, std::ios::binary);
if (!in) throw big_error("cannot open archive: " + path.string());
std::array<std::byte, 16> header{};
in.read(reinterpret_cast<char *>(header.data()), static_cast<std::streamsize>(header.size()));
if (!in || std::string_view{reinterpret_cast<const char *>(header.data()), 4} != "BIG4") throw big_error("not a BIG4 archive: " + path.string());
// The index is a dense variable-length table from offset 16; read a
// window and grow it until every entry parses. Payloads are never
// touched, so the memory cost stays at the index size.
const auto size = archive.file_size_;
std::uint64_t window = std::min<std::uint64_t>(size, 1U << 20U);
std::vector<std::byte> buffer;
for (;;) {
buffer.resize(static_cast<std::size_t>(window));
in.clear();
in.seekg(0);
in.read(reinterpret_cast<char *>(buffer.data()), static_cast<std::streamsize>(window));
const auto got = in.gcount();
buffer.resize(got > 0 ? static_cast<std::size_t>(got) : 0U);
std::vector<big_entry> entries;
if (big_archive::parse_index(buffer, entries)) {
archive.entries_ = std::move(entries);
break;
}
if (window >= size) throw big_error("truncated BIG4 index: " + path.string());
window = std::min<std::uint64_t>(size, window * 2U);
}
archive.reindex();
return archive;
}
/** Parse an in-memory archive image; payloads are sliced from it. */
[[nodiscard]] static auto from_bytes(std::vector<std::byte> bytes) -> big_archive {
big_archive archive;
archive.bytes_ = std::move(bytes);
archive.in_memory_ = true;
if (!big_archive::parse_index(archive.bytes_, archive.entries_)) throw big_error("not a BIG4 archive");
archive.reindex();
return archive;
}
[[nodiscard]] auto path() const -> const std::filesystem::path & { return this->path_; }
[[nodiscard]] auto entries() const -> const std::vector<big_entry> & { return this->entries_; }
[[nodiscard]] auto size() const -> std::size_t { return this->entries_.size(); }
[[nodiscard]] auto contains(std::string_view name) const -> bool { return this->index_.contains(std::string{name}); }
/** The entry with exactly this name, or `nullptr`. */
[[nodiscard]] auto find_exact(std::string_view name) const -> const big_entry * {
const auto it = this->index_.find(std::string{name});
return it == this->index_.end() ? nullptr : &this->entries_[it->second];
}
/** Every entry whose name 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: this->entries_) {
if (entry.name.find(needle) != std::string::npos) matches.push_back(&entry);
}
return matches;
}
/** Read an entry's payload; RefPack-decompress it when `decompress`. */
[[nodiscard]] auto read(std::string_view name, bool decompress = true) const -> std::vector<std::byte> {
const auto *entry = this->find_exact(name);
if (entry == nullptr) throw big_error("no such entry: " + std::string{name});
return this->read(*entry, decompress);
}
[[nodiscard]] auto read(const big_entry &entry, bool decompress = true) const -> std::vector<std::byte> {
const auto raw = this->stored(entry.offset, entry.size);
return decompress ? maybe_decompress(raw) : raw;
}
/** The first `count` stored bytes of an entry (no decompression). */
[[nodiscard]] auto read_prefix(std::string_view name, std::size_t count) const -> std::vector<std::byte> {
const auto *entry = this->find_exact(name);
if (entry == nullptr) throw big_error("no such entry: " + std::string{name});
return this->stored(entry->offset, static_cast<std::uint32_t>(std::min<std::size_t>(count, entry->size)));
}
private:
big_archive() = default;
/** Parse a `BIG4` index from `data`; false when it is not fully present. */
[[nodiscard]] static auto parse_index(std::span<const std::byte> data, std::vector<big_entry> &out) -> bool {
if (data.size() < 16U) return false;
byte_reader reader{data};
if (reader.read_ascii(4) != "BIG4") return false;
(void) reader.read_u32(); // total file size (little-endian); implied by the buffer
const auto count = reader.read_be_u32();
(void) reader.read_be_u32(); // index size
// Each entry is at least `u32 offset + u32 size + NUL name` (9 bytes),
// so a count that cannot fit bounds both the reserve and the window.
if (count > (data.size() - 16U) / 9U) return false;
out.clear();
out.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) {
if (reader.remaining() < 8U) return false;
big_entry entry;
entry.offset = reader.read_be_u32();
entry.size = reader.read_be_u32();
// The NUL-terminated name may run past the current window.
std::string name;
bool terminated = false;
while (reader.remaining() > 0U) {
const auto ch = reader.read_u8();
if (ch == 0U) {
terminated = true;
break;
}
name.push_back(static_cast<char>(ch));
}
if (!terminated) return false;
entry.name = std::move(name);
out.push_back(std::move(entry));
}
return true;
}
/** Read `count` stored bytes at `offset`, from memory or from disk. */
[[nodiscard]] auto stored(std::uint32_t offset, std::uint32_t count) const -> std::vector<std::byte> {
if (this->in_memory_) {
if (static_cast<std::uint64_t>(offset) + count > this->bytes_.size()) throw big_error("entry extends past end of archive");
const auto *begin = this->bytes_.data() + offset;
return std::vector<std::byte>{begin, begin + count};
}
if (static_cast<std::uint64_t>(offset) + count > this->file_size_) throw big_error("entry extends past end of archive");
std::ifstream in(this->path_, std::ios::binary);
if (!in) throw big_error("cannot open archive: " + this->path_.string());
in.seekg(static_cast<std::streamoff>(offset));
std::vector<std::byte> raw(count);
if (count > 0U) in.read(reinterpret_cast<char *>(raw.data()), static_cast<std::streamsize>(count));
if (!in) throw big_error("short read from archive: " + this->path_.string());
return raw;
}
auto reindex() -> void {
this->index_.clear();
for (std::size_t i = 0; i < this->entries_.size(); ++i) this->index_.emplace(this->entries_[i].name, i);
}
std::filesystem::path path_;
std::vector<std::byte> bytes_; ///< In-memory image (from_bytes); empty when opened from disk.
bool in_memory_ = false;
std::uint64_t file_size_ = 0;
std::vector<big_entry> entries_;
std::unordered_map<std::string, std::size_t> index_;
};
/**
* Builds a `BIG4` archive.
*
* Entry payloads are written 64-byte aligned (as the retail archives are),
* and the header's index size reproduces the retail convention
* (`last-entry end + 8`). `add(..., compress = true)` RefPack-encodes the
* payload when that is smaller, otherwise stores it verbatim.
*/
class big_writer {
public:
auto add(std::string name, std::vector<std::byte> payload, bool compress = false) -> void {
if (compress) {
auto packed = refpack_compress(payload);
if (packed.size() < payload.size()) payload = std::move(packed);
}
this->items_.push_back({std::move(name), std::move(payload)});
}
/** Serialise the archive into a fresh byte image. */
[[nodiscard]] auto write() const -> std::vector<std::byte> {
std::uint64_t table_end = 16U;
for (const auto &item: this->items_) table_end += 8U + item.name.size() + 1U;
const auto index_size = table_end + 8U;
const auto data_start = detail::align_up(index_size, 64U);
std::vector<std::uint64_t> offsets;
offsets.reserve(this->items_.size());
std::uint64_t cursor = data_start;
for (const auto &item: this->items_) {
offsets.push_back(cursor);
cursor = detail::align_up(cursor + item.payload.size(), 64U);
}
byte_writer writer;
writer.write_ascii("BIG4");
writer.write_u32(static_cast<std::uint32_t>(cursor)); // total file size (little-endian)
writer.write_u32(detail::bswap32(static_cast<std::uint32_t>(this->items_.size())));
writer.write_u32(detail::bswap32(static_cast<std::uint32_t>(index_size)));
for (std::size_t i = 0; i < this->items_.size(); ++i) {
writer.write_u32(detail::bswap32(static_cast<std::uint32_t>(offsets[i])));
writer.write_u32(detail::bswap32(static_cast<std::uint32_t>(this->items_[i].payload.size())));
writer.write_ascii(this->items_[i].name);
writer.write_u8(0);
}
while (writer.size() < data_start) writer.write_u8(0);
for (std::size_t i = 0; i < this->items_.size(); ++i) {
writer.write_bytes(this->items_[i].payload);
if (i + 1U < this->items_.size()) {
while (writer.size() < offsets[i + 1U]) writer.write_u8(0);
}
}
return writer.take();
}
private:
struct pending_item {
std::string name;
std::vector<std::byte> payload;
};
std::vector<pending_item> items_;
};
} // namespace ra3::assets
+396
View File
@@ -0,0 +1,396 @@
/**
* Compiled `BinaryAsset` streams (`Data\*.bin` + `.manifest`).
*
* Retail RA3 does not ship its gameplay/art assets as source files: the
* BinaryAssetBuilder compiles every XML/`.w3x` into a `.bin` *instance stream*
* plus a `.manifest` index (`data\static.bin`, `data\global.bin`, ...). This
* partition parses that index, exposes each asset's instance slice, its
* relocation/import sidecars and its custom-data (`cdata`) blob, and implements
* the Bob-Jenkins-style hash the builder names assets with.
*
* Asset names are `"Type:Instance"` (e.g. `W3DMesh:ABAIRFIELD`); `Type` is
* case-sensitive and `Instance` is lowercased before hashing.
*/
export module ra3.assets:binary;
import std;
import :big;
import :bytes;
import :error;
import :refpack;
export namespace ra3::assets {
/** The compiled instance streams a retail install ships. */
enum class binary_stream {
static_data, ///< `data\static.bin` - the main art/gameplay stream
global, ///< `data\global.bin` - sounds, AI, UI
locale, ///< `data\locale.bin` - localized on-demand textures
static_low, ///< `data\static_l.bin` - low-detail model LODs
static_medium, ///< `data\static_m.bin` - medium-detail model LODs
};
/** The `data\<name>.manifest` stem for a stream. */
[[nodiscard]] inline auto to_string(binary_stream stream) -> std::string_view {
switch (stream) {
case binary_stream::static_data: return "static";
case binary_stream::global: return "global";
case binary_stream::locale: return "locale";
case binary_stream::static_low: return "static_l";
case binary_stream::static_medium: return "static_m";
}
return "static";
}
/** Resolve a stream name (e.g. `"global"`), or `std::nullopt`. */
[[nodiscard]] inline auto binary_stream_from_name(std::string_view name) -> std::optional<binary_stream> {
for (const auto candidate: {binary_stream::static_data, binary_stream::global, binary_stream::locale, binary_stream::static_low, binary_stream::static_medium}) {
if (to_string(candidate) == name) return candidate;
}
return std::nullopt;
}
/** Streams in the order `binary_stream_from_big` prefers when none is given. */
inline constexpr std::array<binary_stream, 5> binary_stream_order{binary_stream::static_data, binary_stream::global, binary_stream::locale, binary_stream::static_low,
binary_stream::static_medium};
/** Types compiled from a `.w3x` model source. */
inline constexpr std::array<std::string_view, 5> w3d_types{"W3DMesh", "W3DHierarchy", "W3DAnimation", "W3DContainer", "W3DCollisionBox"};
/**
* The hash BinaryAssetBuilder names assets with.
*
* A Bob-Jenkins `lookup3`-style mix. `seed` is the running value; the public
* overloads seed it with the length (`hash_string`).
*/
[[nodiscard]] inline auto fast_hash(std::span<const std::byte> data, std::uint32_t seed = 0) -> std::uint32_t {
const auto length = data.size();
if (length == 0U) return 0x1337C0DEU;
const auto at16 = [&](std::size_t pos) -> std::uint32_t {
return static_cast<std::uint32_t>(std::to_integer<std::uint8_t>(data[pos])) |
(static_cast<std::uint32_t>(std::to_integer<std::uint8_t>(data[pos + 1U])) << 8U);
};
std::uint32_t h = seed;
std::size_t pos = 0;
const auto extra = length & 3U;
for (std::size_t i = 0; i < (length >> 2U); ++i) {
h += at16(pos);
h ^= (at16(pos + 2U) ^ (h << 5U)) << 11U;
h += h >> 11U;
pos += 4U;
}
if (extra == 1U) {
h += std::to_integer<std::uint8_t>(data[pos]);
h ^= h << 10U;
h += h >> 1U;
} else if (extra == 2U) {
h += at16(pos);
h ^= h << 11U;
h += h >> 17U;
} else if (extra == 3U) {
h += at16(pos);
h ^= h << 16U;
h ^= static_cast<std::uint32_t>(std::to_integer<std::uint8_t>(data[pos + 2U])) << 18U;
h += h >> 11U;
}
h ^= h << 3U;
h += h >> 5U;
h ^= h << 2U;
h += h >> 15U;
h ^= h << 10U;
return h;
}
/**
* Hash an asset type (case-sensitive, the `TypeId`) or an instance name
* (`case_sensitive = false`, the `InstanceId`).
*/
[[nodiscard]] inline auto hash_string(std::string_view text, bool case_sensitive = true) -> std::uint32_t {
std::string buffer{text};
if (!case_sensitive) {
std::ranges::transform(buffer, buffer.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
}
const auto *bytes = reinterpret_cast<const std::byte *>(buffer.data());
return fast_hash(std::span<const std::byte>{bytes, buffer.size()}, static_cast<std::uint32_t>(buffer.size()));
}
/** One asset in a compiled stream. */
struct binary_asset {
std::uint32_t index = 0;
std::uint32_t type_id = 0;
std::uint32_t instance_id = 0;
std::uint32_t type_hash = 0;
std::uint32_t instance_hash = 0;
std::vector<std::pair<std::uint32_t, std::uint32_t>> references; ///< (typeId, instanceId) pairs
std::string name; ///< "Type:Instance"
std::string source; ///< the `.w3x`/XML it was compiled from
std::uint32_t instance_size = 0;
std::uint32_t relocation_size = 0;
std::uint32_t imports_size = 0;
bool tokenized = false;
std::uint32_t instance_offset = 4; ///< into the decompressed `.bin` (after its 4-byte checksum)
std::uint32_t relocation_offset = 4; ///< into the `.relo`
std::uint32_t imports_offset = 4; ///< into the `.imp`
[[nodiscard]] auto type_name() const -> std::string_view {
const auto colon = this->name.find(':');
return std::string_view{this->name}.substr(0, colon);
}
[[nodiscard]] auto instance_name() const -> std::string_view {
const auto colon = this->name.find(':');
return colon == std::string::npos ? std::string_view{this->name} : std::string_view{this->name}.substr(colon + 1U);
}
/**
* The `cdata` blob name for a custom-data asset (`AudioFile`,
* `OnDemandTexture`, ...):
* `data\<stream>\cdata\<typeId>.<typeHash>.<instanceId>.<instanceHash>.cdata`.
*/
[[nodiscard]] auto cdata_name(std::string_view stream) const -> std::string {
return std::format("data\\{}\\cdata\\{:08x}.{:08x}.{:08x}.{:08x}.cdata", stream, this->type_id, this->type_hash, this->instance_id,
this->instance_hash);
}
};
/** A callback that resolves a `cdata` blob name to its decompressed bytes. */
using cdata_source = std::function<std::optional<std::vector<std::byte>>(const std::string &name)>;
/**
* A parsed `.manifest` (+ optional `.bin`/`.relo`/`.imp`).
*
* Only the manifest is mandatory; `need_data = false` when constructing
* skips the (up to hundreds of megabytes) instance stream, which is all
* listing the assets requires.
*/
class binary_container {
public:
/** Parse a manifest, its instance stream and its optional fixup streams. */
[[nodiscard]] static auto from_bytes(std::vector<std::byte> manifest, std::vector<std::byte> data = {}, std::vector<std::byte> relocation = {},
std::vector<std::byte> imports = {}, std::string stream = "static") -> binary_container {
binary_container container;
container.stream_ = std::move(stream);
container.manifest_ = std::move(manifest);
container.data_ = std::move(data);
container.relocation_ = std::move(relocation);
container.imports_ = std::move(imports);
container.parse();
return container;
}
[[nodiscard]] auto stream() const -> const std::string & { return this->stream_; }
[[nodiscard]] auto version() const -> std::uint16_t { return this->version_; }
[[nodiscard]] auto is_linked() const -> bool { return this->is_linked_; }
[[nodiscard]] auto assets() const -> const std::vector<binary_asset> & { return this->assets_; }
[[nodiscard]] auto size() const -> std::size_t { return this->assets_.size(); }
/** Count of assets per `Type` (the part before `:`). */
[[nodiscard]] auto type_counts() const -> std::map<std::string, std::size_t> {
std::map<std::string, std::size_t> counts;
for (const auto &asset: this->assets_) ++counts[std::string{asset.type_name()}];
return counts;
}
/** Map `(typeId << 32 | instanceId)` to the owning asset. */
[[nodiscard]] auto asset_index() const -> const std::unordered_map<std::uint64_t, std::size_t> & { return this->asset_index_; }
/** Every asset, in manifest order, whose `Type` matches (case-insensitive). */
[[nodiscard]] auto of_type(std::string_view type) const -> std::vector<const binary_asset *> {
std::vector<const binary_asset *> matches;
for (const auto &asset: this->assets_) {
if (std::ranges::equal(asset.type_name(), type, [](char a, char b) { return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b)); }))
matches.push_back(&asset);
}
return matches;
}
/** Resolve `"Type:Instance"` (case-sensitive, then insensitive) or `"#index"`. */
[[nodiscard]] auto find(std::string_view selector) const -> const binary_asset & {
if (selector.starts_with('#')) {
const auto index = static_cast<std::size_t>(std::stoul(std::string{selector.substr(1)}));
if (index >= this->assets_.size()) throw binary_error("asset index out of range: " + std::string{selector});
return this->assets_[index];
}
for (const auto &asset: this->assets_) {
if (asset.name == selector) return asset;
}
for (const auto &asset: this->assets_) {
if (std::ranges::equal(asset.name, selector, [](char a, char b) { return std::tolower(static_cast<unsigned char>(a)) == std::tolower(static_cast<unsigned char>(b)); }))
return asset;
}
throw binary_error("no such asset: " + std::string{selector});
}
auto set_cdata_source(cdata_source source) -> void { this->cdata_ = std::move(source); }
[[nodiscard]] auto read_instance(const binary_asset &asset) const -> std::span<const std::byte> {
if (asset.instance_size == 0U) return {};
if (static_cast<std::uint64_t>(asset.instance_offset) + asset.instance_size > this->data_.size()) throw binary_error("instance extends past stream: " + asset.name);
return std::span<const std::byte>{this->data_}.subspan(asset.instance_offset, asset.instance_size);
}
[[nodiscard]] auto read_relocation(const binary_asset &asset) const -> std::span<const std::byte> {
if (asset.relocation_size == 0U) return {};
if (static_cast<std::uint64_t>(asset.relocation_offset) + asset.relocation_size > this->relocation_.size()) throw binary_error("relocation extends past stream: " + asset.name);
return std::span<const std::byte>{this->relocation_}.subspan(asset.relocation_offset, asset.relocation_size);
}
[[nodiscard]] auto read_imports(const binary_asset &asset) const -> std::span<const std::byte> {
if (asset.imports_size == 0U) return {};
if (static_cast<std::uint64_t>(asset.imports_offset) + asset.imports_size > this->imports_.size()) throw binary_error("imports extend past stream: " + asset.name);
return std::span<const std::byte>{this->imports_}.subspan(asset.imports_offset, asset.imports_size);
}
/** The asset's `cdata` blob, or `std::nullopt` when it has none. */
[[nodiscard]] auto read_cdata(const binary_asset &asset) const -> std::optional<std::vector<std::byte>> {
if (!this->cdata_) return std::nullopt;
return this->cdata_(asset.cdata_name(this->stream_));
}
/** The asset's real payload: its `cdata` blob when present, else its instance. */
[[nodiscard]] auto read_payload(const binary_asset &asset) const -> std::vector<std::byte> {
if (auto cdata = this->read_cdata(asset)) return *cdata;
const auto instance = this->read_instance(asset);
return {instance.begin(), instance.end()};
}
private:
binary_container() = default;
auto parse() -> void {
byte_reader reader{this->manifest_};
const auto is_big_endian = reader.read_u8();
this->is_linked_ = reader.read_bool();
this->version_ = reader.read_u16();
if (is_big_endian != 0U) throw binary_error("big-endian manifests are not supported");
if (this->version_ != 5U && this->version_ != 6U) throw binary_error("unsupported manifest version " + std::to_string(this->version_));
(void) reader.read_u32(); // stream checksum
(void) reader.read_u32(); // all-types hash
const auto count = reader.read_u32();
(void) reader.read_u32(); // total instance data size
(void) reader.read_u32(); // max instance chunk size
(void) reader.read_u32(); // max relocation chunk size
(void) reader.read_u32(); // max imports chunk size
const auto reference_buffer_size = reader.read_u32();
const auto reference_name_buffer_size = reader.read_u32();
const auto name_buffer_size = reader.read_u32();
const auto source_buffer_size = reader.read_u32();
constexpr std::size_t header_size = 48U;
constexpr std::size_t entry_size = 48U;
if (this->manifest_.size() < header_size + static_cast<std::size_t>(count) * entry_size) throw binary_error("manifest entry table is truncated");
const auto entries_off = header_size;
const auto references_off = entries_off + static_cast<std::size_t>(count) * entry_size;
const auto reference_names_off = references_off + reference_buffer_size;
const auto names_off = reference_names_off + reference_name_buffer_size;
const auto sources_off = names_off + name_buffer_size;
if (sources_off + source_buffer_size > this->manifest_.size()) throw binary_error("manifest string buffers are truncated");
const auto cstr = [&](std::size_t base, std::int32_t offset) -> std::string {
if (offset < 0) return {};
const auto start = base + static_cast<std::size_t>(offset);
if (start >= this->manifest_.size()) return {};
std::size_t end = start;
while (end < this->manifest_.size() && std::to_integer<std::uint8_t>(this->manifest_[end]) != 0U) ++end;
return std::string{reinterpret_cast<const char *>(this->manifest_.data() + start), end - start};
};
this->assets_.reserve(count);
std::uint32_t instance_offset = 4;
std::uint32_t relocation_offset = 4;
std::uint32_t imports_offset = 4;
for (std::uint32_t i = 0; i < count; ++i) {
byte_reader entry{std::span<const std::byte>{this->manifest_}.subspan(entries_off + static_cast<std::size_t>(i) * entry_size, entry_size)};
binary_asset asset;
asset.index = i;
asset.type_id = entry.read_u32();
asset.instance_id = entry.read_u32();
asset.type_hash = entry.read_u32();
asset.instance_hash = entry.read_u32();
const auto reference_offset = entry.read_i32();
const auto reference_count = entry.read_i32();
const auto name_offset = entry.read_i32();
const auto source_offset = entry.read_i32();
asset.instance_size = entry.read_u32();
asset.relocation_size = entry.read_u32();
asset.imports_size = entry.read_u32();
asset.tokenized = entry.read_u32() != 0U;
asset.name = cstr(names_off, name_offset);
asset.source = cstr(sources_off, source_offset);
asset.instance_offset = instance_offset;
asset.relocation_offset = relocation_offset;
asset.imports_offset = imports_offset;
instance_offset += asset.instance_size;
relocation_offset += asset.relocation_size;
imports_offset += asset.imports_size;
for (std::int32_t j = 0; j < reference_count; ++j) {
byte_reader ref{std::span<const std::byte>{this->manifest_}.subspan(references_off + static_cast<std::size_t>(reference_offset) +
static_cast<std::size_t>(j) * 8U,
8U)};
asset.references.emplace_back(ref.read_u32(), ref.read_u32());
}
this->asset_index_.emplace((static_cast<std::uint64_t>(asset.type_id) << 32U) | asset.instance_id, this->assets_.size());
this->assets_.push_back(std::move(asset));
}
}
std::string stream_ = "static";
std::vector<std::byte> manifest_;
std::vector<std::byte> data_;
std::vector<std::byte> relocation_;
std::vector<std::byte> imports_;
std::uint16_t version_ = 0;
bool is_linked_ = false;
std::vector<binary_asset> assets_;
std::unordered_map<std::uint64_t, std::size_t> asset_index_;
cdata_source cdata_;
};
namespace detail {
[[nodiscard]] inline auto pick_manifest(std::string_view stream) -> std::string { return std::format("data\\{}.manifest", stream); }
}
/**
* Load one BinaryAsset stream out of a `BIG4` archive.
*
* `stream` selects the `.manifest` (default: the first of
* `binary_stream_order` found). When `need_data` is false only the manifest
* is read, which is enough to enumerate assets without decompressing the
* multi-hundred-megabyte instance stream.
*
* The returned container keeps a `cdata` reader that borrows `archive`; keep
* the archive alive for as long as the container is used.
*/
[[nodiscard]] inline auto binary_stream_from_big(const big_archive &archive, std::optional<binary_stream> stream = std::nullopt, bool need_data = true) -> binary_container {
std::optional<binary_stream> chosen = stream;
if (!chosen) {
for (const auto candidate: binary_stream_order) {
if (archive.contains(detail::pick_manifest(to_string(candidate)))) {
chosen = candidate;
break;
}
}
}
if (!chosen) throw binary_error("archive has no known .manifest entry");
const auto manifest_name = detail::pick_manifest(to_string(*chosen));
const auto base = manifest_name.substr(0, manifest_name.size() - std::string_view{".manifest"}.size());
const auto manifest = maybe_decompress(archive.read(manifest_name, false));
std::vector<std::byte> data;
if (need_data) data = maybe_decompress(archive.read(base + ".bin", false));
const auto relocation = maybe_decompress(archive.read(base + ".relo", false));
const auto imports = maybe_decompress(archive.read(base + ".imp", false));
auto container = binary_container::from_bytes(std::move(manifest), std::move(data), std::move(relocation), std::move(imports), std::string{to_string(*chosen)});
container.set_cdata_source([&archive](const std::string &name) -> std::optional<std::vector<std::byte>> {
const auto *entry = archive.find_exact(name);
if (entry == nullptr) return std::nullopt;
return maybe_decompress(archive.read(*entry, false));
});
return container;
}
} // namespace ra3::assets
+220
View File
@@ -0,0 +1,220 @@
/**
* Bounds-checked little-/big-endian byte readers and writers.
*
* Red Alert 3's asset formats are little-endian, with one notable exception:
* the `BIG4` index stores its counts, offsets and sizes big-endian. Both byte
* orders are provided here. Every read is bounds-checked and throws
* `format_error` rather than reading past the end of the buffer.
*/
export module ra3.assets:bytes;
import std;
import :error;
export namespace ra3::assets {
/** A cursor over a read-only byte range with checked accessors. */
class byte_reader {
public:
explicit byte_reader(std::span<const std::byte> data)
: data_(data) {
}
[[nodiscard]] auto size() const -> std::size_t { return this->data_.size(); }
[[nodiscard]] auto position() const -> std::size_t { return this->pos_; }
[[nodiscard]] auto remaining() const -> std::size_t { return this->data_.size() - this->pos_; }
[[nodiscard]] auto empty() const -> bool { return this->pos_ >= this->data_.size(); }
auto seek(std::size_t at) -> void {
if (at > this->data_.size()) throw format_error("seek past end of buffer");
this->pos_ = at;
}
auto skip(std::size_t count) -> void {
this->require(count);
this->pos_ += count;
}
[[nodiscard]] auto read_u8() -> std::uint8_t {
this->require(1);
return std::to_integer<std::uint8_t>(this->data_[this->pos_++]);
}
[[nodiscard]] auto read_bool() -> bool { return this->read_u8() != 0U; }
[[nodiscard]] auto read_u16() -> std::uint16_t {
return static_cast<std::uint16_t>(this->read_le(2));
}
[[nodiscard]] auto read_u24() -> std::uint32_t { return this->read_le(3); }
[[nodiscard]] auto read_u32() -> std::uint32_t { return this->read_le(4); }
[[nodiscard]] auto read_u64() -> std::uint64_t { return this->read_le(8); }
[[nodiscard]] auto read_i16() -> std::int16_t { return static_cast<std::int16_t>(this->read_u16()); }
[[nodiscard]] auto read_i32() -> std::int32_t { return static_cast<std::int32_t>(this->read_u32()); }
[[nodiscard]] auto read_f32() -> float { return std::bit_cast<float>(this->read_u32()); }
[[nodiscard]] auto read_be_u16() -> std::uint16_t { return static_cast<std::uint16_t>(this->read_be(2)); }
[[nodiscard]] auto read_be_u32() -> std::uint32_t { return this->read_be(4); }
/** A view of the next `count` bytes; the cursor advances past them. */
[[nodiscard]] auto read_bytes(std::size_t count) -> std::span<const std::byte> {
this->require(count);
const auto view = this->data_.subspan(this->pos_, count);
this->pos_ += count;
return view;
}
/** `count` bytes decoded as Latin-1 (one byte per character). */
[[nodiscard]] auto read_ascii(std::size_t count) -> std::string {
const auto view = this->read_bytes(count);
return std::string{reinterpret_cast<const char *>(view.data()), view.size()};
}
/** A `u16`-length-prefixed ASCII string. */
[[nodiscard]] auto read_u16_prefixed_ascii() -> std::string { return this->read_ascii(this->read_u16()); }
/** A `u16`-length-prefixed string, decoded as UTF-8 (ASCII-compatible). */
[[nodiscard]] auto read_u16_prefixed_ascii_as_utf8() -> std::string { return this->read_u16_prefixed_ascii(); }
/** A `u16`-length-prefixed UTF-16LE string (length counted in code units). */
[[nodiscard]] auto read_u16_prefixed_utf16() -> std::u16string {
const auto count = this->read_u16();
this->require(static_cast<std::size_t>(count) * 2U);
std::u16string text(count, u'\0');
for (std::uint16_t i = 0; i < count; ++i) text[i] = static_cast<char16_t>(this->read_u16());
return text;
}
/** A NUL-terminated ASCII string; the cursor stops just past the NUL. */
[[nodiscard]] auto read_cstring() -> std::string {
std::string text;
while (this->pos_ < this->data_.size()) {
const auto ch = static_cast<char>(this->read_u8());
if (ch == '\0') break;
text.push_back(ch);
}
return text;
}
private:
auto require(std::size_t count) const -> void {
if (this->pos_ + count > this->data_.size()) throw format_error("unexpected end of buffer");
}
[[nodiscard]] auto read_le(std::size_t width) -> std::uint64_t {
this->require(width);
std::uint64_t value = 0;
for (std::size_t i = 0; i < width; ++i) value |= static_cast<std::uint64_t>(std::to_integer<std::uint8_t>(this->data_[this->pos_ + i])) << (8U * i);
this->pos_ += width;
return value;
}
[[nodiscard]] auto read_be(std::size_t width) -> std::uint64_t {
this->require(width);
std::uint64_t value = 0;
for (std::size_t i = 0; i < width; ++i) value = (value << 8U) | static_cast<std::uint64_t>(std::to_integer<std::uint8_t>(this->data_[this->pos_ + i]));
this->pos_ += width;
return value;
}
std::span<const std::byte> data_;
std::size_t pos_ = 0;
};
/** An append-only little-endian byte buffer. */
class byte_writer {
public:
[[nodiscard]] auto size() const -> std::size_t { return this->buffer_.size(); }
[[nodiscard]] auto data() const -> std::span<const std::byte> { return this->buffer_; }
[[nodiscard]] auto take() -> std::vector<std::byte> { return std::move(this->buffer_); }
auto write_u8(std::uint8_t value) -> void { this->buffer_.push_back(static_cast<std::byte>(value)); }
auto write_bool(bool value) -> void { this->write_u8(value ? 1U : 0U); }
auto write_u16(std::uint16_t value) -> void { this->write_le(value, 2); }
auto write_u24(std::uint32_t value) -> void { this->write_le(value, 3); }
auto write_u32(std::uint32_t value) -> void { this->write_le(value, 4); }
auto write_u64(std::uint64_t value) -> void { this->write_le(value, 8); }
auto write_i16(std::int16_t value) -> void { this->write_u16(static_cast<std::uint16_t>(value)); }
auto write_i32(std::int32_t value) -> void { this->write_u32(static_cast<std::uint32_t>(value)); }
auto write_f32(float value) -> void { this->write_u32(std::bit_cast<std::uint32_t>(value)); }
auto write_bytes(std::span<const std::byte> bytes) -> void { this->buffer_.insert(this->buffer_.end(), bytes.begin(), bytes.end()); }
auto write_ascii(std::string_view text) -> void {
for (const auto ch: text) this->buffer_.push_back(static_cast<std::byte>(static_cast<unsigned char>(ch)));
}
auto write_u16_prefixed_ascii(std::string_view text) -> void {
this->write_u16(static_cast<std::uint16_t>(text.size()));
this->write_ascii(text);
}
auto write_u16_prefixed_utf16(std::u16string_view text) -> void {
this->write_u16(static_cast<std::uint16_t>(text.size()));
for (const auto unit: text) {
this->write_u16(static_cast<std::uint16_t>(unit));
}
}
/** Overwrite a previously written `u32` (used to backpatch sizes). */
auto patch_u32(std::size_t offset, std::uint32_t value) -> void {
if (offset + 4U > this->buffer_.size()) throw format_error("patch offset past end of buffer");
for (std::size_t i = 0; i < 4U; ++i) this->buffer_[offset + i] = static_cast<std::byte>((value >> (8U * i)) & 0xFFU);
}
private:
auto write_le(std::uint64_t value, std::size_t width) -> void {
for (std::size_t i = 0; i < width; ++i) this->buffer_.push_back(static_cast<std::byte>((value >> (8U * i)) & 0xFFU));
}
std::vector<std::byte> buffer_;
};
/** Convenience: encode a UTF-8 string (ASCII subset) as UTF-16LE code units. */
[[nodiscard]] inline auto to_utf16(std::string_view text) -> std::u16string {
std::u16string out;
out.reserve(text.size());
for (const auto ch: text) out.push_back(static_cast<char16_t>(static_cast<unsigned char>(ch)));
return out;
}
/** Convenience: narrow a UTF-16 string that is known to be within Latin-1. */
[[nodiscard]] inline auto to_ascii(std::u16string_view text) -> std::string {
std::string out;
out.reserve(text.size());
for (const auto unit: text) out.push_back(static_cast<char>(unit & 0xFFU));
return out;
}
/** Read a whole file as bytes. */
[[nodiscard]] inline auto read_file(const std::filesystem::path &path) -> std::vector<std::byte> {
std::ifstream in(path, std::ios::binary);
if (!in) throw format_error("cannot open file: " + path.string());
in.seekg(0, std::ios::end);
const auto end = in.tellg();
if (end < 0) throw format_error("cannot size file: " + path.string());
std::vector<std::byte> bytes(static_cast<std::size_t>(end));
in.seekg(0, std::ios::beg);
if (!bytes.empty()) in.read(reinterpret_cast<char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
return bytes;
}
/** Write bytes to a file, creating parent directories as needed. */
inline auto write_file(const std::filesystem::path &path, std::span<const std::byte> bytes) -> void {
std::error_code ec;
if (!path.parent_path().empty()) std::filesystem::create_directories(path.parent_path(), ec);
std::ofstream out(path, std::ios::binary);
if (!out) throw format_error("cannot write file: " + path.string());
out.write(reinterpret_cast<const char *>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
if (!out) throw format_error("cannot write file: " + path.string());
}
} // namespace ra3::assets
+180
View File
@@ -0,0 +1,180 @@
/**
* SAGE `.csf` string tables (`data\gamestrings.csf`).
*
* The compressed unit/label localization table Red Alert 3 ships per language
* in `Lang-*.big` / `English.big`. Each value is a UTF-16 string whose every
* code unit is bit-inverted (`code ^ 0xFFFF`); labels are plain ASCII and are
* matched case-insensitively.
*/
export module ra3.assets:csf;
import std;
import :big;
import :bytes;
import :error;
import :refpack;
export namespace ra3::assets {
/** Magic of the label block (`" LBL"` little-endian). */
inline constexpr std::uint32_t csf_label_flag = 0x4C424C20U;
/** Magic of a value block (`" RTS"` little-endian). */
inline constexpr std::uint32_t csf_value_flag = 0x53545220U;
/** One label and its (usually single) string value. */
struct csf_entry {
std::string label;
std::vector<std::u16string> values;
/** The concatenation of every value block, the way the game renders it. */
[[nodiscard]] auto value() const -> std::u16string {
std::u16string joined;
for (const auto &chunk: this->values) joined += chunk;
return joined;
}
};
/** Decode a UTF-16 string (with surrogate pairs) to UTF-8. */
[[nodiscard]] inline auto utf16_to_utf8(std::u16string_view text) -> std::string {
std::string out;
for (std::size_t i = 0; i < text.size(); ++i) {
auto code = static_cast<std::uint32_t>(text[i]);
if (code >= 0xD800U && code <= 0xDBFFU && i + 1U < text.size()) {
const auto low = static_cast<std::uint32_t>(text[i + 1U]);
if (low >= 0xDC00U && low <= 0xDFFFU) {
code = 0x10000U + ((code - 0xD800U) << 10U) + (low - 0xDC00U);
++i;
}
}
if (code < 0x80U) {
out.push_back(static_cast<char>(code));
} else if (code < 0x800U) {
out.push_back(static_cast<char>(0xC0U | (code >> 6U)));
out.push_back(static_cast<char>(0x80U | (code & 0x3FU)));
} else if (code < 0x10000U) {
out.push_back(static_cast<char>(0xE0U | (code >> 12U)));
out.push_back(static_cast<char>(0x80U | ((code >> 6U) & 0x3FU)));
out.push_back(static_cast<char>(0x80U | (code & 0x3FU)));
} else {
out.push_back(static_cast<char>(0xF0U | (code >> 18U)));
out.push_back(static_cast<char>(0x80U | ((code >> 12U) & 0x3FU)));
out.push_back(static_cast<char>(0x80U | ((code >> 6U) & 0x3FU)));
out.push_back(static_cast<char>(0x80U | (code & 0x3FU)));
}
}
return out;
}
/**
* A parsed `.csf` table.
*
* Labels are stored verbatim; lookup upper-cases the key because the engine
* treats them case-insensitively.
*/
class csf_table {
public:
/** Parse a `.csf` image. */
[[nodiscard]] static auto parse(std::span<const std::byte> bytes) -> csf_table {
byte_reader reader{bytes};
const auto magic = reader.read_ascii(4);
if (magic != "CSF " && magic != " FSC") throw csf_error("not a CSF file");
csf_table table;
table.version_ = reader.read_u32();
const auto num_labels = reader.read_u32();
(void) reader.read_u32(); // number of value blocks
(void) reader.read_u32(); // reserved
(void) reader.read_u32(); // reserved
table.entries_.reserve(num_labels);
for (std::uint32_t i = 0; i < num_labels; ++i) {
(void) reader.read_u32(); // label flag (" LBL")
const auto value_count = reader.read_u32();
const auto label_length = reader.read_u32();
csf_entry entry;
entry.label = reader.read_ascii(label_length);
entry.values.reserve(value_count);
for (std::uint32_t j = 0; j < value_count; ++j) {
(void) reader.read_u32(); // value flag (" RTS")
const auto char_count = reader.read_u32();
std::u16string value(char_count, u'\0');
for (std::uint32_t k = 0; k < char_count; ++k) value[k] = static_cast<char16_t>(reader.read_u16() ^ 0xFFFFU);
entry.values.push_back(std::move(value));
}
table.entries_.push_back(std::move(entry));
}
table.reindex();
return table;
}
/** Read and parse a `.csf` file from disk. */
[[nodiscard]] static auto open(const std::filesystem::path &path) -> csf_table {
return csf_table::parse(read_file(path));
}
/** Load `data\gamestrings.csf` out of an archive. */
[[nodiscard]] static auto from_big(const big_archive &archive, std::string_view entry = "data\\gamestrings.csf") -> csf_table {
return csf_table::parse(maybe_decompress(archive.read(entry, false)));
}
/** Serialise the table back to a `.csf` image. */
[[nodiscard]] auto write() const -> std::vector<std::byte> {
byte_writer writer;
writer.write_ascii(" FSC");
writer.write_u32(this->version_);
writer.write_u32(static_cast<std::uint32_t>(this->entries_.size()));
std::uint32_t value_count = 0;
for (const auto &entry: this->entries_) value_count += static_cast<std::uint32_t>(entry.values.size());
writer.write_u32(value_count);
writer.write_u32(0);
writer.write_u32(0);
for (const auto &entry: this->entries_) {
writer.write_u32(csf_label_flag);
writer.write_u32(static_cast<std::uint32_t>(entry.values.size()));
writer.write_u32(static_cast<std::uint32_t>(entry.label.size()));
writer.write_ascii(entry.label);
for (const auto &value: entry.values) {
writer.write_u32(csf_value_flag);
writer.write_u32(static_cast<std::uint32_t>(value.size()));
for (const auto unit: value) writer.write_u16(static_cast<std::uint16_t>(unit) ^ 0xFFFFU);
}
}
return writer.take();
}
[[nodiscard]] auto version() const -> std::uint32_t { return this->version_; }
[[nodiscard]] auto entries() const -> const std::vector<csf_entry> & { return this->entries_; }
[[nodiscard]] auto size() const -> std::size_t { return this->entries_.size(); }
/** The entry for `label`, or `nullptr`; the match is case-insensitive. */
[[nodiscard]] auto find(std::string_view label) const -> const csf_entry * {
auto key = std::string{label};
std::ranges::transform(key, key.begin(), [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
const auto it = this->index_.find(key);
return it == this->index_.end() ? nullptr : &this->entries_[it->second];
}
/** The UTF-8 value for `label`, or `fallback` when absent. */
[[nodiscard]] auto lookup(std::string_view label, std::string fallback = {}) const -> std::string {
const auto *entry = this->find(label);
if (entry == nullptr) return fallback;
auto text = utf16_to_utf8(entry->value());
return text.empty() ? std::move(fallback) : text;
}
private:
auto reindex() -> void {
this->index_.clear();
for (std::size_t i = 0; i < this->entries_.size(); ++i) {
auto key = this->entries_[i].label;
std::ranges::transform(key, key.begin(), [](unsigned char ch) { return static_cast<char>(std::toupper(ch)); });
this->index_.try_emplace(std::move(key), i);
}
}
std::uint32_t version_ = 3;
std::vector<csf_entry> entries_;
std::unordered_map<std::string, std::size_t> index_;
};
} // namespace ra3::assets
+56
View File
@@ -0,0 +1,56 @@
/**
* Exception hierarchy of libra3assets.
*
* Every failure the library reports derives from `asset_error` (itself a
* `std::runtime_error`), so a caller can catch the whole family with one
* handler and still discriminate by the concrete type when it matters.
*/
export module ra3.assets:error;
import std;
export namespace ra3::assets {
/** Base class of every failure libra3assets reports. */
class asset_error: public std::runtime_error {
public:
using std::runtime_error::runtime_error;
};
/** A byte range is not a well-formed Red Alert 3 asset. */
class format_error: public asset_error {
public:
using asset_error::asset_error;
};
/** An EA RefPack stream is malformed or its length disagrees. */
class refpack_error: public asset_error {
public:
using asset_error::asset_error;
};
/** A `BIG4` archive is malformed, truncated or missing an entry. */
class big_error: public asset_error {
public:
using asset_error::asset_error;
};
/** A compiled `BinaryAsset` manifest/stream is malformed. */
class binary_error: public asset_error {
public:
using asset_error::asset_error;
};
/** A SAGE `.csf` string table is malformed. */
class csf_error: public asset_error {
public:
using asset_error::asset_error;
};
/** A SAGE `.map` container or one of its chunks is malformed. */
class map_error: public asset_error {
public:
using asset_error::asset_error;
};
} // namespace ra3::assets
+778
View File
@@ -0,0 +1,778 @@
/**
* SAGE `.map` containers - the core of the map editor.
*
* A `.map` is a `CkMp` chunk tree. On disk it may be wrapped twice: inside a
* `BIG4` the payload is one RefPack stream; the `.map` file it yields is either
* a bare `CkMp` tree or an `EAR\0`-wrapped RefPack stream around one. This
* partition unwraps all three shapes and models the tree as an ordered list of
* named, versioned chunks.
*
* The container round-trips losslessly: chunks this library does not model are
* preserved byte-for-byte, and the asset-name table keeps its original indices
* so unparsed chunks (which embed name indices) stay valid. On top of the
* container sit typed accessors for the chunks a map editor needs:
* `HeightMapData` (the terrain grid), `ObjectsList` (every placed prop, building
* and `*Waypoints/Waypoint`), `MPPositionList`, `WorldInfo` and `WaypointsList`.
*/
export module ra3.assets:map;
import std;
import :big;
import :bytes;
import :error;
import :refpack;
export namespace ra3::assets {
/** A world-space coordinate. */
struct coord3d {
float x = 0.0F;
float y = 0.0F;
float z = 0.0F;
};
/**
* A map object's `RoadType` bit flags.
*
* Zero for most objects; set on objects the terrain road system connects
* (`Start`/`End`/`BridgeStart`/`BridgeEnd`/`Angled`/`TightCurve`/`EndCap`).
*/
enum class road_type : std::uint32_t {
none = 0,
start = 2,
end = 4,
angled = 8,
bridge_start = 16,
bridge_end = 32,
tight_curve = 64,
end_cap = 128,
unknown3 = 256,
unknown4 = 512,
};
[[nodiscard]] constexpr auto operator|(road_type a, road_type b) -> road_type {
return static_cast<road_type>(static_cast<std::uint32_t>(a) | static_cast<std::uint32_t>(b));
}
[[nodiscard]] constexpr auto operator&(road_type a, road_type b) -> road_type {
return static_cast<road_type>(static_cast<std::uint32_t>(a) & static_cast<std::uint32_t>(b));
}
/** True when `value` has every bit of `flag` set. */
[[nodiscard]] constexpr auto has_flag(road_type value, road_type flag) -> bool {
return (static_cast<std::uint32_t>(value) & static_cast<std::uint32_t>(flag)) != 0U;
}
/** The `CkMp` chunk types SAGE defines, plus `unknown` for anything else. */
enum class chunk_kind {
asset_list,
global_version,
height_map_data,
blend_tile_data,
world_info,
mp_position_list,
sides_list,
library_map_lists,
teams,
player_scripts_list,
build_lists,
objects_list,
polygon_triggers,
trigger_areas,
global_water_settings,
fog_settings,
mission_hot_spots,
mission_objectives,
standing_water_areas,
river_areas,
standing_wave_areas,
global_lighting,
post_effects_chunk,
environment_data,
named_cameras,
camera_animation_list,
castle_templates,
waypoints_list,
skybox_settings,
unknown,
};
/** The canonical chunk name (the `unknown` kind has no name). */
[[nodiscard]] inline auto to_string(chunk_kind kind) -> std::string_view {
switch (kind) {
case chunk_kind::asset_list: return "AssetList";
case chunk_kind::global_version: return "GlobalVersion";
case chunk_kind::height_map_data: return "HeightMapData";
case chunk_kind::blend_tile_data: return "BlendTileData";
case chunk_kind::world_info: return "WorldInfo";
case chunk_kind::mp_position_list: return "MPPositionList";
case chunk_kind::sides_list: return "SidesList";
case chunk_kind::library_map_lists: return "LibraryMapLists";
case chunk_kind::teams: return "Teams";
case chunk_kind::player_scripts_list: return "PlayerScriptsList";
case chunk_kind::build_lists: return "BuildLists";
case chunk_kind::objects_list: return "ObjectsList";
case chunk_kind::polygon_triggers: return "PolygonTriggers";
case chunk_kind::trigger_areas: return "TriggerAreas";
case chunk_kind::global_water_settings: return "GlobalWaterSettings";
case chunk_kind::fog_settings: return "FogSettings";
case chunk_kind::mission_hot_spots: return "MissionHotSpots";
case chunk_kind::mission_objectives: return "MissionObjectives";
case chunk_kind::standing_water_areas: return "StandingWaterAreas";
case chunk_kind::river_areas: return "RiverAreas";
case chunk_kind::standing_wave_areas: return "StandingWaveAreas";
case chunk_kind::global_lighting: return "GlobalLighting";
case chunk_kind::post_effects_chunk: return "PostEffectsChunk";
case chunk_kind::environment_data: return "EnvironmentData";
case chunk_kind::named_cameras: return "NamedCameras";
case chunk_kind::camera_animation_list: return "CameraAnimationList";
case chunk_kind::castle_templates: return "CastleTemplates";
case chunk_kind::waypoints_list: return "WaypointsList";
case chunk_kind::skybox_settings: return "SkyboxSettings";
case chunk_kind::unknown: break;
}
return {};
}
/** Classify a chunk name. */
[[nodiscard]] inline auto chunk_kind_of(std::string_view name) -> chunk_kind {
for (int i = 0; i < static_cast<int>(chunk_kind::unknown); ++i) {
const auto kind = static_cast<chunk_kind>(i);
if (to_string(kind) == name) return kind;
}
return chunk_kind::unknown;
}
/** The map's asset-name table: `index -> name`, indices preserved on write. */
class name_table {
public:
[[nodiscard]] auto name(std::uint32_t index) const -> std::string_view {
if (index >= this->names_.size()) throw map_error("asset name index out of range: " + std::to_string(index));
return this->names_[index];
}
/** The existing index of `name`, or a freshly appended one. */
[[nodiscard]] auto get_or_create(std::string_view name) -> std::uint32_t {
for (std::uint32_t i = 1; i < this->names_.size(); ++i) {
if (this->names_[i] == name) return i;
}
this->names_.emplace_back(name);
return static_cast<std::uint32_t>(this->names_.size() - 1U);
}
[[nodiscard]] auto entries() const -> const std::vector<std::string> & { return this->names_; }
[[nodiscard]] auto size() const -> std::size_t { return this->names_.empty() ? 0U : this->names_.size() - 1U; }
/** Resize the table (used while parsing); index 0 stays the unused slot. */
auto resize(std::size_t count) -> void { this->names_.resize(count); }
/** Assign the name at `index` (used while parsing). */
auto set_name(std::uint32_t index, std::string name) -> void { this->names_.at(index) = std::move(name); }
private:
std::vector<std::string> names_{std::string{}}; // index 0 is unused
};
/** One `CkMp` chunk: a named, versioned, opaque payload. */
struct map_chunk {
std::string name;
chunk_kind kind = chunk_kind::unknown;
std::uint32_t asset_index = 0;
std::uint16_t version = 0;
std::vector<std::byte> payload;
};
/** A `HeightMapData` border rectangle. */
struct height_map_border {
std::uint32_t corner1_x = 0;
std::uint32_t corner1_y = 0;
std::uint32_t x = 0;
std::uint32_t y = 0;
};
/** The parsed `HeightMapData` chunk: the terrain elevation grid. */
struct height_map_data {
std::uint32_t width = 0;
std::uint32_t height = 0;
std::uint32_t border_width = 0;
std::vector<height_map_border> borders;
std::vector<std::uint16_t> elevations; ///< `width * height`, row-major (y outer, x inner)
std::uint16_t version = 6;
/** Metres per elevation unit (uint16 grids scale by 0.0390625). */
[[nodiscard]] auto vertical_scale() const -> float { return this->version >= 5U ? 0.0390625F : 0.625F; }
[[nodiscard]] auto at(std::uint32_t x, std::uint32_t y) const -> std::uint16_t { return this->elevations.at(static_cast<std::size_t>(y) * this->width + x); }
auto set(std::uint32_t x, std::uint32_t y, std::uint16_t value) -> void { this->elevations.at(static_cast<std::size_t>(y) * this->width + x) = value; }
/** Parse a `HeightMapData` chunk payload. */
[[nodiscard]] static auto parse(std::uint16_t version, std::span<const std::byte> payload) -> height_map_data {
byte_reader reader{payload};
height_map_data data;
data.version = version;
data.width = reader.read_u32();
data.height = reader.read_u32();
data.border_width = reader.read_u32();
const auto border_count = reader.read_u32();
data.borders.reserve(border_count);
for (std::uint32_t i = 0; i < border_count; ++i) {
height_map_border border;
if (version >= 6U) {
border.corner1_x = reader.read_u32();
border.corner1_y = reader.read_u32();
}
border.x = reader.read_u32();
border.y = reader.read_u32();
data.borders.push_back(border);
}
const auto area = reader.read_u32();
const auto expected = static_cast<std::uint64_t>(data.width) * data.height;
if (area != expected) throw map_error("HeightMapData area does not match width * height");
data.elevations.resize(static_cast<std::size_t>(expected));
for (std::uint32_t y = 0; y < data.height; ++y) {
for (std::uint32_t x = 0; x < data.width; ++x) {
data.elevations[static_cast<std::size_t>(y) * data.width + x] = version >= 5U ? reader.read_u16() : reader.read_u8();
}
}
return data;
}
/** Serialise the chunk payload (without the 10-byte chunk header). */
[[nodiscard]] auto serialize() const -> std::vector<std::byte> {
byte_writer writer;
writer.write_u32(this->width);
writer.write_u32(this->height);
writer.write_u32(this->border_width);
writer.write_u32(static_cast<std::uint32_t>(this->borders.size()));
for (const auto &border: this->borders) {
if (this->version >= 6U) {
writer.write_u32(border.corner1_x);
writer.write_u32(border.corner1_y);
}
writer.write_u32(border.x);
writer.write_u32(border.y);
}
writer.write_u32(this->width * this->height);
for (const auto elevation: this->elevations) {
if (this->version >= 5U) {
writer.write_u16(elevation);
} else {
writer.write_u8(static_cast<std::uint8_t>(elevation));
}
}
return writer.take();
}
};
/** The value type of an asset property. */
enum class property_type : std::uint8_t {
boolean = 0,
integer = 1,
real_number = 2,
ascii_string = 3,
unicode_string = 4,
unknown = 5,
};
/** The payload of an `asset_property`; the active alternative tracks `property_type`. */
using property_value = std::variant<bool, std::int32_t, float, std::string, std::u16string>;
/** One key/value pair carried by a map object or the world info. */
struct asset_property {
std::string name;
property_type type = property_type::integer;
property_value value{}; ///< bool, int32, float, ASCII/UTF-8 string, or UTF-16 string
[[nodiscard]] static auto boolean(std::string name, bool value) -> asset_property {
return {std::move(name), property_type::boolean, value};
}
[[nodiscard]] static auto integer(std::string name, std::int32_t value) -> asset_property {
return {std::move(name), property_type::integer, value};
}
[[nodiscard]] static auto real(std::string name, float value) -> asset_property {
return {std::move(name), property_type::real_number, value};
}
[[nodiscard]] static auto text(std::string name, std::string value) -> asset_property {
return {std::move(name), property_type::ascii_string, std::move(value)};
}
[[nodiscard]] static auto wide_text(std::string name, std::u16string value) -> asset_property {
return {std::move(name), property_type::unicode_string, std::move(value)};
}
[[nodiscard]] auto as_bool() const -> bool {
const auto *held = std::get_if<bool>(&this->value);
return held != nullptr && *held;
}
[[nodiscard]] auto as_int() const -> std::int32_t {
const auto *held = std::get_if<std::int32_t>(&this->value);
return held != nullptr ? *held : 0;
}
[[nodiscard]] auto as_real() const -> float {
const auto *held = std::get_if<float>(&this->value);
return held != nullptr ? *held : 0.0F;
}
[[nodiscard]] auto as_ascii() const -> std::string_view {
const auto *held = std::get_if<std::string>(&this->value);
return held != nullptr ? std::string_view{*held} : std::string_view{};
}
[[nodiscard]] auto as_unicode() const -> const std::u16string * { return std::get_if<std::u16string>(&this->value); }
};
/** One object placed on the map (building, prop, waypoint, ...). */
struct map_object {
std::string type_name; ///< the SAGE `ThingTemplate` name, e.g. `*Waypoints/Waypoint`
coord3d position;
float angle = 0.0F;
road_type road = road_type::none; ///< road connectivity flags (0 for most objects)
std::uint16_t version = 1;
std::vector<asset_property> properties;
[[nodiscard]] auto property(std::string_view key) const -> const asset_property * {
for (const auto &entry: this->properties) {
if (entry.name == key) return &entry;
}
return nullptr;
}
};
/** A player start position recovered from a `Player_N_Start` waypoint. */
struct player_start {
int index = 0; ///< the `N` in `Player_N_Start` (1-based)
coord3d position;
};
/** One `MPPositionInfo` entry. */
struct mp_position {
bool is_human = false;
bool is_computer = false;
bool load_ai_script = false;
std::uint32_t team = 0;
std::vector<std::string> side_restrictions;
std::uint16_t version = 1;
};
/** One `WaypointPath` (a link between two waypoints by id). */
struct waypoint_path {
std::int32_t start_id = 0;
std::int32_t end_id = 0;
};
namespace detail {
/** True when `data` begins with the ASCII tag (avoids `memcmp` with a literal). */
[[nodiscard]] inline auto starts_with(std::span<const std::byte> data, std::string_view tag) -> bool {
if (data.size() < tag.size()) return false;
for (std::size_t i = 0; i < tag.size(); ++i) {
if (std::to_integer<std::uint8_t>(data[i]) != static_cast<std::uint8_t>(tag[i])) return false;
}
return true;
}
[[nodiscard]] inline auto read_property(byte_reader &reader, const name_table &names) -> asset_property {
asset_property property;
property.type = static_cast<property_type>(reader.read_u8());
property.name = names.name(reader.read_u24());
switch (property.type) {
case property_type::boolean: property.value = reader.read_bool(); break;
case property_type::integer: property.value = reader.read_i32(); break;
case property_type::real_number: property.value = reader.read_f32(); break;
case property_type::ascii_string:
case property_type::unknown: property.value = reader.read_u16_prefixed_ascii(); break;
case property_type::unicode_string: property.value = reader.read_u16_prefixed_utf16(); break;
}
return property;
}
inline auto write_property(byte_writer &writer, const asset_property &property, name_table &names) -> void {
writer.write_u8(static_cast<std::uint8_t>(property.type));
writer.write_u24(names.get_or_create(property.name));
switch (property.type) {
case property_type::boolean: writer.write_bool(property.as_bool()); break;
case property_type::integer: writer.write_i32(property.as_int()); break;
case property_type::real_number: writer.write_f32(property.as_real()); break;
case property_type::ascii_string:
case property_type::unknown: writer.write_u16_prefixed_ascii(property.as_ascii()); break;
case property_type::unicode_string: {
const auto *text = property.as_unicode();
writer.write_u16_prefixed_utf16(text != nullptr ? *text : std::u16string{});
break;
}
}
}
[[nodiscard]] inline auto parse_property_list(byte_reader &reader, const name_table &names) -> std::vector<asset_property> {
const auto count = reader.read_u16();
std::vector<asset_property> properties;
properties.reserve(count);
for (std::uint16_t i = 0; i < count; ++i) properties.push_back(read_property(reader, names));
return properties;
}
inline auto write_property_list(byte_writer &writer, const std::vector<asset_property> &properties, name_table &names) -> void {
writer.write_u16(static_cast<std::uint16_t>(properties.size()));
for (const auto &property: properties) write_property(writer, property, names);
}
/** Split `Player_<n>_Start` into `n`, or 0 when the name does not match. */
[[nodiscard]] inline auto parse_player_start_name(std::string_view name) -> int {
constexpr std::string_view prefix = "Player_";
constexpr std::string_view suffix = "_Start";
if (!name.starts_with(prefix) || !name.ends_with(suffix)) return 0;
const auto digits = name.substr(prefix.size(), name.size() - prefix.size() - suffix.size());
if (digits.empty() || !std::ranges::all_of(digits, [](char ch) { return ch >= '0' && ch <= '9'; })) return 0;
return std::stoi(std::string{digits});
}
}
/**
* A parsed `.map`: the asset-name table plus the ordered chunk list.
*/
class map_document {
public:
/** Unwrap (BIG payload / `EAR\0` / bare RefPack / bare `CkMp`) and parse. */
[[nodiscard]] static auto parse(std::span<const std::byte> file) -> map_document {
auto data = std::vector<std::byte>{file.begin(), file.end()};
if (is_refpack(data)) data = refpack_decompress(data); // a BIG payload
if (detail::starts_with(data, std::string_view{"EAR\0", 4})) {
const auto *body = reinterpret_cast<const std::byte *>(data.data()) + 8;
std::span<const std::byte> payload{body, data.size() - 8U};
data = maybe_decompress(payload);
} else if (is_refpack(data)) {
data = refpack_decompress(data);
}
return map_document::from_ckmp(data);
}
[[nodiscard]] static auto open(const std::filesystem::path &path) -> map_document {
return map_document::parse(read_file(path));
}
[[nodiscard]] auto names() const -> const name_table & { return this->names_; }
[[nodiscard]] auto names() -> name_table & { return this->names_; }
[[nodiscard]] auto chunks() const -> const std::vector<map_chunk> & { return this->chunks_; }
[[nodiscard]] auto chunks() -> std::vector<map_chunk> & { return this->chunks_; }
[[nodiscard]] auto find_chunk(std::string_view name) const -> const map_chunk * {
for (const auto &chunk: this->chunks_) {
if (chunk.name == name) return &chunk;
}
return nullptr;
}
[[nodiscard]] auto find_chunk(std::string_view name) -> map_chunk * {
for (auto &chunk: this->chunks_) {
if (chunk.name == name) return &chunk;
}
return nullptr;
}
[[nodiscard]] auto has_chunk(std::string_view name) const -> bool { return this->find_chunk(name) != nullptr; }
/** The chunk of a known kind, or `nullptr`. */
[[nodiscard]] auto find_chunk(chunk_kind kind) const -> const map_chunk * {
for (const auto &chunk: this->chunks_) {
if (chunk.kind == kind) return &chunk;
}
return nullptr;
}
[[nodiscard]] auto find_chunk(chunk_kind kind) -> map_chunk * {
for (auto &chunk: this->chunks_) {
if (chunk.kind == kind) return &chunk;
}
return nullptr;
}
[[nodiscard]] auto has_chunk(chunk_kind kind) const -> bool { return this->find_chunk(kind) != nullptr; }
// -- typed chunks ---------------------------------------------------
/** The terrain grid, or `std::nullopt` when the map has no `HeightMapData`. */
[[nodiscard]] auto height_map() const -> std::optional<height_map_data> {
const auto *chunk = this->find_chunk("HeightMapData");
if (chunk == nullptr) return std::nullopt;
return height_map_data::parse(chunk->version, chunk->payload);
}
/** Replace (or create) the `HeightMapData` chunk from a grid. */
auto set_height_map(height_map_data data) -> void {
auto *chunk = this->find_chunk("HeightMapData");
if (chunk == nullptr) {
chunk = &this->chunks_.emplace_back();
chunk->name = "HeightMapData";
chunk->kind = chunk_kind::height_map_data;
chunk->asset_index = this->names_.get_or_create("HeightMapData");
chunk->version = data.version;
} else {
data.version = chunk->version; // keep the file's on-disk version
}
chunk->payload = data.serialize();
}
/** Every object the map places. */
[[nodiscard]] auto objects() const -> std::vector<map_object> {
const auto *chunk = this->find_chunk("ObjectsList");
if (chunk == nullptr) return {};
return map_document::decode_objects(this->names_, chunk->payload);
}
/** Replace (or create) the `ObjectsList` chunk. */
auto set_objects(const std::vector<map_object> &objects) -> void {
auto *chunk = this->find_chunk("ObjectsList");
if (chunk == nullptr) {
chunk = &this->chunks_.emplace_back();
chunk->name = "ObjectsList";
chunk->kind = chunk_kind::objects_list;
chunk->asset_index = this->names_.get_or_create("ObjectsList");
chunk->version = 1;
}
chunk->payload = map_document::encode_objects(this->names_, objects);
}
/** The `Player_N_Start` waypoints, ordered by index. */
[[nodiscard]] auto player_starts() const -> std::vector<player_start> {
std::vector<player_start> starts;
for (const auto &object: this->objects()) {
if (object.type_name != "*Waypoints/Waypoint") continue;
const auto *name_property = object.property("waypointName");
if (name_property == nullptr) continue;
const auto index = detail::parse_player_start_name(name_property->as_ascii());
if (index == 0) continue;
starts.push_back({index, object.position});
}
std::ranges::sort(starts, {}, &player_start::index);
return starts;
}
[[nodiscard]] auto mp_positions() const -> std::vector<mp_position> {
const auto *chunk = this->find_chunk("MPPositionList");
if (chunk == nullptr) return {};
return map_document::decode_mp_positions(chunk->payload);
}
auto set_mp_positions(const std::vector<mp_position> &positions) -> void {
auto *chunk = this->find_chunk("MPPositionList");
if (chunk == nullptr) {
chunk = &this->chunks_.emplace_back();
chunk->name = "MPPositionList";
chunk->kind = chunk_kind::mp_position_list;
chunk->asset_index = this->names_.get_or_create("MPPositionList");
chunk->version = 1;
}
chunk->payload = map_document::encode_mp_positions(this->names_, positions);
}
/** The `WorldInfo` property list. */
[[nodiscard]] auto world_info() const -> std::vector<asset_property> {
const auto *chunk = this->find_chunk("WorldInfo");
if (chunk == nullptr) return {};
byte_reader reader{chunk->payload};
return detail::parse_property_list(reader, this->names_);
}
auto set_world_info(const std::vector<asset_property> &properties) -> void {
auto *chunk = this->find_chunk("WorldInfo");
if (chunk == nullptr) {
chunk = &this->chunks_.emplace_back();
chunk->name = "WorldInfo";
chunk->kind = chunk_kind::world_info;
chunk->asset_index = this->names_.get_or_create("WorldInfo");
chunk->version = 1;
}
byte_writer writer;
detail::write_property_list(writer, properties, this->names_);
chunk->payload = writer.take();
}
/** The waypoint paths (`WaypointsList`). */
[[nodiscard]] auto waypoint_paths() const -> std::vector<waypoint_path> {
const auto *chunk = this->find_chunk("WaypointsList");
if (chunk == nullptr) return {};
byte_reader reader{chunk->payload};
const auto count = reader.read_u32();
std::vector<waypoint_path> paths;
paths.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) paths.push_back({reader.read_i32(), reader.read_i32()});
return paths;
}
// -- serialisation --------------------------------------------------
/** The bare `CkMp` byte image. */
[[nodiscard]] auto to_ckmp() const -> std::vector<std::byte> {
byte_writer writer;
writer.write_ascii("CkMp");
const auto count = static_cast<std::uint32_t>(this->names_.size());
writer.write_u32(count);
for (std::uint32_t i = count; i >= 1U; --i) {
const auto &name = this->names_.entries()[i];
writer.write_u8(static_cast<std::uint8_t>(name.size()));
writer.write_ascii(name);
writer.write_u32(i);
}
for (const auto &chunk: this->chunks_) {
writer.write_u32(chunk.asset_index);
writer.write_u16(chunk.version);
writer.write_u32(static_cast<std::uint32_t>(chunk.payload.size()));
writer.write_bytes(chunk.payload);
}
return writer.take();
}
/**
* A standalone `.map` image.
*
* When `compress` is true the result is `EAR\0` + `u32` decompressed size
* + a RefPack stream (the retail compression). When false it is a bare
* `CkMp` tree. To store a map inside a `BIG4`, pass the *uncompressed*
* image and let `big_writer::add(..., compress = true)` wrap it once.
*/
[[nodiscard]] auto serialize(bool compress = false) const -> std::vector<std::byte> {
auto ckmp = this->to_ckmp();
if (!compress) return ckmp;
byte_writer writer;
writer.write_ascii(std::string_view{"EAR\0", 4});
writer.write_u32(static_cast<std::uint32_t>(ckmp.size()));
const auto packed = refpack_compress(ckmp);
writer.write_bytes(packed);
return writer.take();
}
/** True when the name table carries `name` (top-level chunk, property, ...). */
[[nodiscard]] auto has_name(std::string_view name) const -> bool {
for (std::uint32_t i = 1; i < this->names_.entries().size(); ++i) {
if (this->names_.entries()[i] == name) return true;
}
return false;
}
private:
map_document() = default;
[[nodiscard]] static auto from_ckmp(std::span<const std::byte> data) -> map_document {
if (data.size() < 8U || !detail::starts_with(data, "CkMp")) throw map_error("not a CkMp map");
byte_reader reader{data};
(void) reader.read_ascii(4);
const auto count = reader.read_u32();
map_document document;
document.names_.resize(count + 1U);
for (std::uint32_t i = count; i >= 1U; --i) {
const auto length = reader.read_u8();
auto name = reader.read_ascii(length);
const auto index = reader.read_u32();
if (index != i) throw map_error("asset name table index mismatch");
document.names_.set_name(i, std::move(name));
}
while (reader.remaining() >= 10U) {
const auto index = reader.read_u32();
const auto version = reader.read_u16();
const auto size = reader.read_u32();
if (index >= document.names_.entries().size() || reader.remaining() < size) throw map_error("truncated CkMp chunk");
map_chunk chunk;
chunk.asset_index = index;
chunk.name = std::string{document.names_.entries()[index]};
chunk.kind = chunk_kind_of(chunk.name);
chunk.version = version;
const auto payload = reader.read_bytes(size);
chunk.payload.assign(payload.begin(), payload.end());
document.chunks_.push_back(std::move(chunk));
}
return document;
}
[[nodiscard]] static auto decode_objects(const name_table &names, std::span<const std::byte> payload) -> std::vector<map_object> {
byte_reader reader{payload};
std::vector<map_object> objects;
while (reader.remaining() >= 10U) {
(void) reader.read_u32(); // nested asset index (always "Object")
const auto version = reader.read_u16();
const auto size = reader.read_u32();
if (reader.remaining() < size) throw map_error("truncated Object asset");
const auto body = reader.read_bytes(size);
byte_reader body_reader{body};
map_object object;
object.version = version;
object.position.x = body_reader.read_f32();
object.position.y = body_reader.read_f32();
object.position.z = body_reader.read_f32();
object.angle = body_reader.read_f32();
object.road = static_cast<road_type>(body_reader.read_u32());
object.type_name = body_reader.read_ascii(body_reader.read_u16());
object.properties = detail::parse_property_list(body_reader, names);
objects.push_back(std::move(object));
}
return objects;
}
[[nodiscard]] static auto encode_objects(name_table &names, const std::vector<map_object> &objects) -> std::vector<std::byte> {
const auto object_index = names.get_or_create("Object");
byte_writer writer;
for (const auto &object: objects) {
byte_writer body;
body.write_f32(object.position.x);
body.write_f32(object.position.y);
body.write_f32(object.position.z);
body.write_f32(object.angle);
body.write_u32(static_cast<std::uint32_t>(object.road));
body.write_u16(static_cast<std::uint16_t>(object.type_name.size()));
body.write_ascii(object.type_name);
detail::write_property_list(body, object.properties, names);
writer.write_u32(object_index);
writer.write_u16(object.version);
writer.write_u32(static_cast<std::uint32_t>(body.size()));
writer.write_bytes(body.data());
}
return writer.take();
}
[[nodiscard]] static auto decode_mp_positions(std::span<const std::byte> payload) -> std::vector<mp_position> {
byte_reader reader{payload};
std::vector<mp_position> positions;
while (reader.remaining() >= 10U) {
(void) reader.read_u32(); // nested asset index (always "MPPositionInfo")
const auto version = reader.read_u16();
const auto size = reader.read_u32();
if (reader.remaining() < size) throw map_error("truncated MPPositionInfo asset");
const auto body = reader.read_bytes(size);
byte_reader body_reader{body};
mp_position position;
position.version = version;
position.is_human = body_reader.read_bool();
position.is_computer = body_reader.read_bool();
if (version > 0U) position.load_ai_script = body_reader.read_bool();
position.team = body_reader.read_u32();
if (version > 0U) {
const auto count = body_reader.read_u32();
position.side_restrictions.reserve(count);
for (std::uint32_t i = 0; i < count; ++i) position.side_restrictions.push_back(body_reader.read_u16_prefixed_ascii());
}
positions.push_back(std::move(position));
}
return positions;
}
[[nodiscard]] static auto encode_mp_positions(name_table &names, const std::vector<mp_position> &positions) -> std::vector<std::byte> {
const auto info_index = names.get_or_create("MPPositionInfo");
byte_writer writer;
for (const auto &position: positions) {
byte_writer body;
body.write_bool(position.is_human);
body.write_bool(position.is_computer);
if (position.version > 0U) body.write_bool(position.load_ai_script);
body.write_u32(position.team);
if (position.version > 0U) {
body.write_u32(static_cast<std::uint32_t>(position.side_restrictions.size()));
for (const auto &side: position.side_restrictions) body.write_u16_prefixed_ascii(side);
}
writer.write_u32(info_index);
writer.write_u16(position.version);
writer.write_u32(static_cast<std::uint32_t>(body.size()));
writer.write_bytes(body.data());
}
return writer.take();
}
name_table names_;
std::vector<map_chunk> chunks_;
};
} // namespace ra3::assets
+284
View File
@@ -0,0 +1,284 @@
/**
* EA's RefPack compression codec.
*
* Red Alert 3 compresses individual `BIG4` payloads and every SAGE `.map` with
* RefPack (the `10 FB` stream shared across EA titles). This partition decodes
* and encodes that stream. The decoder is a port of the reference used across
* the project (`ra3tools/ra3_big.py`, `OpenRA3`'s `ra3.fs`); the encoder is a
* greedy LZ77 matcher that emits only canonical tokens, so anything it produces
* is readable by the same decoder (and by the game).
*/
export module ra3.assets:refpack;
import std;
import :bytes;
import :error;
export namespace ra3::assets {
/** Max back-reference distance (the 17-bit RefPack window). */
inline constexpr std::size_t refpack_window = 1U << 17U;
/** True when `data` starts with a RefPack header (`0b??010000`, `0xFB`). */
[[nodiscard]] inline auto is_refpack(std::span<const std::byte> data) -> bool {
return data.size() >= 2U && (std::to_integer<std::uint8_t>(data[0]) & 0x3EU) == 0x10U && std::to_integer<std::uint8_t>(data[1]) == 0xFBU;
}
/** Read the declared output size from a RefPack header without decompressing. */
[[nodiscard]] inline auto refpack_output_size(std::span<const std::byte> data) -> std::uint32_t {
if (!is_refpack(data)) throw refpack_error("not a RefPack stream");
std::size_t pos = 0;
const auto header = std::to_integer<std::uint8_t>(data[pos++]);
const bool large_files = (header & 0x80U) != 0U;
const bool compressed_size_present = (header & 0x01U) != 0U;
pos++; // 0xFB
const std::size_t size_bytes = large_files ? 4U : 3U;
const auto read_size = [&]() -> std::uint32_t {
std::uint32_t value = 0;
for (std::size_t i = 0; i < size_bytes; ++i) {
if (pos >= data.size()) throw refpack_error("truncated RefPack size field");
value = (value << 8U) | std::to_integer<std::uint8_t>(data[pos++]);
}
return value;
};
if (compressed_size_present) (void) read_size();
return read_size();
}
/**
* Decompress an EA RefPack stream.
*
* @throws refpack_error if the stream is malformed or the length disagrees.
*/
[[nodiscard]] inline auto refpack_decompress(std::span<const std::byte> data) -> std::vector<std::byte> {
if (!is_refpack(data)) throw refpack_error("not a RefPack stream");
std::size_t pos = 0;
const auto header = std::to_integer<std::uint8_t>(data[pos++]);
const bool large_files = (header & 0x80U) != 0U;
const bool compressed_size_present = (header & 0x01U) != 0U;
pos++; // 0xFB
const std::size_t size_bytes = large_files ? 4U : 3U;
const auto read_size = [&]() -> std::uint32_t {
std::uint32_t value = 0;
for (std::size_t i = 0; i < size_bytes; ++i) {
if (pos >= data.size()) throw refpack_error("truncated RefPack size field");
value = (value << 8U) | std::to_integer<std::uint8_t>(data[pos++]);
}
return value;
};
if (compressed_size_present) (void) read_size();
const auto out_len = read_size();
std::vector<std::byte> out;
out.reserve(out_len); // guarantees no reallocation, so overlapping reads stay valid
const auto copy_literals = [&](std::size_t 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;
};
// A back-reference may overlap its own output (an RLE run): out[start + i]
// is read one byte at a time, so the pattern repeats correctly.
const auto copy_reference = [&](std::size_t length, std::size_t distance) {
if (distance == 0U || distance > out.size()) throw refpack_error("RefPack back-reference out of range");
const auto start = out.size() - distance;
if (out.size() + length > out_len) throw refpack_error("RefPack output overrun");
for (std::size_t i = 0; i < length; ++i) out.push_back(out[start + i]);
};
while (pos < data.size()) {
const auto cmd = std::to_integer<std::uint8_t>(data[pos++]);
if ((cmd & 0x80U) == 0U) { // 2-byte command
if (pos >= data.size()) throw refpack_error("truncated 2-byte command");
const auto b2 = std::to_integer<std::uint8_t>(data[pos++]);
copy_literals(cmd & 0x03U);
copy_reference(((cmd & 0x1CU) >> 2U) + 3U, ((cmd & 0x60U) << 3U) + b2 + 1U);
} else if ((cmd & 0x40U) == 0U) { // 3-byte command
if (pos + 1U >= data.size()) throw refpack_error("truncated 3-byte command");
const auto b2 = std::to_integer<std::uint8_t>(data[pos]);
const auto b3 = std::to_integer<std::uint8_t>(data[pos + 1U]);
pos += 2U;
copy_literals((b2 & 0xC0U) >> 6U);
copy_reference((cmd & 0x3FU) + 4U, ((b2 & 0x3FU) << 8U) + b3 + 1U);
} else if ((cmd & 0x20U) == 0U) { // 4-byte command
if (pos + 2U >= data.size()) throw refpack_error("truncated 4-byte command");
const auto b2 = std::to_integer<std::uint8_t>(data[pos]);
const auto b3 = std::to_integer<std::uint8_t>(data[pos + 1U]);
const auto b4 = std::to_integer<std::uint8_t>(data[pos + 2U]);
pos += 3U;
copy_literals(cmd & 0x03U);
copy_reference(((cmd & 0x0CU) << 6U) + b4 + 5U, ((cmd & 0x10U) << 12U) + (static_cast<std::size_t>(b2) << 8U) + b3 + 1U);
} else if (cmd < 0xFCU) { // long literal run
copy_literals((static_cast<std::size_t>(cmd & 0x1FU) + 1U) << 2U);
} 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 std::byte> data) -> std::vector<std::byte> {
if (is_refpack(data)) return refpack_decompress(data);
return {data.begin(), data.end()};
}
namespace detail {
/** True when `(length, distance)` maps to one of the three canonical tokens. */
[[nodiscard]] inline constexpr auto refpack_match_encodable(std::size_t length, std::size_t distance) -> bool {
if (length >= 3U && length <= 10U && distance >= 1U && distance <= 1024U) return true;
if (length >= 4U && length <= 67U && distance >= 1U && distance <= 16384U) return true;
if (length >= 5U && length <= 1028U && distance >= 1U && distance <= 131072U) return true;
return false;
}
/** Greedy RefPack encoder state. */
class refpack_encoder {
public:
explicit refpack_encoder(std::span<const std::byte> input)
: input_(input), chain_(input.size(), -1) {
}
[[nodiscard]] auto run() -> std::vector<std::byte> {
const auto n = this->input_.size();
const bool large = n >= (1U << 24U);
this->out_.write_u8(static_cast<std::uint8_t>(0x10U | (large ? 0x80U : 0x00U)));
this->out_.write_u8(0xFBU);
const std::size_t size_bytes = large ? 4U : 3U;
for (std::size_t i = size_bytes; i-- > 0U;) this->out_.write_u8(static_cast<std::uint8_t>((n >> (8U * i)) & 0xFFU));
std::size_t pos = 0;
std::size_t literals_start = 0;
while (pos < n) {
std::size_t best_len = 0;
std::size_t best_dist = 0;
if (pos + 2U < n) this->find_match(pos, best_len, best_dist);
if (best_len >= 3U) {
const auto pending = pos - literals_start;
const auto carry = pending % 4U; // 0..3 literals ride with the token
const auto run = pending - carry; // always a multiple of 4
this->emit_literal_run(literals_start, run);
this->emit_match(literals_start + run, carry, best_len, best_dist);
for (std::size_t i = pos; i < pos + best_len; ++i) this->insert(i);
pos += best_len;
literals_start = pos;
} else {
this->insert(pos);
++pos;
}
}
const auto pending = n - literals_start;
const auto carry = pending % 4U;
this->emit_literal_run(literals_start, pending - carry);
this->out_.write_u8(static_cast<std::uint8_t>(0xFCU | carry));
this->write_literals(literals_start + (pending - carry), carry);
return this->out_.take();
}
private:
void insert(std::size_t pos) {
if (pos + 2U >= this->input_.size()) return;
const auto bucket = this->hash3(pos);
this->chain_[pos] = this->head_[bucket];
this->head_[bucket] = static_cast<std::int32_t>(pos);
}
void find_match(std::size_t pos, std::size_t &best_len, std::size_t &best_dist) const {
const auto n = this->input_.size();
const auto max_len = std::min<std::size_t>(1028U, n - pos);
auto candidate = this->head_[this->hash3(pos)];
int depth = 0;
while (candidate >= 0 && depth < 64) {
const auto c = static_cast<std::size_t>(candidate);
const auto distance = pos - c;
if (distance > refpack_window) break; // the chain only walks backwards
std::size_t length = 0;
while (length < max_len && this->input_[c + length] == this->input_[pos + length]) ++length;
if (length > best_len && refpack_match_encodable(length, distance)) {
best_len = length;
best_dist = distance;
if (length == max_len) break;
}
candidate = this->chain_[c];
++depth;
}
}
void emit_literal_run(std::size_t offset, std::size_t count) {
std::size_t remaining = count;
std::size_t at = offset;
while (remaining >= 4U) {
std::size_t step = std::min<std::size_t>(112U, remaining);
step -= step % 4U;
if (step < 4U) step = 4U;
this->out_.write_u8(static_cast<std::uint8_t>(0xE0U | ((step / 4U) - 1U)));
this->write_literals(at, step);
at += step;
remaining -= step;
}
}
void emit_match(std::size_t literal_offset, std::size_t literal_count, std::size_t length, std::size_t distance) {
const auto d = static_cast<std::uint32_t>(distance - 1U);
if (length >= 3U && length <= 10U && distance <= 1024U) { // 2-byte token
const auto cmd = static_cast<std::uint8_t>((((d >> 8U) & 0x03U) << 5U) | (static_cast<std::uint32_t>(length - 3U) << 2U) |
static_cast<std::uint32_t>(literal_count));
this->out_.write_u8(cmd);
this->out_.write_u8(static_cast<std::uint8_t>(d & 0xFFU));
} else if (length >= 4U && length <= 67U && distance <= 16384U) { // 3-byte token
this->out_.write_u8(static_cast<std::uint8_t>(0x80U | (length - 4U)));
this->out_.write_u8(static_cast<std::uint8_t>((literal_count << 6U) | ((d >> 8U) & 0x3FU)));
this->out_.write_u8(static_cast<std::uint8_t>(d & 0xFFU));
} else if (length >= 5U && length <= 1028U && distance <= 131072U) { // 4-byte token
const auto l = static_cast<std::uint32_t>(length - 5U);
const auto cmd = static_cast<std::uint8_t>(0xC0U | (((l >> 8U) & 0x03U) << 2U) | ((d >> 12U) & 0x10U) |
static_cast<std::uint32_t>(literal_count));
this->out_.write_u8(cmd);
this->out_.write_u8(static_cast<std::uint8_t>((d >> 8U) & 0xFFU));
this->out_.write_u8(static_cast<std::uint8_t>(d & 0xFFU));
this->out_.write_u8(static_cast<std::uint8_t>(l & 0xFFU));
} else {
throw refpack_error("internal error: unencodable RefPack match");
}
this->write_literals(literal_offset, literal_count);
}
void write_literals(std::size_t offset, std::size_t count) {
for (std::size_t i = 0; i < count; ++i) this->out_.write_u8(std::to_integer<std::uint8_t>(this->input_[offset + i]));
}
[[nodiscard]] auto hash3(std::size_t pos) const -> std::size_t {
const auto a = static_cast<std::uint32_t>(std::to_integer<std::uint8_t>(this->input_[pos]));
const auto b = static_cast<std::uint32_t>(std::to_integer<std::uint8_t>(this->input_[pos + 1U]));
const auto c = static_cast<std::uint32_t>(std::to_integer<std::uint8_t>(this->input_[pos + 2U]));
return ((a | (b << 8U) | (c << 16U)) * 2654435761U) >> (32U - 16U);
}
std::span<const std::byte> input_;
std::vector<std::int32_t> chain_;
std::vector<std::int32_t> head_ = std::vector<std::int32_t>(1U << 16U, -1);
byte_writer out_;
};
}
/**
* Compress a byte range into a canonical RefPack stream.
*
* The encoder is a plain greedy LZ77: it never emits a token form the
* decoder above cannot read, so `refpack_decompress(refpack_compress(x))`
* is lossless for every input.
*/
[[nodiscard]] inline auto refpack_compress(std::span<const std::byte> input) -> std::vector<std::byte> {
return detail::refpack_encoder{input}.run();
}
} // namespace ra3::assets
+310
View File
@@ -0,0 +1,310 @@
#include "test_main.hpp"
import std;
import ra3.assets;
using namespace ra3::assets;
namespace {
auto bytes_of(std::string_view text) -> std::vector<std::byte> {
std::vector<std::byte> out;
out.reserve(text.size());
for (const auto ch: text) out.push_back(static_cast<std::byte>(static_cast<unsigned char>(ch)));
return out;
}
auto text_of(std::span<const std::byte> bytes) -> std::string {
return std::string{reinterpret_cast<const char *>(bytes.data()), bytes.size()};
}
/** A deterministic pseudo-random buffer (no external RNG needed). */
auto pseudo_random(std::size_t size, std::uint32_t seed) -> std::vector<std::byte> {
std::vector<std::byte> out;
out.reserve(size);
std::uint32_t state = seed;
for (std::size_t i = 0; i < size; ++i) {
state = state * 1664525U + 1013904223U;
out.push_back(static_cast<std::byte>((state >> 16U) & 0xFFU));
}
return out;
}
auto make_empty_ckmp(const std::vector<std::string> &names) -> std::vector<std::byte> {
byte_writer writer;
writer.write_ascii("CkMp");
writer.write_u32(static_cast<std::uint32_t>(names.size()));
for (std::uint32_t i = static_cast<std::uint32_t>(names.size()); i >= 1U; --i) {
writer.write_u8(static_cast<std::uint8_t>(names[i - 1].size()));
writer.write_ascii(names[i - 1]);
writer.write_u32(i);
}
return writer.take();
}
auto make_csf() -> std::vector<std::byte> {
byte_writer writer;
writer.write_ascii(" FSC");
writer.write_u32(3);
writer.write_u32(2); // labels
writer.write_u32(2); // value blocks
writer.write_u32(0);
writer.write_u32(0);
writer.write_u32(csf_label_flag);
writer.write_u32(1);
writer.write_u32(3);
writer.write_ascii("ABC");
writer.write_u32(csf_value_flag);
writer.write_u32(5);
for (const auto ch: std::string_view{"Hello"}) writer.write_u16(static_cast<std::uint16_t>(ch) ^ 0xFFFFU);
writer.write_u32(csf_label_flag);
writer.write_u32(1);
writer.write_u32(3);
writer.write_ascii("UNI");
writer.write_u32(csf_value_flag);
writer.write_u32(1);
writer.write_u16(0x4E2DU ^ 0xFFFFU); // U+4E2D (CJK)
return writer.take();
}
} // namespace
TEST(refpack_roundtrip_small) {
const std::vector<std::string> samples{"", "A", "AB", "ABC", "ABCD", "abcabcabc", std::string(300, 'z')};
for (const auto &sample: samples) {
const auto input = bytes_of(sample);
const auto packed = refpack_compress(input);
const auto restored = refpack_decompress(packed);
CHECK_EQ(restored.size(), input.size());
CHECK(std::ranges::equal(restored, input));
}
}
TEST(refpack_roundtrip_large_and_repetitive) {
std::vector<std::byte> input;
for (int i = 0; i < 2000; ++i) {
for (const auto ch: std::string_view{"lorem ipsum dolor sit amet "}) input.push_back(static_cast<std::byte>(ch));
}
const auto random = pseudo_random(50000, 0x1234ABCDU);
input.insert(input.end(), random.begin(), random.end());
const auto packed = refpack_compress(input);
CHECK(packed.size() < input.size()); // the repeated prefix must compress
CHECK(std::ranges::equal(refpack_decompress(packed), input));
}
TEST(refpack_decode_known_stream) {
// "abcd" literals, then a back-reference (distance 4, length 3) -> "abcdabc".
const std::vector<std::byte> stream{
std::byte{0x10}, std::byte{0xFB}, std::byte{0x00}, std::byte{0x00}, std::byte{0x07}, std::byte{0xE0},
std::byte{'a'}, std::byte{'b'}, std::byte{'c'}, std::byte{'d'}, std::byte{0x00}, std::byte{0x03},
std::byte{0xFC},
};
CHECK(is_refpack(stream));
CHECK_EQ(text_of(refpack_decompress(stream)), "abcdabc");
CHECK_EQ(refpack_output_size(stream), 7U);
}
TEST(big_roundtrip) {
big_writer writer;
writer.add("data\\a.txt", bytes_of("hello world"));
writer.add("data\\b.bin", bytes_of(std::string(1000, 'x')), true);
const auto image = writer.write();
CHECK(image.size() >= 16U);
const std::string magic{reinterpret_cast<const char *>(image.data()), 4};
CHECK_EQ(magic, "BIG4");
const auto archive = big_archive::from_bytes(image);
CHECK_EQ(archive.size(), 2U);
CHECK(archive.contains("data\\a.txt"));
CHECK_EQ(text_of(archive.read("data\\a.txt")), "hello world");
CHECK_EQ(archive.read("data\\b.bin").size(), 1000U);
CHECK_EQ(archive.find("data\\").size(), 2U);
// The first payload starts 64-byte aligned, as the retail archives do.
CHECK_EQ(archive.entries()[0].offset % 64U, 0U);
}
TEST(big_open_from_disk_is_lazy) {
big_writer writer;
writer.add("data\\a.txt", bytes_of("hello disk"));
writer.add("data\\b.bin", bytes_of(std::string(4096, 'q')), true);
const auto image = writer.write();
const auto path = std::filesystem::temp_directory_path() / "libra3assets_big_lazy.big";
write_file(path, image);
const auto archive = big_archive::open(path);
CHECK_EQ(archive.size(), 2U);
CHECK(archive.path() == path);
CHECK(archive.contains("data\\a.txt"));
CHECK_EQ(archive.entries()[0].offset % 64U, 0U);
CHECK_EQ(text_of(archive.read("data\\a.txt")), "hello disk");
CHECK_EQ(archive.read("data\\b.bin").size(), 4096U);
CHECK_EQ(text_of(archive.read_prefix("data\\a.txt", 5)), "hello");
CHECK(archive.read_prefix("data\\b.bin", 8U).size() == 8U);
std::error_code ec;
std::filesystem::remove(path, ec);
}
TEST(binary_manifest_and_hash) {
CHECK_EQ(hash_string("W3DMesh"), 0xC2B1A262U);
CHECK_EQ(hash_string("ABAIRFIELD", false), 0x2B479BD3U);
CHECK(hash_string("Texture") != hash_string("Texture", false));
const std::string name = "W3DMesh:TEST";
const std::string source = "art:test.w3x";
byte_writer manifest;
manifest.write_u8(0); // not big-endian
manifest.write_u8(0); // not linked
manifest.write_u16(5); // version
manifest.write_u32(0); // checksum
manifest.write_u32(0); // all-types hash
manifest.write_u32(1); // count
manifest.write_u32(12);
manifest.write_u32(0);
manifest.write_u32(0);
manifest.write_u32(0);
manifest.write_u32(0); // reference buffer
manifest.write_u32(0); // reference-name buffer
manifest.write_u32(static_cast<std::uint32_t>(name.size() + 1));
manifest.write_u32(static_cast<std::uint32_t>(source.size() + 1));
manifest.write_u32(hash_string("W3DMesh"));
manifest.write_u32(hash_string("TEST", false));
manifest.write_u32(hash_string("W3DMesh"));
manifest.write_u32(hash_string("TEST", false));
manifest.write_i32(0); // reference offset
manifest.write_i32(0); // reference count
manifest.write_i32(0); // name offset
manifest.write_i32(0); // source offset
manifest.write_u32(8); // instance size
manifest.write_u32(0); // relocation size
manifest.write_u32(0); // imports size
manifest.write_u32(0); // tokenized
manifest.write_ascii(name);
manifest.write_u8(0);
manifest.write_ascii(source);
manifest.write_u8(0);
byte_writer data;
data.write_u32(0); // stream checksum
data.write_ascii("PAYLOAD!");
auto container = binary_container::from_bytes(manifest.take(), data.take());
CHECK_EQ(container.size(), 1U);
const auto &asset = container.assets()[0];
CHECK_EQ(asset.name, "W3DMesh:TEST");
CHECK_EQ(asset.type_name(), "W3DMesh");
CHECK_EQ(asset.instance_name(), "TEST");
CHECK_EQ(asset.source, "art:test.w3x");
CHECK_EQ(text_of(container.read_instance(asset)), "PAYLOAD!");
CHECK_EQ(container.find("#0").name, "W3DMesh:TEST");
CHECK_EQ(container.of_type("w3dmesh").size(), 1U);
CHECK_EQ(container.type_counts().at("W3DMesh"), 1U);
CHECK(container.read_relocation(asset).empty());
container.set_cdata_source([&asset](const std::string &cdata_name) -> std::optional<std::vector<std::byte>> {
if (cdata_name == asset.cdata_name("static")) return bytes_of("CDATA");
return std::nullopt;
});
CHECK(container.read_cdata(asset).has_value());
CHECK_EQ(text_of(container.read_payload(asset)), "CDATA");
}
TEST(csf_roundtrip) {
auto table = csf_table::parse(make_csf());
CHECK_EQ(table.size(), 2U);
CHECK_EQ(table.version(), 3U);
CHECK_EQ(table.lookup("abc"), "Hello"); // lookup is case-insensitive
CHECK_EQ(table.lookup("ABC"), "Hello");
CHECK_EQ(table.lookup("uni"), "\xE4\xB8\xAD");
CHECK(table.find("missing") == nullptr);
const auto rewritten = table.write();
const auto reparsed = csf_table::parse(rewritten);
CHECK_EQ(reparsed.size(), 2U);
CHECK_EQ(reparsed.lookup("abc"), "Hello");
CHECK_EQ(reparsed.lookup("uni"), "\xE4\xB8\xAD");
}
TEST(csf_decode_utf16_surrogate) {
const std::u16string emoji = u"\U0001F600";
CHECK_EQ(utf16_to_utf8(emoji), "\xF0\x9F\x98\x80");
}
TEST(map_roundtrip) {
const std::vector<std::string> names{"HeightMapData", "ObjectsList", "Object", "waypointName", "MPPositionList", "MPPositionInfo", "WorldInfo", "waypointID"};
auto document = map_document::parse(make_empty_ckmp(names));
CHECK_EQ(document.names().size(), names.size());
height_map_data height;
height.width = 2;
height.height = 2;
height.version = 6;
height.elevations = {1, 2, 3, 4};
document.set_height_map(height);
map_object waypoint;
waypoint.type_name = "*Waypoints/Waypoint";
waypoint.position = {100.0F, 200.0F, 0.0F};
waypoint.properties.push_back(asset_property::text("waypointName", "Player_1_Start"));
waypoint.properties.push_back(asset_property::integer("waypointID", 0));
document.set_objects({waypoint});
mp_position position;
position.is_human = true;
position.team = 1;
document.set_mp_positions({position});
const auto ckmp = document.to_ckmp();
const auto reparsed = map_document::parse(ckmp);
CHECK(reparsed.has_chunk(chunk_kind::height_map_data));
CHECK(reparsed.has_chunk(chunk_kind::objects_list));
CHECK(reparsed.has_chunk(chunk_kind::mp_position_list));
CHECK_EQ(reparsed.find_chunk("HeightMapData")->kind, chunk_kind::height_map_data);
CHECK_EQ(chunk_kind_of("BlendTileData"), chunk_kind::blend_tile_data);
CHECK_EQ(chunk_kind_of("NotAChunk"), chunk_kind::unknown);
const auto restored = reparsed.height_map();
CHECK(restored.has_value());
CHECK_EQ(restored->width, 2U);
CHECK_EQ(restored->at(1, 1), 4U);
CHECK_EQ(restored->vertical_scale(), 0.0390625F);
const auto objects = reparsed.objects();
CHECK_EQ(objects.size(), 1U);
CHECK_EQ(objects[0].type_name, "*Waypoints/Waypoint");
CHECK_EQ(objects[0].property("waypointID")->as_int(), 0);
const auto starts = reparsed.player_starts();
CHECK_EQ(starts.size(), 1U);
CHECK_EQ(starts[0].index, 1);
CHECK_EQ(starts[0].position.x, 100.0F);
const auto positions = reparsed.mp_positions();
CHECK_EQ(positions.size(), 1U);
CHECK(positions[0].is_human);
CHECK_EQ(positions[0].team, 1U);
}
TEST(map_compressed_and_big_payload) {
const std::vector<std::string> names{"HeightMapData"};
auto document = map_document::parse(make_empty_ckmp(names));
height_map_data height;
height.width = 1;
height.height = 1;
height.version = 6;
height.elevations = {7};
document.set_height_map(height);
// EAR\0 + RefPack round-trip.
const auto compressed = document.serialize(true);
const auto from_compressed = map_document::parse(compressed);
CHECK(from_compressed.height_map().has_value());
CHECK_EQ(from_compressed.height_map()->at(0, 0), 7U);
// A BIG payload is one more RefPack layer over the map file.
const auto big_payload = refpack_compress(document.to_ckmp());
const auto from_big = map_document::parse(big_payload);
CHECK_EQ(from_big.height_map()->at(0, 0), 7U);
}
+5
View File
@@ -0,0 +1,5 @@
#include "test_main.hpp"
int main() {
return ra3test::run_all();
}
+83
View File
@@ -0,0 +1,83 @@
#ifndef RA3TEST_HPP
#define RA3TEST_HPP
// A tiny dependency-free test harness (registry + CHECK macros). Each unit-test
// executable compiles test_main.cpp and links the sources under test.
#include <functional>
#include <iostream>
#include <sstream>
#include <string>
#include <vector>
namespace ra3test {
struct test_case {
std::string name;
std::function<void()> fn;
};
inline auto registry() -> std::vector<test_case> & {
static std::vector<test_case> tests;
return tests;
}
inline auto failure_count() -> int & {
static int count = 0;
return count;
}
struct registrar {
registrar(std::string name, std::function<void()> fn) { registry().push_back({std::move(name), std::move(fn)}); }
};
inline auto report_failure(const std::string &expr, const std::string &file, int line) -> void {
++failure_count();
std::cerr << " FAIL " << file << ':' << line << " " << expr << '\n';
}
inline auto check(bool condition, const std::string &expr, const std::string &file, int line) -> void {
if (!condition) report_failure(expr, file, line);
}
inline auto run_all() -> int {
int passed = 0;
for (auto &test: registry()) {
const int before = failure_count();
std::cout << "[ RUN ] " << test.name << '\n';
try {
test.fn();
} catch (const std::exception &exc) {
report_failure(std::string("uncaught exception: ") + exc.what(), __FILE__, __LINE__);
}
if (failure_count() == before) {
++passed;
std::cout << "[ OK ] " << test.name << '\n';
}
}
std::cout << "\n" << passed << '/' << registry().size() << " tests passed, " << failure_count() << " failure(s)\n";
return failure_count() == 0 ? 0 : 1;
}
} // namespace ra3test
#define TEST(name) \
static void name(); \
static ::ra3test::registrar ra3test_reg_##name(#name, name); \
static void name()
#define CHECK(cond) ::ra3test::check((cond), #cond, __FILE__, __LINE__)
#define CHECK_EQ(a, b) ::ra3test::check_eq((a), (b), #a, #b, __FILE__, __LINE__)
namespace ra3test {
template<typename A, typename B>
auto check_eq(const A &a, const B &b, const std::string &ea, const std::string &eb, const std::string &file, int line) -> void {
if (!(a == b)) {
std::ostringstream os;
os << ea << " == " << eb;
report_failure(os.str(), file, line);
}
}
}
#endif // RA3TEST_HPP