Files
OpenRA3/apps/openra3/main.cpp
T
EnderTheCoder 70f35d8382 render map objects: decode compiled W3DMesh art, skin and draw over terrain
Read the buildings and props a map places from its compiled art (the
uncompressed worldbuilder stream + embedded DDS textures):

- ra3.models: BAB asset-stream parser (lazy slices), W3DMesh + D3DHierarchy
  decode, DDS (DXT1/3/5 + uncompressed) decode, per-mesh bone remap and
  bind-pose single-joint skinning, flattened to one world-space triangle soup.
- ra3.map: parse the ObjectsList chunk into (type, x, y, z, angle).
- ra3.terrain: render3d rasterises the scene over the raymarched terrain with
  a z-buffer; gpu_terrain carries the scene for the GPU backends.
- ra3.vulkan: second pipeline + depth attachment, terrain.frag writes
  gl_FragDepth, and a small depth bias keeps ground decals from z-fighting.
- objects.fx shaders (compiled to SPIR-V), embedded like scene/terrain.

Only opaque parts are drawn: FX-light billboards (DefaultW3D.fx / BasicW3D.fx)
and damage-fill shells (BuildingsGenericDamageFill.fx) are skipped, since the
latter paint the wrecked interior (e.g. orange CBBuilding_Wood) over the shell.

Ground-decal meshes with no diffuse role and the Road templates themselves are
still not drawn.
2026-09-29 18:08:11 +08:00

1381 lines
71 KiB
C++

#if defined(_WIN32)
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
import std;
import ra3;
import ender.log;
namespace {
#if defined(_WIN32)
/** Path of the crash report written next to the executable. */
[[nodiscard]] auto crash_log_path() -> const std::filesystem::path & {
static const auto path = [] {
std::wstring buffer(32768U, L'\0');
const DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
buffer.resize(length);
return std::filesystem::path{buffer}.parent_path() / L"openra3_crash.log";
}();
return path;
}
auto crash_write(std::string_view text) -> void {
const HANDLE file = CreateFileW(crash_log_path().wstring().c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, nullptr, OPEN_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (file == INVALID_HANDLE_VALUE) return;
DWORD written = 0;
WriteFile(file, text.data(), static_cast<DWORD>(text.size()), &written, nullptr);
CloseHandle(file);
}
/** `address` rendered as `module.dll+0xRVA`, or `0x...` when unmapped. */
auto crash_describe(std::uintptr_t address, char *out, std::size_t size) -> void {
HMODULE module = nullptr;
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCWSTR>(address), &module)) {
wchar_t wide[260] = L"?";
GetModuleFileNameW(module, wide, 260U);
char narrow[260] = "?";
WideCharToMultiByte(CP_UTF8, 0, wide, -1, narrow, sizeof(narrow), nullptr, nullptr);
const char *base = std::strrchr(narrow, '\\');
const auto rva = address - reinterpret_cast<std::uintptr_t>(module);
std::snprintf(out, size, "%s+0x%llX", base != nullptr ? base + 1 : narrow, static_cast<unsigned long long>(rva));
return;
}
std::snprintf(out, size, "0x%llX", static_cast<unsigned long long>(address));
}
LONG WINAPI openra3_crash_handler(EXCEPTION_POINTERS *info) {
char line[512];
const auto *record = info->ExceptionRecord;
std::snprintf(line, sizeof(line), "\n=== OpenRA3 crash ===\nmodule=%s\nexception=0x%08lX address=0x%p\n",
"openra3", static_cast<unsigned long>(record->ExceptionCode), record->ExceptionAddress);
crash_write(line);
void *frames[48] = {};
const USHORT count = CaptureStackBackTrace(0U, 48U, frames, nullptr);
for (USHORT i = 0; i < count; ++i) {
char described[320] = {};
crash_describe(reinterpret_cast<std::uintptr_t>(frames[i]), described, sizeof(described));
std::snprintf(line, sizeof(line), " #%02u %s\n", static_cast<unsigned>(i), described);
crash_write(line);
}
crash_write("=== end ===\n");
return EXCEPTION_EXECUTE_HANDLER;
}
auto install_crash_handler() -> void {
SetUnhandledExceptionFilter(&openra3_crash_handler);
std::set_terminate([] {
crash_write("\n=== OpenRA3 std::terminate ===\n");
if (const auto exception = std::current_exception()) {
try {
std::rethrow_exception(exception);
} catch (const std::exception &error) {
char line[512];
std::snprintf(line, sizeof(line), "what(): %s\n", error.what());
crash_write(line);
} catch (...) {
crash_write("what(): (non-std exception)\n");
}
}
std::abort();
});
}
#endif
/**
* Configure the process logger and start a fresh per-run log file.
*
* `ender::log::file_sink` archives the previous `openra3.log` to a
* timestamped file on open, so every run gets its own log and the previous
* run's log is preserved.
*/
auto setup_logging(const std::filesystem::path &exe_dir) -> void {
namespace log = ender::log;
log::configure({.minimum = log::level::info, .stacktrace_from = log::level::warn});
#if defined(__EMSCRIPTEN__)
// The default sink writes to stderr, which the browser maps to
// console.error regardless of level; use stdout so INFO/WARN appear at
// their real level. There is no file sink on the web.
log::set_sinks({std::make_shared<log::console_sink>(std::cout)});
(void) exe_dir;
#else
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
#endif
log::info("OpenRA3 started");
}
auto print_usage() -> void {
std::puts("OpenRA3 - Red Alert 3 reconstruction");
std::puts("usage:");
std::puts(" openra3 extract --game-dir DIR --out DIR (build-time asset extraction)");
std::puts(" openra3 menu (pick a map, then view it)");
std::puts(" openra3 menu-preview [--out FILE.bmp] (headless render of the menu)");
std::puts(" openra3 maps");
std::puts(" openra3 textures --map ID (loose terrain TGAs a map uses)");
std::puts(" openra3 skirmish [--map ID] [--frames N] [--seed N]");
std::puts(" openra3 render [--map ID] [--frames N] [--seed N] [--zoom Z]");
std::puts(" [--out FILE.bmp] [--vulkan] [--no-window] [--no-sim] [--thumbnail]");
std::puts(" [--3d] [--cam-pitch DEG] [--cam-yaw DEG] [--cam-x U] [--cam-y U]");
std::puts(" [--cam-height U] [--fov DEG] [--width W] [--height H]");
std::puts(" [--fullscreen] [--fps N] (0 = vsync)");
std::puts(" [--vulkan] [--dx11] [--dx12] [--webgpu] [--wasmgl] [--sdl] (preferred display backend)");
std::puts("assets: read from <exe_dir>/assets (extracted at build time by the openra3_assets target)");
}
auto option_value(const std::vector<std::string> &args, std::string_view name) -> std::optional<std::string> {
for (std::size_t i = 0; i + 1U < args.size(); ++i) {
if (args[i] == name) return args[i + 1U];
}
return std::nullopt;
}
auto has_flag(const std::vector<std::string> &args, std::string_view name) -> bool {
return std::find(args.begin(), args.end(), name) != args.end();
}
/** Preferred display backend from the command line (default: Vulkan). */
[[nodiscard]] auto display_backend_from_args(const std::vector<std::string> &args) -> ra3::display::backend {
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) return ra3::display::backend::d3d11;
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) return ra3::display::backend::d3d12;
if (has_flag(args, "--webgpu")) return ra3::display::backend::webgpu;
if (has_flag(args, "--wasmgl")) return ra3::display::backend::wasmgl;
if (has_flag(args, "--sdl")) return ra3::display::backend::sdl;
return ra3::display::backend::vulkan;
}
/** Directory the executable lives in. */
auto executable_dir(const char *argv0) -> std::filesystem::path {
std::error_code ec;
const auto path = std::filesystem::absolute(argv0, ec);
// lexically_normal drops the "." element Emscripten leaves in argv[0]
// ("/./openra3.js" -> "/"), which the wasm FS does not collapse itself.
return ec ? std::filesystem::current_path() : path.parent_path().lexically_normal();
}
auto read_file(const std::filesystem::path &path) -> std::vector<ra3::core::uint8> {
std::ifstream in(path, std::ios::binary);
if (!in) throw std::runtime_error("cannot open " + path.string());
return {std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>()};
}
/** One extracted map in `<assets>/maps`. */
struct asset_map {
std::string id;
std::filesystem::path file;
};
/** Every main map under `root` (recursively; ra3tools keeps the game layout). */
auto list_asset_maps(const std::filesystem::path &root) -> std::vector<asset_map> {
std::unordered_map<std::string, std::filesystem::path> by_id;
std::error_code ec;
if (!std::filesystem::is_directory(root, ec)) return {};
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (!entry.is_regular_file()) continue;
const auto path = entry.path();
const auto name = path.filename().string();
if (name.size() <= 4U || !name.ends_with(".map")) continue;
const auto id = name.substr(0, name.size() - 4U);
if (id.ends_with("_edit")) continue;
const bool game_layout = path.parent_path().filename().string() == id;
const auto it = by_id.find(id);
if (it == by_id.end() || game_layout) by_id[id] = path;
}
std::vector<asset_map> maps;
maps.reserve(by_id.size());
for (auto &[id, path]: by_id) maps.push_back({id, std::move(path)});
std::sort(maps.begin(), maps.end(), [](const asset_map &a, const asset_map &b) { return a.id < b.id; });
return maps;
}
/** Find `<id>_art.tga` anywhere under `root`. */
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";
std::error_code ec;
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (entry.is_regular_file() && entry.path().filename().string() == want) return entry.path();
}
return std::nullopt;
}
/** A map's compiled art stream (`map.manifest` + `map.bin`). */
struct model_stream_paths {
std::filesystem::path manifest;
std::filesystem::path bin;
};
/**
* Locate the compiled art stream that holds a map's buildings/props.
*
* Retail bakes the shared static art (the props a map places) into
* `Data\WBData.big`'s uncompressed `worldbuilder.bin`, so that stream is
* preferred; the per-map `map.bin` (which links to it) is the fallback.
*/
auto find_map_stream(const std::filesystem::path &root, std::string_view id) -> std::optional<model_stream_paths> {
std::error_code ec;
const auto consider = [&](const std::filesystem::path &dir) -> std::optional<model_stream_paths> {
const auto manifest = dir / "map.manifest";
const auto bin = dir / "map.bin";
if (std::filesystem::exists(manifest, ec) && std::filesystem::exists(bin, ec)) return model_stream_paths{manifest, bin};
return std::nullopt;
};
// A `worldbuilder` stream (the full prop art) takes precedence.
for (const auto &dir: {root / "models", root / "raw" / "WBData" / "data", root}) {
const auto manifest = dir / "worldbuilder.manifest";
const auto bin = dir / "worldbuilder.bin";
if (std::filesystem::exists(manifest, ec) && std::filesystem::exists(bin, ec)) return model_stream_paths{manifest, bin};
}
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (entry.is_regular_file() && entry.path().filename() == "worldbuilder.bin") {
const auto manifest = entry.path().parent_path() / "worldbuilder.manifest";
if (std::filesystem::exists(manifest, ec)) return model_stream_paths{manifest, entry.path()};
}
}
if (auto found = consider(root / "models" / id); found) return found;
if (auto found = consider(root / "maps" / id); found) return found;
for (const auto &entry: std::filesystem::recursive_directory_iterator(root, ec)) {
if (!entry.is_directory() || entry.path().filename().string() != id) continue;
if (auto found = consider(entry.path()); found) return found;
}
return std::nullopt;
}
/** Build the map's building/prop scene from its compiled art stream. */
auto build_object_scene(const std::filesystem::path &assets, std::string_view id, const ra3::terrain::map_data &terrain,
const ra3::terrain::render_options &options, std::span<const ra3::core::uint8> ckmp) -> ra3::models::scene {
ra3::models::scene scene;
try {
const auto paths = find_map_stream(assets, id);
if (!paths) {
std::puts("objects: no compiled art stream found");
return scene;
}
const auto stream = ra3::models::asset_stream::load_files(paths->manifest, paths->bin);
std::vector<ra3::models::placement> placements;
for (const auto &object: ra3::map::parse_objects(ckmp)) {
placements.push_back({object.type, object.x, object.y, object.z, object.angle, object.scale});
}
const auto world_w = terrain.world_width();
const auto world_h = terrain.world_height();
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;
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));
return static_cast<float>(terrain.elevation(cx, cy)) * options.z_scale;
});
std::printf("objects: %zu placed, %zu missing, %zu triangles, %zu textures (%s)\n", scene.placed, scene.missing, scene.triangle_count(),
scene.textures.size(), paths->bin.filename().string().c_str());
} catch (const std::exception &error) {
std::printf("objects: failed to build scene (%s)\n", error.what());
}
return scene;
}
/** Write `<assets>/maps/map_names.tsv` from the install's localized string table. */
auto write_map_names(const std::filesystem::path &data_dir, const std::filesystem::path &assets) -> bool {
try {
const auto names = ra3::map::load_map_names_from_data_dir(data_dir);
if (names.empty()) return false;
std::filesystem::create_directories(assets / "maps");
std::ofstream out(assets / "maps" / "map_names.tsv", std::ios::binary);
out << ra3::map::serialize_map_names(names);
return static_cast<bool>(out);
} catch (const std::exception &) {
return false;
}
}
// ---- extraction (build-time) --------------------------------------------
auto extract_assets(const std::filesystem::path &game_dir, const std::filesystem::path &out) -> int {
using namespace ra3;
const auto data = game_dir / "Data";
if (!std::filesystem::is_directory(data)) {
std::printf("no Data directory under %s\n", game_dir.string().c_str());
return 1;
}
std::filesystem::create_directories(out / "maps");
std::filesystem::create_directories(out / "terrain");
// Maps: unwrap each `<id>\<id>.map` to a raw CkMp file, plus its overview art.
std::size_t maps_written = 0;
try {
const auto archive = fs::big_archive::open(data / "MapsMultiplayer.big");
const auto catalog = map::map_catalog::from_archive(archive);
for (const auto &info: catalog.maps()) {
const auto loaded = map::load_map(archive, info);
std::ofstream(out / "maps" / (info.id + ".map"), std::ios::binary).write(reinterpret_cast<const char *>(loaded.bytes.data()),
static_cast<std::streamsize>(loaded.bytes.size()));
auto art_entry = info.entry;
if (art_entry.size() > 4U) art_entry.resize(art_entry.size() - 4U);
art_entry += "_art.tga";
try {
const auto art = archive.read(art_entry, true);
std::ofstream(out / "maps" / (info.id + "_art.tga"), std::ios::binary).write(reinterpret_cast<const char *>(art.data()),
static_cast<std::streamsize>(art.size()));
} catch (const std::exception &) {
}
++maps_written;
}
} catch (const std::exception &error) {
std::printf("map extraction failed: %s\n", error.what());
}
// Terrain textures: every `art\terrain\*.tga` (and normals), decompressed.
std::size_t tgAs = 0;
for (const auto *name: {"Terrain.big", "Core11.big"}) {
const auto path = data / name;
std::error_code ec;
if (!std::filesystem::exists(path, ec)) continue;
try {
const auto archive = fs::big_archive::open(path);
for (const auto &entry: archive.entries()) {
auto lower = entry.name;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
if (!lower.starts_with("art\\terrain\\") || !lower.ends_with(".tga")) continue;
const auto payload = archive.read(entry.name, true);
const auto base = entry.name.substr(entry.name.find_last_of("\\/") + 1U);
std::ofstream(out / "terrain" / base, std::ios::binary).write(reinterpret_cast<const char *>(payload.data()),
static_cast<std::streamsize>(payload.size()));
++tgAs;
}
} catch (const std::exception &error) {
std::printf("terrain extraction failed (%s): %s\n", name, error.what());
}
}
std::printf("extracted %zu maps and %zu terrain textures to %s\n", maps_written, tgAs, out.string().c_str());
write_map_names(game_dir / "Data", out);
return 0;
}
// ---- commands ------------------------------------------------------------
/** The install to extract from when assets are missing: `$RA3_GAME_DIR` or the default. */
auto default_game_dir() -> std::optional<std::filesystem::path> {
if (const auto *env = std::getenv("RA3_GAME_DIR"); env != nullptr && *env != '\0') {
const std::filesystem::path candidate{env};
if (std::filesystem::is_directory(candidate / "Data")) return candidate;
}
const std::filesystem::path candidate{"C:/Red Alert 3"};
if (std::filesystem::is_directory(candidate / "Data")) return candidate;
return std::nullopt;
}
/** Extract the assets on first run if the build did not already. */
auto ensure_assets(const std::filesystem::path &assets) -> bool {
bool ready = !list_asset_maps(assets).empty();
if (!ready) {
if (const auto game = default_game_dir()) {
std::printf("extracting assets from %s to %s ...\n", game->string().c_str(), assets.string().c_str());
ready = extract_assets(*game, assets) == 0;
} else {
std::puts("no assets and no Red Alert 3 install found (set RA3_GAME_DIR)");
return false;
}
}
// Fill in the display-name table if an older extraction predates it.
if (ready && !std::filesystem::exists(assets / "maps" / "map_names.tsv")) {
if (const auto game = default_game_dir()) write_map_names(*game / "Data", assets);
}
return ready;
}
/** Print the loose terrain TGA files a map resolves to (for a wasm preload). */
auto command_textures(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto requested = option_value(args, "--map");
if (!requested) {
std::puts("usage: openra3 textures --map ID");
return 2;
}
const auto maps = list_asset_maps(assets);
const asset_map *picked = nullptr;
for (const auto &map: maps) {
if (map.id == *requested) picked = &map;
}
if (picked == nullptr) {
std::printf("no map '%s'\n", requested->c_str());
return 2;
}
const auto parsed = ra3::terrain::parse_map(ra3::map::to_ckmp(read_file(picked->file)));
const auto files = ra3::terrain::resolve_texture_files(parsed, assets / "terrain");
std::printf("map %s: %zu resolved textures\n", picked->id.c_str(), files.size());
for (const auto &file: files) {
std::printf("%s\n", file.string().c_str());
}
return 0;
}
auto command_maps(const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets);
std::printf("maps: %zu\n", maps.size());
for (const auto &m: maps) {
std::error_code ec;
const auto size = std::filesystem::file_size(m.file, ec);
std::printf(" %-28s %-34s %6llu KB\n", m.id.c_str(), names.lookup(m.id).c_str(), static_cast<unsigned long long>(size / 1024U));
}
return 0;
}
auto command_skirmish(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3;
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
if (maps.empty()) return 1;
const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front();
if (requested) {
const auto it = std::find_if(maps.begin(), maps.end(), [&](const asset_map &m) { return m.id == *requested; });
if (it == maps.end()) {
std::printf("map '%s' not found; using %s\n", requested->c_str(), picked->id.c_str());
} else {
picked = &*it;
}
}
skirmish::match_config config;
config.map_id = picked->id;
if (const auto seed = option_value(args, "--seed")) config.seed = static_cast<core::uint32>(std::stoul(*seed));
if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast<core::uint32>(std::stoul(*frames));
auto starts = map::starts_from_ckmp(map::to_ckmp(read_file(picked->file)));
if (starts.size() < 2U) starts = skirmish::builtin_start_positions();
auto match = skirmish::skirmish_match::create(config, starts);
const auto summary = match.run();
std::printf("OpenRA3 skirmish map=%s seed=%u\n", config.map_id.c_str(), config.seed);
std::puts("start positions:");
for (std::size_t i = 0; i < starts.size(); ++i) std::printf(" P%zu (%.0f, %.0f)\n", i, static_cast<double>(starts[i].x), static_cast<double>(starts[i].y));
std::printf("result: %s winner=%d frames=%u (%.1f s)\n", summary.decided ? "decided" : "timeout", summary.winner, summary.frames,
static_cast<double>(summary.frames) / 30.0);
for (const auto &player: match.players()) {
const auto side = std::string{game::to_string(player.side)};
std::printf(" P%u %-10s %-7s money=%6d units=%2zu kills=%d losses=%d\n", player.index, player.name.c_str(), side.c_str(), player.money,
match.unit_count(player.index), player.kills, player.losses);
}
return 0;
}
auto command_render(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
using namespace ra3;
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
if (maps.empty()) return 1;
const auto requested = option_value(args, "--map");
const asset_map *picked = &maps.front();
if (requested) {
const auto it = std::find_if(maps.begin(), maps.end(), [&](const asset_map &m) { return m.id == *requested; });
if (it != maps.end()) picked = &*it;
}
render::scene_options scene;
const auto names = map::load_map_names(assets);
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);
skirmish::match_config config;
config.map_id = picked->id;
if (const auto seed = option_value(args, "--seed")) config.seed = static_cast<core::uint32>(std::stoul(*seed));
if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast<core::uint32>(std::stoul(*frames));
// A pure map view skips the skirmish simulation entirely (it was the
// multi-second stall before the viewer opened).
if (has_flag(args, "--no-sim")) config.max_frames = 0U;
const auto bytes = map::to_ckmp(read_file(picked->file));
auto starts = map::starts_from_ckmp(bytes);
if (starts.size() < 2U) starts = skirmish::builtin_start_positions();
auto match = skirmish::skirmish_match::create(config, starts);
const auto summary = match.run();
std::vector<render::marker> markers;
for (const auto &player: match.players()) markers.push_back({static_cast<double>(player.start.x), static_cast<double>(player.start.y), render::yellow, 6});
for (const auto &entry: match.units()) {
if (!entry.alive) continue;
const auto color = entry.owner == 0U ? render::blue : render::red;
int radius = 2;
if (data::class_of(entry.kind) == data::target_class::vehicle) radius = 4;
if (data::class_of(entry.kind) == data::target_class::structure) radius = 6;
markers.push_back({static_cast<double>(entry.position.x), static_cast<double>(entry.position.y), color, radius});
}
terrain::render_options terrain_options;
if (const auto value = option_value(args, "--pitch")) terrain_options.pitch = std::stof(*value);
if (const auto value = option_value(args, "--terrain-scale")) terrain_options.scale = static_cast<core::uint32>(std::stoul(*value));
const bool want_thumbnail = has_flag(args, "--thumbnail");
const bool want_3d = has_flag(args, "--3d");
bool perspective = false;
render::image composed;
double world_width = scene.world_width;
double world_height = scene.world_height;
bool rendered_terrain = false;
if (!want_thumbnail) {
try {
const auto terrain = terrain::parse_map(bytes);
const auto textures = terrain::load_textures_from_dir(terrain, assets / "terrain");
if (want_3d) {
render::camera3d camera;
if (!starts.empty()) {
camera.target_x = starts[0].x;
camera.target_y = starts[0].y;
}
if (const auto value = option_value(args, "--cam-x")) camera.target_x = std::stof(*value);
if (const auto value = option_value(args, "--cam-y")) camera.target_y = std::stof(*value);
const auto degrees = [](std::string_view text) {
return static_cast<float>(std::stod(std::string{text}) * 3.14159265358979323846 / 180.0);
};
if (const auto value = option_value(args, "--cam-pitch")) camera.pitch = degrees(*value);
if (const auto value = option_value(args, "--cam-yaw")) camera.yaw = degrees(*value);
if (const auto value = option_value(args, "--cam-height")) camera.height = std::stof(*value);
if (const auto value = option_value(args, "--fov")) camera.fov = degrees(*value);
const auto width = option_value(args, "--width") ? static_cast<core::uint32>(std::stoul(*option_value(args, "--width"))) : 1280U;
const auto height = option_value(args, "--height") ? static_cast<core::uint32>(std::stoul(*option_value(args, "--height"))) : 720U;
std::printf("3D terrain %ux%u cells, textures %zu/%zu\n", terrain.width, terrain.height, textures.resolved(), terrain.textures.size());
const bool offscreen = option_value(args, "--out").has_value() || has_flag(args, "--no-window");
bool gpu_shown = false;
const auto objects = build_object_scene(assets, picked->id, terrain, terrain_options, bytes);
if (!offscreen) {
const auto gpu = terrain::build_gpu_terrain(terrain, textures, terrain_options, objects);
ra3::client::display_options options;
options.title = scene.title;
options.width = static_cast<int>(width);
options.height = static_cast<int>(height);
options.fullscreen = has_flag(args, "--fullscreen");
options.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0;
ra3::render::image mini;
if (has_flag(args, "--minimap")) {
terrain::render_options mini_opts;
mini_opts.scale = 1U;
mini_opts.pitch = 1.0F;
mini = render::downscale(terrain::render(terrain, textures, mini_opts), 220U);
}
gpu_shown = ra3::display::run_terrain(options, gpu, camera, mini, display_backend_from_args(args));
if (gpu_shown) return 0;
std::printf("GPU terrain unavailable; showing the top-down map instead\n");
}
if (offscreen) {
// Offscreen: the software raymarcher produces the 3D image.
composed = terrain::render3d(terrain, textures, camera, width, height, terrain_options, &objects);
perspective = true;
} else {
// Windowed without a GPU: the cheap top-down raster.
composed = terrain::render(terrain, textures, terrain_options);
world_width = static_cast<double>(terrain.world_width());
world_height = static_cast<double>(terrain.world_height());
for (const auto &marker: markers) {
const auto [px, py] = terrain::world_to_pixel(terrain, marker.x, marker.y, terrain_options.scale, terrain_options.pitch);
const auto radius = marker.radius * 3;
composed.fill_circle(px, py, radius, marker.color);
composed.draw_circle(px, py, radius + 1, render::black);
}
}
} else {
composed = terrain::render(terrain, textures, terrain_options);
world_width = static_cast<double>(terrain.world_width());
world_height = static_cast<double>(terrain.world_height());
for (const auto &marker: markers) {
const auto [px, py] = terrain::world_to_pixel(terrain, marker.x, marker.y, terrain_options.scale, terrain_options.pitch);
const auto radius = marker.radius * 3;
composed.fill_circle(px, py, radius, marker.color);
composed.draw_circle(px, py, radius + 1, render::black);
}
}
rendered_terrain = true;
std::printf("terrain %ux%u cells, textures %zu/%zu, water=%d\n", terrain.width, terrain.height, textures.resolved(), terrain.textures.size(),
terrain.has_water ? 1 : 0);
std::size_t blend_cells = 0;
for (const auto value: terrain.blends) {
if (value != 0U) ++blend_cells;
}
std::printf("blends: %zu cells, %zu descriptions\n", blend_cells, terrain.blend_descriptions.size());
} catch (const std::exception &error) {
std::printf("terrain render failed (%s); using the overview art\n", error.what());
}
}
if (!rendered_terrain) {
try {
const auto art_path = find_art(assets, picked->id);
if (!art_path) throw std::runtime_error("no overview art for " + picked->id);
const auto art = render::decode_tga(read_file(*art_path));
composed = render::compose(art, markers, scene);
world_width = scene.world_width;
world_height = scene.world_height;
} catch (const std::exception &error) {
std::printf("no overview art (%s)\n", error.what());
return 1;
}
}
float center_u = 0.5F;
float center_v = 0.5F;
float initial_zoom = perspective ? 1.0F : 6.0F;
if (!perspective && !starts.empty() && world_width > 0.0 && world_height > 0.0) {
center_u = static_cast<float>(static_cast<double>(starts[0].x) / world_width);
center_v = static_cast<float>(1.0 - static_cast<double>(starts[0].y) / world_height);
}
if (const auto zoom = option_value(args, "--zoom")) initial_zoom = std::stof(*zoom);
const auto out = option_value(args, "--out");
const bool no_window = has_flag(args, "--no-window");
bool shown = false;
if (!out && !no_window) {
render::view_camera camera;
camera.zoom = std::clamp(initial_zoom, camera.min_zoom, camera.max_zoom);
camera.center_x = center_u;
camera.center_y = center_v;
camera.clamp_center();
ra3::client::display_options viewer;
viewer.title = scene.title;
viewer.width = static_cast<int>(composed.width());
viewer.height = static_cast<int>(composed.height());
viewer.fullscreen = has_flag(args, "--fullscreen");
viewer.fps_limit = option_value(args, "--fps") ? std::stoi(*option_value(args, "--fps")) : 0;
shown = ra3::display::run_image(viewer, composed, camera, display_backend_from_args(args));
}
if (!shown) {
const auto path = out.value_or("openra3_view.bmp");
const auto bmp = render::encode_bmp(composed);
std::ofstream file(path, std::ios::binary);
if (!file) {
std::printf("cannot write %s\n", path.c_str());
return 1;
}
file.write(reinterpret_cast<const char *>(bmp.data()), static_cast<std::streamsize>(bmp.size()));
std::printf("wrote %s (%ux%u)\n", path.c_str(), composed.width(), composed.height());
}
std::printf("map=%s units P0=%d P1=%d winner=%d frames=%u\n", picked->id.c_str(), summary.units_left[0], summary.units_left[1], summary.winner,
summary.frames);
return 0;
}
// ---- interactive menu ----------------------------------------------------
/** Every render/skirmish option the menu can edit. */
struct menu_settings {
int mode = 0; ///< 0 = 3D terrain, 1 = top-down, 2 = skirmish.
int width = 1280;
int height = 720;
float cam_pitch = 58.0F; ///< degrees
float cam_yaw = 0.0F; ///< degrees
float cam_height = 420.0F;
float fov = 49.0F; ///< degrees
float zoom = 1.0F;
int terrain_scale = 4;
float terrain_pitch = 0.62F;
float world_size = 5120.0F;
unsigned seed = 1U;
unsigned frames = 27000U;
bool thumbnail = false;
bool fullscreen = false;
int fps_index = 0; ///< 0 = vsync, 1..3 = 30/60/120, 4 = uncapped.
int renderer = 0; ///< Preferred display backend (see renderer_backends).
std::string out;
};
/** Frame-rate presets: index -> cap (0 = vsync, -1 = uncapped). */
inline constexpr std::array<int, 5> fps_caps{0, 30, 60, 120, -1};
inline constexpr std::array<std::string_view, 5> fps_names{"vsync", "30", "60", "120", "uncapped"};
/**
* Preferred display backends offered by the menu. Each entry is tried first,
* then the others in turn, so an unavailable backend degrades gracefully.
* Direct3D is Windows-only; on other hosts those entries fall through.
*/
inline constexpr std::array<ra3::display::backend, 6> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
ra3::display::backend::d3d12, ra3::display::backend::webgpu,
ra3::display::backend::wasmgl, ra3::display::backend::sdl};
inline constexpr std::array<std::string_view, 6> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGPU", "WebGL (worker)",
"SDL (software)"};
inline constexpr int renderer_count = static_cast<int>(renderer_backends.size());
[[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend {
return renderer_backends[static_cast<std::size_t>(std::clamp(index, 0, static_cast<int>(renderer_backends.size()) - 1))];
}
inline constexpr int menu_width = 1280;
inline constexpr int menu_height = 720;
enum class menu_action { none, quit, play };
struct menu_state {
menu_settings settings;
int pane = 0; ///< 0 = map list, 1 = options.
int map_index = 0;
int map_scroll = 0;
int field = 0;
int text_cursor = 0;
int hover_map = -1; ///< map row under the cursor, or -1.
int hover_field = -1; ///< option row under the cursor, or -1.
bool dirty = true; ///< recompose needed (a visible value changed).
menu_action action = menu_action::none;
};
[[nodiscard]] auto fixed(double value, int decimals) -> std::string {
char buffer[64];
std::snprintf(buffer, sizeof(buffer), "%.*f", decimals, value);
return buffer;
}
[[nodiscard]] auto menu_field_count() -> int { return 20; }
[[nodiscard]] auto menu_field_label(int field) -> std::string_view {
switch (field) {
case 0: return "Mode";
case 1: return "Window width";
case 2: return "Window height";
case 3: return "3D camera pitch";
case 4: return "3D camera yaw";
case 5: return "3D camera height";
case 6: return "Field of view";
case 7: return "Top-down zoom";
case 8: return "Terrain scale";
case 9: return "Terrain pitch";
case 10: return "World size";
case 11: return "Skirmish seed";
case 12: return "Skirmish frames";
case 13: return "Overview thumbnail";
case 14: return "Save to BMP";
case 15: return "Fullscreen";
case 16: return "Frame rate";
case 17: return "Play";
case 18: return "Renderer";
default: return "Quit";
}
}
[[nodiscard]] auto menu_mode_name(int mode) -> std::string_view {
switch (mode) {
case 0: return "3D terrain";
case 1: return "Top-down";
default: return "Skirmish";
}
}
[[nodiscard]] auto menu_field_value(int field, const menu_settings &s) -> std::string {
switch (field) {
case 0: return std::string{menu_mode_name(s.mode)};
case 1: return std::to_string(s.width) + " px";
case 2: return std::to_string(s.height) + " px";
case 3: return fixed(static_cast<double>(s.cam_pitch), 0) + " deg";
case 4: return fixed(static_cast<double>(s.cam_yaw), 0) + " deg";
case 5: return fixed(static_cast<double>(s.cam_height), 0);
case 6: return fixed(static_cast<double>(s.fov), 0) + " deg";
case 7: return fixed(static_cast<double>(s.zoom), 2);
case 8: return std::to_string(s.terrain_scale);
case 9: return fixed(static_cast<double>(s.terrain_pitch), 2);
case 10: return fixed(static_cast<double>(s.world_size), 0);
case 11: return std::to_string(s.seed);
case 12: return std::to_string(s.frames);
case 13: return s.thumbnail ? "yes" : "no";
case 14: return s.out.empty() ? std::string{"(window)"} : s.out;
case 15: return s.fullscreen ? "yes" : "no";
case 16: return std::string{fps_names[static_cast<std::size_t>(std::clamp(s.fps_index, 0, 4))]} + " fps";
case 17: return "start";
case 18: return std::string{renderer_names[static_cast<std::size_t>(std::clamp(s.renderer, 0, renderer_count - 1))]};
default: return "exit";
}
}
auto menu_adjust(int field, menu_settings &s, int delta) -> void {
switch (field) {
case 0: s.mode = (s.mode + delta + 3) % 3; break;
case 1: s.width = std::clamp(s.width + delta * 16, 320, 3840); break;
case 2: s.height = std::clamp(s.height + delta * 16, 240, 2160); break;
case 3: s.cam_pitch = std::clamp(s.cam_pitch + static_cast<float>(delta), 10.0F, 85.0F); break;
case 4: s.cam_yaw = std::fmod(s.cam_yaw + static_cast<float>(delta) * 5.0F + 360.0F, 360.0F); break;
case 5: s.cam_height = std::clamp(s.cam_height + static_cast<float>(delta) * 25.0F, 50.0F, 4000.0F); break;
case 6: s.fov = std::clamp(s.fov + static_cast<float>(delta), 20.0F, 90.0F); break;
case 7: s.zoom = std::clamp(s.zoom + static_cast<float>(delta) * 0.5F, 1.0F, 24.0F); break;
case 8: s.terrain_scale = std::clamp(s.terrain_scale + delta, 1, 16); break;
case 9: s.terrain_pitch = std::clamp(s.terrain_pitch + static_cast<float>(delta) * 0.05F, 0.1F, 1.0F); break;
case 10: s.world_size = std::clamp(s.world_size + static_cast<float>(delta) * 512.0F, 512.0F, 20480.0F); break;
case 11: s.seed = static_cast<unsigned>(std::max<long long>(1, static_cast<long long>(s.seed) + delta)); break;
case 12: s.frames = static_cast<unsigned>(std::clamp<long long>(static_cast<long long>(s.frames) + static_cast<long long>(delta) * 900LL, 30LL, 216000LL)); break;
case 13: s.thumbnail = !s.thumbnail; break;
case 15: s.fullscreen = !s.fullscreen; break;
case 16: s.fps_index = (s.fps_index + delta + 5) % 5; break;
case 18: s.renderer = (s.renderer + delta + renderer_count) % renderer_count; break;
default: break;
}
}
/** Shared menu geometry, so drawing and hit-testing never diverge. */
struct menu_layout {
double k = 1.0;
int list_x = 0;
int list_y = 0;
int list_w = 0;
int list_h = 0;
int row_h = 1;
int rows = 1;
int set_x = 0;
int set_w = 0;
int field_row = 1;
[[nodiscard]] auto S(double v) const -> int { return static_cast<int>(std::lround(v * k)); }
[[nodiscard]] auto scale() const -> ra3::core::uint32 {
return static_cast<ra3::core::uint32>(std::max(1, static_cast<int>(std::lround(2.0 * k))));
}
};
[[nodiscard]] auto menu_layout_at(ra3::core::uint32 width, ra3::core::uint32 height) -> menu_layout {
menu_layout L;
L.k = static_cast<double>(height) / 720.0;
L.list_x = L.S(24);
L.list_y = L.S(78);
L.list_w = L.S(598);
L.list_h = L.S(568);
L.row_h = std::max(1, L.S(26));
L.rows = std::max(1, (L.list_h - L.S(46)) / L.row_h);
L.set_x = L.S(650);
L.set_w = static_cast<int>(width) - L.set_x - L.S(24);
L.field_row = std::max(1, L.S(26));
return L;
}
[[nodiscard]] auto compose_menu(const menu_state &st, const std::vector<asset_map> &maps, const ra3::map::map_name_table &names, ra3::core::uint32 width,
ra3::core::uint32 height) -> ra3::render::image {
using namespace ra3::render;
// Warm red + gold theme, no cold hues anywhere.
const auto background = argb(34, 9, 8);
const auto panel = argb(58, 14, 12);
const auto panel_edge = argb(120, 30, 22);
const auto highlight = argb(184, 132, 34); // gold bar
const auto gold = argb(236, 190, 80);
const auto label_color = argb(212, 154, 98);
const auto value_color = argb(246, 228, 192); // cream
const auto on_gold = argb(38, 12, 6); // dark text on the gold bar
const auto dim = argb(170, 120, 92);
image img(width, height, background);
const auto L = menu_layout_at(width, height);
const auto scale = L.scale();
draw_text(img, L.S(24), L.S(12), "OpenRA3", gold, scale + 1U);
draw_text(img, L.S(24) + static_cast<int>(text_width("OpenRA3", scale + 1U)) + L.S(18), L.S(26), "map browser / options", label_color, scale);
img.draw_rect(L.list_x, L.list_y, L.list_w, L.list_h, panel);
img.draw_rect(L.list_x, L.list_y, L.list_w, L.S(3), gold);
img.draw_rect(L.list_x, L.list_y, L.S(3), L.list_h, panel_edge);
draw_text(img, L.list_x + L.S(12), L.list_y + L.S(10), "Maps", gold, scale);
// Scroll only via the wheel / keyboard; hovering does not slide the list.
const int scroll = std::clamp(st.map_scroll, 0, std::max(0, static_cast<int>(maps.size()) - L.rows));
for (int r = 0; r < L.rows && scroll + r < static_cast<int>(maps.size()); ++r) {
const auto index = scroll + r;
const int y = L.list_y + L.S(44) + r * L.row_h;
const bool lit = st.hover_map == index || (st.hover_map < 0 && st.pane == 0 && index == st.map_index);
if (lit) img.draw_rect(L.list_x + L.S(6), y - L.S(3), L.list_w - L.S(12), L.row_h - L.S(2), highlight);
const auto name = names.lookup(maps[index].id);
draw_text(img, L.list_x + L.S(14), y, name, lit ? on_gold : value_color, scale);
draw_text(img, L.list_x + L.S(330), y + L.S(4), maps[index].id, lit ? on_gold : dim, 1U);
}
img.draw_rect(L.set_x, L.list_y, L.set_w, L.list_h, panel);
img.draw_rect(L.set_x, L.list_y, L.set_w, L.S(3), gold);
img.draw_rect(L.set_x, L.list_y, L.S(3), L.list_h, panel_edge);
draw_text(img, L.set_x + L.S(12), L.list_y + L.S(10), "Options", gold, scale);
for (int i = 0; i < menu_field_count(); ++i) {
const int y = L.list_y + L.S(46) + i * L.field_row;
const bool lit = st.hover_field == i || (st.hover_field < 0 && st.pane == 1 && i == st.field);
if (lit) img.draw_rect(L.set_x + L.S(6), y - L.S(4), L.set_w - L.S(12), L.field_row - L.S(4), highlight);
draw_text(img, L.set_x + L.S(14), y, menu_field_label(i), lit ? on_gold : label_color, scale);
const auto value = menu_field_value(i, st.settings);
draw_text(img, L.set_x + L.S(340), y, value, lit ? on_gold : value_color, scale);
if (i == 14 && lit) {
const auto cursor_x = L.set_x + L.S(340) + static_cast<int>(text_width(value.substr(0, static_cast<std::size_t>(st.text_cursor)), scale));
img.draw_rect(cursor_x, y - L.S(1), std::max(2, L.S(3)), L.S(18), on_gold);
}
}
const auto shown_index = st.hover_map >= 0 ? st.hover_map : st.map_index;
const auto selected_map = maps.empty() ? std::string{"(none)"} : names.lookup(maps[static_cast<std::size_t>(shown_index)].id);
draw_text(img, L.S(24), static_cast<int>(height) - L.S(66), "Selected: " + selected_map, gold, scale);
const auto hint = "Click a map to play Up/Down+Enter Wheel scrolls Tab options Esc quit";
draw_text(img, L.S(24), static_cast<int>(height) - L.S(32), hint, dim, 1U);
draw_text(img, static_cast<int>(width) - L.S(150), static_cast<int>(height) - L.S(32),
std::to_string(width) + "x" + std::to_string(height), dim, 1U);
return img;
}
auto menu_handle(menu_state &st, const ra3::render::ui_event &ev, const std::vector<asset_map> &maps, ra3::core::uint32 width, ra3::core::uint32 height)
-> void {
using ra3::render::ui_event_type;
using ra3::render::ui_key;
const auto L = menu_layout_at(width, height);
const auto move = [&](int delta) {
if (st.pane == 0) {
const auto count = static_cast<int>(maps.size());
st.map_index = std::clamp(st.map_index + delta, 0, std::max(0, count - 1));
if (st.map_index < st.map_scroll) st.map_scroll = st.map_index;
if (st.map_index >= st.map_scroll + L.rows) st.map_scroll = st.map_index - L.rows + 1;
st.map_scroll = std::clamp(st.map_scroll, 0, std::max(0, count - L.rows));
} else {
st.field = std::clamp(st.field + delta, 0, menu_field_count() - 1);
}
};
const auto hit_map = [&](int x, int y) -> int {
if (x < L.list_x || x >= L.list_x + L.list_w || y < L.list_y + L.S(44)) return -1;
const int scroll = std::clamp(st.map_scroll, 0, std::max(0, static_cast<int>(maps.size()) - L.rows));
const int r = (y - (L.list_y + L.S(44))) / L.row_h;
const int index = scroll + r;
return (r >= 0 && index >= 0 && index < static_cast<int>(maps.size())) ? index : -1;
};
const auto hit_field = [&](int x, int y) -> int {
if (x < L.set_x || x >= L.set_x + L.set_w || y < L.list_y + L.S(46)) return -1;
const int i = (y - (L.list_y + L.S(46))) / L.field_row;
return (i >= 0 && i < menu_field_count()) ? i : -1;
};
if (ev.type == ui_event_type::wheel) {
const auto count = static_cast<int>(maps.size());
const int step = std::max(1, static_cast<int>(std::lround(3.0 * std::abs(ev.wheel))));
const int direction = ev.wheel > 0.0F ? -1 : 1; // wheel up scrolls toward the first map
const int before = st.map_scroll;
st.map_scroll = std::clamp(st.map_scroll + direction * step, 0, std::max(0, count - L.rows));
if (st.map_scroll != before) st.dirty = true;
return;
}
if (ev.type == ui_event_type::mouse_move) {
const int hover_map = hit_map(static_cast<int>(ev.x), static_cast<int>(ev.y));
const int hover_field = hover_map >= 0 ? -1 : hit_field(static_cast<int>(ev.x), static_cast<int>(ev.y));
if (hover_map != st.hover_map || hover_field != st.hover_field) {
st.hover_map = hover_map;
st.hover_field = hover_field;
st.dirty = true; // repaint the gold highlight under the cursor
}
return;
}
if (ev.type == ui_event_type::mouse_button && ev.left) {
const int x = static_cast<int>(ev.x);
const int y = static_cast<int>(ev.y);
const int map = hit_map(x, y);
const int field = hit_field(x, y);
if (map >= 0) {
st.pane = 0;
st.map_index = map;
st.action = menu_action::play;
return;
}
if (field >= 0) {
st.pane = 1;
st.field = field;
if (field == 17) {
st.action = menu_action::play;
} else if (field == 19) {
st.action = menu_action::quit;
} else if (field == 0 || field == 13 || field == 15 || field == 16 || field == 18) {
menu_adjust(field, st.settings, 1);
st.dirty = true;
} else if (field == 14) {
st.text_cursor = static_cast<int>(st.settings.out.size());
}
return;
}
return;
}
if (ev.type == ui_event_type::text) {
if (st.pane == 1 && st.field == 14 && ev.character >= 32 && ev.character < 127) {
st.settings.out.insert(static_cast<std::size_t>(st.text_cursor), 1U, ev.character);
++st.text_cursor;
st.dirty = true;
}
return;
}
if (ev.type != ui_event_type::key) return;
switch (ev.key) {
case ui_key::cancel: st.action = menu_action::quit; return;
case ui_key::tab: st.pane ^= 1; st.dirty = true; return;
case ui_key::up: move(-1); st.dirty = true; return;
case ui_key::down: move(1); st.dirty = true; return;
case ui_key::page_up: move(-10); st.dirty = true; return;
case ui_key::page_down: move(10); st.dirty = true; return;
case ui_key::left:
if (st.pane == 1) {
if (st.field == 14) {
st.text_cursor = std::max(0, st.text_cursor - 1);
} else {
menu_adjust(st.field, st.settings, -1);
}
st.dirty = true;
}
return;
case ui_key::right:
if (st.pane == 1) {
if (st.field == 14) {
st.text_cursor = std::min(static_cast<int>(st.settings.out.size()), st.text_cursor + 1);
} else {
menu_adjust(st.field, st.settings, 1);
}
st.dirty = true;
}
return;
case ui_key::backspace:
if (st.pane == 1 && st.field == 14 && st.text_cursor > 0) {
st.settings.out.erase(static_cast<std::size_t>(st.text_cursor - 1), 1U);
--st.text_cursor;
st.dirty = true;
}
return;
case ui_key::confirm:
if (st.pane == 0 || st.field == 17) {
st.action = menu_action::play;
} else if (st.field == 19) {
st.action = menu_action::quit;
} else if (st.field == 0 || st.field == 13 || st.field == 15 || st.field == 16 || st.field == 18) {
menu_adjust(st.field, st.settings, 1);
st.dirty = true;
}
return;
default: return;
}
}
[[nodiscard]] auto menu_settings_args(const menu_settings &s, const std::string &map_id) -> std::vector<std::string> {
std::vector<std::string> args = {"--map", map_id};
if (s.mode == 2) {
args.insert(args.end(), {"--seed", std::to_string(s.seed), "--frames", std::to_string(s.frames)});
return args;
}
// A map view needs no simulation; skipping it avoids the multi-second
// stall before the window opens.
args.push_back("--no-sim");
if (s.mode == 0) {
args.insert(args.end(), {"--3d", "--width", std::to_string(s.width), "--height", std::to_string(s.height)});
args.insert(args.end(), {"--cam-pitch", fixed(static_cast<double>(s.cam_pitch), 2)});
args.insert(args.end(), {"--cam-yaw", fixed(static_cast<double>(s.cam_yaw), 2)});
args.insert(args.end(), {"--cam-height", fixed(static_cast<double>(s.cam_height), 0)});
args.insert(args.end(), {"--fov", fixed(static_cast<double>(s.fov), 2)});
} else {
args.insert(args.end(), {"--terrain-scale", std::to_string(s.terrain_scale)});
args.insert(args.end(), {"--pitch", fixed(static_cast<double>(s.terrain_pitch), 2)});
args.insert(args.end(), {"--zoom", fixed(static_cast<double>(s.zoom), 2)});
args.insert(args.end(), {"--world-size", fixed(static_cast<double>(s.world_size), 0)});
}
if (s.thumbnail) args.push_back("--thumbnail");
if (!s.out.empty()) {
args.push_back("--out");
args.push_back(s.out);
}
return args;
}
auto launch_menu_settings(const menu_settings &s, const std::string &map_id, const std::filesystem::path &assets) -> int {
if (s.mode == 2) return command_skirmish(menu_settings_args(s, map_id), assets);
return command_render(menu_settings_args(s, map_id), assets);
}
/** Console picker used when no window backend is available (headless). */
auto console_menu(const std::filesystem::path &assets, const std::vector<asset_map> &maps, const ra3::map::map_name_table &names,
const menu_settings &settings) -> int {
std::puts("");
std::puts(" OpenRA3 - select a map");
for (std::size_t i = 0; i < maps.size(); ++i) {
std::printf(" %3zu) %-36s %s\n", i + 1U, names.lookup(maps[i].id).c_str(), maps[i].id.c_str());
}
std::printf("\n Select 1-%zu (or a map id), Enter/q to quit: ", maps.size());
std::string line;
if (!std::getline(std::cin, line)) return 0;
const auto first = line.find_first_not_of(" \t\r\n");
if (first == std::string::npos) return 0;
line = line.substr(first, line.find_last_not_of(" \t\r\n") - first + 1U);
if (line == "q" || line == "Q" || line == "quit") return 0;
const asset_map *picked = nullptr;
try {
const auto index = std::stoul(line);
if (index >= 1U && index <= maps.size()) picked = &maps[index - 1U];
} catch (const std::exception &) {
}
if (picked == nullptr) {
auto lower = line;
std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
const auto it = std::find_if(maps.begin(), maps.end(), [&](const asset_map &m) {
auto id = m.id;
std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return id == lower;
});
if (it != maps.end()) picked = &*it;
}
if (picked == nullptr) {
std::printf("no map '%s'\n", line.c_str());
return 2;
}
std::printf("loading %s (%s) ...\n", names.lookup(picked->id).c_str(), picked->id.c_str());
return launch_menu_settings(settings, picked->id, assets);
}
/** Everything the viewer needs, loaded once (with progress) before opening it. */
struct prepared_view {
bool ok = false;
ra3::terrain::map_data map;
ra3::terrain::texture_set textures;
ra3::terrain::gpu_terrain gpu;
ra3::render::image composed; ///< top-down image (mode 1, or no GPU terrain)
ra3::render::image minimap; ///< whole-map overview for the corner minimap
ra3::render::camera3d camera3d;
ra3::render::view_camera view_camera;
};
auto prepare_map_view(const menu_settings &s, const std::filesystem::path &map_file, const std::filesystem::path &assets,
const std::function<bool(float, std::string_view)> &report) -> prepared_view {
using namespace ra3;
prepared_view view;
const auto bytes = map::to_ckmp(read_file(map_file));
if (!report(0.05F, "Parsing map...")) return view;
view.map = terrain::parse_map(bytes);
auto starts = map::starts_from_ckmp(bytes);
if (starts.size() < 2U) starts = skirmish::builtin_start_positions();
view.textures = terrain::load_textures_from_dir(view.map, assets / "terrain",
[&](float t) { report(0.12F + 0.55F * t, "Loading textures..."); });
const bool want_gpu = s.mode == 0 && s.out.empty() && s.thumbnail == false;
if (want_gpu) {
view.gpu = terrain::build_gpu_terrain(view.map, view.textures, {}, [&](float t) { report(0.70F + 0.18F * t, "Building terrain..."); });
if (!starts.empty()) {
view.camera3d.target_x = starts[0].x;
view.camera3d.target_y = starts[0].y;
}
const auto radians = [](float degrees) { return degrees * 3.14159265358979323846F / 180.0F; };
view.camera3d.pitch = radians(s.cam_pitch);
view.camera3d.yaw = radians(s.cam_yaw);
view.camera3d.height = s.cam_height;
view.camera3d.fov = radians(s.fov);
}
report(0.90F, "Building overview...");
terrain::render_options mini;
mini.scale = 1U;
mini.pitch = 1.0F;
view.minimap = render::downscale(terrain::render(view.map, view.textures, mini), 220U);
if (!want_gpu || s.thumbnail) {
report(0.95F, "Rendering map...");
const auto art = s.thumbnail ? find_art(assets, map_file.stem().string()) : std::optional<std::filesystem::path>{};
if (art) {
view.composed = render::downscale(render::decode_tga(read_file(*art)), 2048U);
view.view_camera.zoom = s.zoom;
} else {
terrain::render_options opts;
opts.scale = static_cast<core::uint32>(std::max(1, s.terrain_scale));
opts.pitch = s.terrain_pitch;
view.composed = terrain::render(view.map, view.textures, opts);
view.view_camera.zoom = s.zoom;
}
if (!starts.empty() && view.map.width > 0U) {
view.view_camera.center_x = static_cast<float>(static_cast<double>(starts[0].x) / static_cast<double>(view.map.world_width()));
view.view_camera.center_y = static_cast<float>(1.0 - static_cast<double>(starts[0].y) / static_cast<double>(view.map.world_height()));
}
view.view_camera.clamp_center();
}
report(1.0F, "Ready");
view.ok = true;
return view;
}
auto command_menu(const std::vector<std::string> &args, const std::filesystem::path &assets) -> int {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
if (maps.empty()) {
std::puts("no maps found");
return 1;
}
const auto names = ra3::map::load_map_names(assets);
menu_state st;
// Seed the menu from any command-line flags so they are all visible/editable.
if (has_flag(args, "--thumbnail")) st.settings.thumbnail = true;
if (has_flag(args, "--fullscreen")) st.settings.fullscreen = true;
if (has_flag(args, "--3d")) st.settings.mode = 0;
if (has_flag(args, "--vulkan")) st.settings.renderer = 0;
if (has_flag(args, "--dx11") || has_flag(args, "--d3d11")) st.settings.renderer = 1;
if (has_flag(args, "--dx12") || has_flag(args, "--d3d12")) st.settings.renderer = 2;
if (has_flag(args, "--webgpu")) st.settings.renderer = 3;
if (has_flag(args, "--wasmgl")) st.settings.renderer = 4;
if (has_flag(args, "--sdl")) st.settings.renderer = 5;
if (const auto value = option_value(args, "--width")) st.settings.width = std::stoi(*value);
if (const auto value = option_value(args, "--height")) st.settings.height = std::stoi(*value);
if (const auto value = option_value(args, "--cam-pitch")) st.settings.cam_pitch = std::stof(*value);
if (const auto value = option_value(args, "--cam-yaw")) st.settings.cam_yaw = std::stof(*value);
if (const auto value = option_value(args, "--cam-height")) st.settings.cam_height = std::stof(*value);
if (const auto value = option_value(args, "--fov")) st.settings.fov = std::stof(*value);
if (const auto value = option_value(args, "--zoom")) st.settings.zoom = std::stof(*value);
if (const auto value = option_value(args, "--pitch")) st.settings.terrain_pitch = std::stof(*value);
if (const auto value = option_value(args, "--terrain-scale")) st.settings.terrain_scale = std::stoi(*value);
if (const auto value = option_value(args, "--world-size")) st.settings.world_size = std::stof(*value);
if (const auto value = option_value(args, "--seed")) st.settings.seed = static_cast<unsigned>(std::stoul(*value));
if (const auto value = option_value(args, "--frames")) st.settings.frames = static_cast<unsigned>(std::stoul(*value));
if (const auto value = option_value(args, "--out")) st.settings.out = *value;
if (const auto requested = option_value(args, "--map")) {
for (std::size_t i = 0; i < maps.size(); ++i) {
if (maps[i].id == *requested) st.map_index = static_cast<int>(i);
}
}
bool windowed = false;
for (;;) {
ra3::client::display_options options;
options.title = "OpenRA3";
options.width = menu_width;
options.height = menu_height;
options.fullscreen = st.settings.fullscreen;
options.fps_limit = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))];
int applied_renderer = st.settings.renderer;
auto display = ra3::display::create(options, renderer_backend(applied_renderer));
if (display) ender::log::info(std::format("menu display backend: {}", display->name()));
if (!display) {
if (!windowed) return console_menu(assets, maps, names, st.settings);
return 0;
}
windowed = true;
st.action = menu_action::none;
st.dirty = true;
st.hover_map = -1;
st.hover_field = -1;
const auto frame = [&](const ra3::render::ui_event &ev, ra3::core::uint32 w, ra3::core::uint32 h,
bool &done) -> std::optional<ra3::render::image> {
menu_handle(st, ev, maps, w, h);
if (st.action != menu_action::none) {
done = true;
return std::nullopt;
}
if (!st.dirty) return std::nullopt;
st.dirty = false;
return compose_menu(st, maps, names, w, h);
};
(void)display->menu_loop(frame);
if (st.action == menu_action::quit) {
display->shutdown();
return 0;
}
// Apply renderer / fullscreen / frame-rate changes (recreate the window).
const int want_fps = fps_caps[static_cast<std::size_t>(std::clamp(st.settings.fps_index, 0, 4))];
if (st.settings.renderer != applied_renderer) {
display->shutdown();
applied_renderer = st.settings.renderer;
options.fullscreen = st.settings.fullscreen;
options.fps_limit = want_fps;
display = ra3::display::create(options, renderer_backend(applied_renderer));
if (!display) return console_menu(assets, maps, names, st.settings);
} else if (st.settings.fullscreen != options.fullscreen || want_fps != options.fps_limit) {
display->shutdown();
options.fullscreen = st.settings.fullscreen;
options.fps_limit = want_fps;
if (!display->init(options)) {
return console_menu(assets, maps, names, st.settings);
}
}
const auto &picked = maps[static_cast<std::size_t>(st.map_index)];
std::printf("loading %s (%s) ...\n", names.lookup(picked.id).c_str(), picked.id.c_str());
prepared_view view;
const bool loaded = display->run_with_progress([&](const auto &report) {
view = prepare_map_view(st.settings, picked.file, assets, report);
return view.ok;
});
if (!loaded || !view.ok) {
display->shutdown();
return 0;
}
if (st.settings.mode == 2) {
display->shutdown();
command_skirmish(menu_settings_args(st.settings, picked.id), assets);
continue;
}
if (st.settings.mode == 0 && st.settings.out.empty() && display->supports_terrain()) {
(void)display->terrain_loop(view.gpu, view.camera3d, view.minimap);
} else if (!view.composed.empty()) {
(void)display->image_loop(view.composed, view.view_camera);
}
display->shutdown();
}
}
/** 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 {
if (!ensure_assets(assets)) return 1;
const auto maps = list_asset_maps(assets);
const auto names = ra3::map::load_map_names(assets);
menu_state st;
if (const auto requested = option_value(args, "--map")) {
for (std::size_t i = 0; i < maps.size(); ++i) {
if (maps[i].id == *requested) st.map_index = static_cast<int>(i);
}
}
if (const auto value = option_value(args, "--pane")) st.pane = std::stoi(*value);
if (const auto value = option_value(args, "--field")) st.field = std::stoi(*value);
const auto composed = compose_menu(st, maps, names, static_cast<ra3::core::uint32>(menu_width), static_cast<ra3::core::uint32>(menu_height));
const auto path = option_value(args, "--out").value_or("openra3_menu.bmp");
const auto bmp = ra3::render::encode_bmp(composed);
std::ofstream file(path, std::ios::binary);
if (!file) {
std::printf("cannot write %s\n", path.c_str());
return 1;
}
file.write(reinterpret_cast<const char *>(bmp.data()), static_cast<std::streamsize>(bmp.size()));
std::printf("wrote %s (%ux%u)\n", path.c_str(), composed.width(), composed.height());
return 0;
}
}
auto main(int argc, char **argv) -> int {
#if defined(_WIN32)
install_crash_handler();
#endif
const std::vector<std::string> args{argv + 1, argv + argc};
const auto exe_dir = executable_dir(argc > 0 ? argv[0] : ".");
const auto assets = exe_dir / "assets";
setup_logging(exe_dir);
#if defined(__EMSCRIPTEN__)
// 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
// lazily from a manifest instead of being preloaded: the browser fetches
// only the files the engine actually opens. On wasm the executable dir is
// "/", so the manifest sits at /assets.manifest.json and the lazy file URLs
// are "assets/<rel>" relative to the page.
const int mounted = ra3::wasmgl::mount_assets((exe_dir / "assets.manifest.json").string(), assets.string(), "assets/");
ender::log::info(std::format("wasm assets: {} files mounted lazily under {}", mounted, assets.string()));
#endif
if (args.empty()) return command_menu({}, assets);
const auto command = args[0];
const std::vector<std::string> rest{args.begin() + 1, args.end()};
if (command == "help" || command == "--help" || command == "-h") {
print_usage();
return 0;
}
if (command == "extract") {
const auto game_dir = option_value(rest, "--game-dir");
const auto out = option_value(rest, "--out");
if (!game_dir || !out) {
std::puts("usage: openra3 extract --game-dir DIR --out DIR");
return 2;
}
return extract_assets(*game_dir, *out);
}
if (command == "menu") return command_menu(rest, assets);
if (command == "menu-preview") return command_menu_preview(rest, assets);
if (command == "maps") return command_maps(assets);
if (command == "textures") return command_textures(rest, assets);
if (command == "skirmish") return command_skirmish(rest, assets);
if (command == "render") return command_render(rest, assets);
print_usage();
return 2;
}