- ra3.dx: D3D11 and D3D12 presentation backends (runtime HLSL via d3dcompiler); 2D image blit and the GPU heightfield terrain raymarch, with the corner minimap/FPS overlays. Non-Windows builds link a null fallback. - ra3.display: preferred backend plus ordered fallback (Vulkan/D3D11/D3D12/SDL); menu gains a Renderer option and the CLI gains --dx11/--dx12/--sdl, which the render command now honours too. - vendor libenderlog (MIT): every run writes openra3.log next to the exe and archives the previous run's log; records at warn and above carry a call stack (native fallback, since libc++ has no <stacktrace>). - Windows crash reporter writes openra3_crash.log (faulting module + backtrace); D3D/DXGI diagnostics are routed through the logger.
1252 lines
64 KiB
C++
1252 lines
64 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});
|
|
log::add_file_sink(exe_dir / "openra3.log", {.max_file_size = 4U * 1024U * 1024U, .max_archives = 10U});
|
|
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 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] [--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, "--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);
|
|
return ec ? std::filesystem::current_path() : path.parent_path();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
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;
|
|
if (!offscreen) {
|
|
const auto gpu = terrain::build_gpu_terrain(terrain, textures, terrain_options);
|
|
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);
|
|
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, 4> renderer_backends{ra3::display::backend::vulkan, ra3::display::backend::d3d11,
|
|
ra3::display::backend::d3d12, ra3::display::backend::sdl};
|
|
inline constexpr std::array<std::string_view, 4> renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "SDL (software)"};
|
|
|
|
[[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, 3))]};
|
|
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 + 4) % 4; 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, "--sdl")) st.settings.renderer = 3;
|
|
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 (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 == "skirmish") return command_skirmish(rest, assets);
|
|
if (command == "render") return command_render(rest, assets);
|
|
|
|
print_usage();
|
|
return 2;
|
|
}
|