#if defined(_WIN32) #define NOMINMAX #define WIN32_LEAN_AND_MEAN #include #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(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(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(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(module); std::snprintf(out, size, "%s+0x%llX", base != nullptr ? base + 1 : narrow, static_cast(rva)); return; } std::snprintf(out, size, "0x%llX", static_cast(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(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(frames[i]), described, sizeof(described)); std::snprintf(line, sizeof(line), " #%02u %s\n", static_cast(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(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 /assets (extracted at build time by the openra3_assets target)"); } auto option_value(const std::vector &args, std::string_view name) -> std::optional { 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 &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 &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 { std::ifstream in(path, std::ios::binary); if (!in) throw std::runtime_error("cannot open " + path.string()); return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; } /** One extracted map in `/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 { std::unordered_map 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 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 `_art.tga` anywhere under `root`. */ auto find_art(const std::filesystem::path &root, std::string_view id) -> std::optional { 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 `/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(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 `\.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(loaded.bytes.data()), static_cast(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(art.data()), static_cast(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(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(payload.data()), static_cast(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 { 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 &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 = ↦ } 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(size / 1024U)); } return 0; } auto command_skirmish(const std::vector &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(std::stoul(*seed)); if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast(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(starts[i].x), static_cast(starts[i].y)); std::printf("result: %s winner=%d frames=%u (%.1f s)\n", summary.decided ? "decided" : "timeout", summary.winner, summary.frames, static_cast(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 &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(std::stoul(*seed)); if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast(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 markers; for (const auto &player: match.players()) markers.push_back({static_cast(player.start.x), static_cast(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(entry.position.x), static_cast(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(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(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(std::stoul(*option_value(args, "--width"))) : 1280U; const auto height = option_value(args, "--height") ? static_cast(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(width); options.height = static_cast(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(terrain.world_width()); world_height = static_cast(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(terrain.world_width()); world_height = static_cast(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(static_cast(starts[0].x) / world_width); center_v = static_cast(1.0 - static_cast(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(composed.width()); viewer.height = static_cast(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(bmp.data()), static_cast(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 fps_caps{0, 30, 60, 120, -1}; inline constexpr std::array 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 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 renderer_names{"Vulkan", "Direct3D 11", "Direct3D 12", "WebGPU", "WebGL (worker)", "SDL (software)"}; inline constexpr int renderer_count = static_cast(renderer_backends.size()); [[nodiscard]] inline auto renderer_backend(int index) -> ra3::display::backend { return renderer_backends[static_cast(std::clamp(index, 0, static_cast(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(s.cam_pitch), 0) + " deg"; case 4: return fixed(static_cast(s.cam_yaw), 0) + " deg"; case 5: return fixed(static_cast(s.cam_height), 0); case 6: return fixed(static_cast(s.fov), 0) + " deg"; case 7: return fixed(static_cast(s.zoom), 2); case 8: return std::to_string(s.terrain_scale); case 9: return fixed(static_cast(s.terrain_pitch), 2); case 10: return fixed(static_cast(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::clamp(s.fps_index, 0, 4))]} + " fps"; case 17: return "start"; case 18: return std::string{renderer_names[static_cast(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(delta), 10.0F, 85.0F); break; case 4: s.cam_yaw = std::fmod(s.cam_yaw + static_cast(delta) * 5.0F + 360.0F, 360.0F); break; case 5: s.cam_height = std::clamp(s.cam_height + static_cast(delta) * 25.0F, 50.0F, 4000.0F); break; case 6: s.fov = std::clamp(s.fov + static_cast(delta), 20.0F, 90.0F); break; case 7: s.zoom = std::clamp(s.zoom + static_cast(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(delta) * 0.05F, 0.1F, 1.0F); break; case 10: s.world_size = std::clamp(s.world_size + static_cast(delta) * 512.0F, 512.0F, 20480.0F); break; case 11: s.seed = static_cast(std::max(1, static_cast(s.seed) + delta)); break; case 12: s.frames = static_cast(std::clamp(static_cast(s.frames) + static_cast(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(std::lround(v * k)); } [[nodiscard]] auto scale() const -> ra3::core::uint32 { return static_cast(std::max(1, static_cast(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(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(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 &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(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(maps.size()) - L.rows)); for (int r = 0; r < L.rows && scroll + r < static_cast(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(text_width(value.substr(0, static_cast(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(shown_index)].id); draw_text(img, L.S(24), static_cast(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(height) - L.S(32), hint, dim, 1U); draw_text(img, static_cast(width) - L.S(150), static_cast(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 &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(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(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(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(maps.size()); const int step = std::max(1, static_cast(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(ev.x), static_cast(ev.y)); const int hover_field = hover_map >= 0 ? -1 : hit_field(static_cast(ev.x), static_cast(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(ev.x); const int y = static_cast(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(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(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(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(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::vector 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(s.cam_pitch), 2)}); args.insert(args.end(), {"--cam-yaw", fixed(static_cast(s.cam_yaw), 2)}); args.insert(args.end(), {"--cam-height", fixed(static_cast(s.cam_height), 0)}); args.insert(args.end(), {"--fov", fixed(static_cast(s.fov), 2)}); } else { args.insert(args.end(), {"--terrain-scale", std::to_string(s.terrain_scale)}); args.insert(args.end(), {"--pitch", fixed(static_cast(s.terrain_pitch), 2)}); args.insert(args.end(), {"--zoom", fixed(static_cast(s.zoom), 2)}); args.insert(args.end(), {"--world-size", fixed(static_cast(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 &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(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(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 &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{}; 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(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(static_cast(starts[0].x) / static_cast(view.map.world_width())); view.view_camera.center_y = static_cast(1.0 - static_cast(starts[0].y) / static_cast(view.map.world_height())); } view.view_camera.clamp_center(); } report(1.0F, "Ready"); view.ok = true; return view; } auto command_menu(const std::vector &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(std::stoul(*value)); if (const auto value = option_value(args, "--frames")) st.settings.frames = static_cast(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(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::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 { 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::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(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 &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(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(menu_width), static_cast(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(bmp.data()), static_cast(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 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/" 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 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; }