From b35372da5238bfd826c44862eab9e4825679984d Mon Sep 17 00:00:00 2001 From: EnderTheCoder Date: Sat, 12 Sep 2026 01:02:49 +0800 Subject: [PATCH] v0.1.0: software map renderer + SDL3 window viewer --- CMakeLists.txt | 34 +++- Dockerfile | 16 +- README.md | 32 +++- VERSION | 2 +- apps/openra3/main.cpp | 90 +++++++++++ docs/ARCHITECTURE.md | 47 ++++-- docs/ROADMAP.md | 14 +- src/core/ra3.core.cppm | 4 +- src/ra3.cppm | 2 + src/render/ra3.render.cppm | 311 +++++++++++++++++++++++++++++++++++++ src/ui/ra3.ui.null.cppm | 24 +++ src/ui/ra3.ui.sdl.cppm | 93 +++++++++++ tests/ra3_tests.cpp | 21 +++ 13 files changed, 659 insertions(+), 31 deletions(-) create mode 100644 src/render/ra3.render.cppm create mode 100644 src/ui/ra3.ui.null.cppm create mode 100644 src/ui/ra3.ui.sdl.cppm diff --git a/CMakeLists.txt b/CMakeLists.txt index 68e2316..bcda2d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -83,13 +83,45 @@ target_sources(ra3_skirmish target_link_libraries(ra3_skirmish PUBLIC ra3_core ra3_logic ra3_game ra3_map) openra3_target_defaults(ra3_skirmish) +# --- software renderer: framebuffer, TGA decode, BMP encode, map compositing --- +add_library(ra3_render STATIC) +target_sources(ra3_render + PUBLIC FILE_SET CXX_MODULES FILES + src/render/ra3.render.cppm +) +target_link_libraries(ra3_render PUBLIC ra3_core) +openra3_target_defaults(ra3_render) + +# --- windowed viewer: SDL3 when available, otherwise a null backend --- +find_package(PkgConfig QUIET) +if(PkgConfig_FOUND) + pkg_check_modules(SDL3 QUIET IMPORTED_TARGET sdl3) +endif() +add_library(ra3_ui STATIC) +if(SDL3_FOUND) + target_sources(ra3_ui + PUBLIC FILE_SET CXX_MODULES FILES + src/ui/ra3.ui.sdl.cppm + ) + target_link_libraries(ra3_ui PUBLIC ra3_core ra3_render PkgConfig::SDL3) + message(STATUS "OpenRA3: SDL3 ${SDL3_VERSION} found - windowed viewer enabled") +else() + target_sources(ra3_ui + PUBLIC FILE_SET CXX_MODULES FILES + src/ui/ra3.ui.null.cppm + ) + target_link_libraries(ra3_ui PUBLIC ra3_core ra3_render) + message(STATUS "OpenRA3: SDL3 not found - offscreen BMP output only") +endif() +openra3_target_defaults(ra3_ui) + # --- umbrella module re-exporting the whole SDK --- add_library(ra3 STATIC) target_sources(ra3 PUBLIC FILE_SET CXX_MODULES FILES src/ra3.cppm ) -target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_game ra3_fs ra3_map ra3_skirmish) +target_link_libraries(ra3 PUBLIC ra3_core ra3_logic ra3_client ra3_game ra3_fs ra3_map ra3_skirmish ra3_render ra3_ui) openra3_target_defaults(ra3) # --- the headless game executable --- diff --git a/Dockerfile b/Dockerfile index b0535b9..3d32a71 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,16 +17,22 @@ RUN if [ -n "$APT_MIRROR" ]; then \ RUN --mount=type=cache,target=/var/lib/apt,sharing=locked \ --mount=type=cache,target=/var/cache/apt,sharing=locked \ - apt-get update && apt-get install -y --no-install-recommends \ + for attempt in 1 2 3 4 5; do \ + apt-get -o Acquire::Retries=5 update && break; \ + echo "apt-get update failed (attempt $attempt), retrying"; \ + sleep 5; \ + done \ + && apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ build-essential \ ca-certificates \ cmake \ g++-16 \ gdb \ git \ + libsdl3-dev \ ninja-build \ pkg-config \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* # Pin the toolchain to GCC 16 (the distro default is still GCC 15). ENV CC=gcc-16 @@ -48,9 +54,11 @@ FROM ubuntu:26.04 AS deploy ENV DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ +RUN apt-get -o Acquire::Retries=5 update \ + && apt-get -o Acquire::Retries=5 install -y --no-install-recommends \ + libsdl3-0 \ libstdc++6 \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=dev /work/build/bin/openra3 /app/openra3 diff --git a/README.md b/README.md index b78abba..84c54b2 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ Ghidra. OpenRA3 is at **v0.0.1**. The engine compiles and runs headless, and a **minimal skirmish** is playable: it reads a real multiplayer map out of your install, recovers the player start waypoints, and simulates two sides earning credits, -training units and fighting until one base falls. There is no renderer yet, so -the match is observed as a deterministic headless result. +training units and fighting until one base falls. The map and match state can be +**rendered** to a window (SDL3) or to an image. ```text $ openra3 skirmish --game-dir "/game" --map map_mp_2_feasel4 @@ -66,6 +66,8 @@ built-in test map so the project still builds and runs in CI. | `src/fs/ra3.fs.cppm` | `BIG4` archives, RefPack codec, local install locator | | `src/map/ra3.map.cppm` | map catalog, `EAR`/RefPack unwrap, start waypoints | | `src/skirmish/ra3.skirmish.cppm` | units, economy, AI, combat, win condition | +| `src/render/ra3.render.cppm` | ARGB framebuffer, TGA decode, BMP encode, map compositing | +| `src/ui/ra3.ui.*.cppm` | SDL3 window viewer (null backend when SDL3 is absent) | | `src/ra3.cppm` | umbrella module re-exporting the SDK | | `apps/openra3/main.cpp` | `maps` / `skirmish` CLI | | `tests/ra3_tests.cpp` | smoke tests (run via `ctest`) | @@ -103,6 +105,32 @@ docker run --rm -v "/path/to/Red Alert 3:/game:ro" openra3-dev:local \ `--frames N` caps the simulation length (default 15 minutes of game time at 30 Hz). The result is deterministic for a given map and seed. +## Rendering the map + +The renderer draws the map's own overview art, a world grid and the match state +(start markers in yellow, player 0 in blue, player 1 in red). + +**Offscreen image** (works anywhere, no display needed): + +```bash +docker run --rm -v "/path/to/Red Alert 3:/game:ro" -v "$PWD/out:/out" openra3-dev:local \ + /work/build/bin/openra3 render --game-dir /game --map map_mp_2_feasel4 --out /out/map.bmp +``` + +**Interactive window** (SDL3; drag to pan, wheel to zoom, Esc to quit). In a +container you need an X server on the host — on Windows run +[VcXsrv](https://sourceforge.net/projects/vcxsrv/) and launch it with "Disable +access control", then: + +```bash +docker run --rm -v "/path/to/Red Alert 3:/game:ro" -e DISPLAY=host.docker.internal:0.0 \ + openra3-dev:local /work/build/bin/openra3 render --game-dir /game --map map_mp_2_feasel4 +``` + +If no display is available the viewer falls back to writing `openra3_view.bmp`. +The unit overlay uses an approximate world scale (`--world-size`, default +5120); exact calibration from the map's heightmap is on the roadmap. + ## Reverse engineering The reconstruction is driven by Ghidra against the retail binary. See diff --git a/VERSION b/VERSION index 8acdd82..6e8bf73 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1 +0.1.0 diff --git a/apps/openra3/main.cpp b/apps/openra3/main.cpp index da5459c..4d4417c 100644 --- a/apps/openra3/main.cpp +++ b/apps/openra3/main.cpp @@ -1,6 +1,8 @@ +#include #include #include #include +#include #include #include #include @@ -14,6 +16,7 @@ namespace { std::puts("usage:"); std::puts(" openra3 maps [--game-dir DIR]"); std::puts(" openra3 skirmish [--game-dir DIR] [--map ID] [--frames N] [--seed N]"); + std::puts(" openra3 render [--game-dir DIR] [--map ID] [--frames N] [--seed N] [--world-size S] [--out FILE.bmp]"); std::puts("env: RA3_GAME_DIR (default C:\\Red Alert 3); game data is never copied into the repo"); } @@ -98,6 +101,92 @@ namespace { } return 0; } + + /** Entry name of a map's overview art (`_art.tga`). */ + auto art_entry_for(const ra3::map::map_info &info) -> std::string { + auto entry = info.entry; + if (entry.size() > 4U) entry.resize(entry.size() - 4U); + entry += "_art.tga"; + return entry; + } + + auto command_render(const std::vector &args, const std::optional &game_dir) -> int { + const auto dir = ra3::fs::find_game_dir(game_dir); + if (!dir) { + std::puts("no Red Alert 3 install found; pass --game-dir or set RA3_GAME_DIR"); + return 1; + } + + const auto archive = ra3::fs::big_archive::open(data_path(*dir, "MapsMultiplayer.big")); + const auto catalog = ra3::map::map_catalog::from_archive(archive); + const auto requested = option_value(args, "--map"); + const ra3::map::map_info *info = nullptr; + if (requested) { + info = catalog.find(*requested); + } else if (catalog.size() > 0U) { + info = &catalog.maps().front(); + } + if (info == nullptr) { + std::printf("map '%s' not found\n", requested ? requested->c_str() : ""); + return 1; + } + + const auto tga = archive.read(art_entry_for(*info), true); + const auto art = ra3::render::decode_tga(tga); + + ra3::render::scene_options scene; + scene.title = "OpenRA3 - " + info->id; + if (const auto size = option_value(args, "--world-size")) scene.world_width = scene.world_height = std::stod(*size); + + ra3::skirmish::match_config config; + config.map_id = info->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 = ra3::map::load_map(archive, *info).starts; + if (starts.size() < 2U) starts = ra3::skirmish::builtin_start_positions(); + auto match = ra3::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), ra3::render::yellow, 6}); + } + for (const auto &entry: match.units()) { + if (!entry.alive) continue; + const auto color = entry.owner == 0U ? ra3::render::blue : ra3::render::red; + int radius = 2; + if (entry.cls == ra3::skirmish::unit_class::tank) radius = 4; + if (entry.cls == ra3::skirmish::unit_class::base) radius = 6; + markers.push_back({static_cast(entry.position.x), static_cast(entry.position.y), color, radius}); + } + + const auto composed = ra3::render::compose(art, markers, scene); + + const auto out = option_value(args, "--out"); + const bool no_window = std::find(args.begin(), args.end(), "--no-window") != args.end(); + bool shown = false; + if (!out && !no_window) { + ra3::ui::viewer_options viewer; + viewer.title = scene.title; + shown = ra3::ui::run_viewer(composed, viewer); + } + if (!shown) { + const auto path = out.value_or("openra3_view.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()); + } + + std::printf("map=%s units P0=%d P1=%d winner=%d frames=%u\n", info->id.c_str(), summary.units_left[0], summary.units_left[1], summary.winner, + summary.frames); + return 0; + } } auto main(int argc, char **argv) -> int { @@ -115,6 +204,7 @@ auto main(int argc, char **argv) -> int { if (command == "maps") return command_maps(game_dir); if (command == "skirmish") return command_skirmish(rest, game_dir); + if (command == "render") return command_render(rest, game_dir); if (command == "help" || command == "--help" || command == "-h") { print_usage(); return 0; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e3e149a..ff34d4b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,22 +4,26 @@ OpenRA3 is organised as a stack of C++ modules. Each layer may import the ones below it, never the ones above. ``` - ┌───────────────────────────────┐ - applications │ openra3 (apps/openra3) │ - └───────────────┬───────────────┘ - │ import ra3 - ┌───────────────▼───────────────┐ - umbrella │ ra3 (re-exports everything) │ - └───────────────┬───────────────┘ - ┌───────────────┬────────┴────────┬───────────────┐ - ▼ ▼ ▼ ▼ - ra3.skirmish ra3.client ra3.game ra3.map - match rules display/loop RA3 sides map catalog - │ │ │ │ - └───────┬───────┴────────┬────────┘ │ - ▼ ▼ ▼ - ra3.logic ra3.core ra3.fs - simulation types/math/random BIG4 + RefPack + ┌───────────────────────────────────────┐ + applications │ openra3 (apps/openra3) │ + └───────────────────┬───────────────────┘ + │ import ra3 + ┌───────────────────▼───────────────────┐ + umbrella │ ra3 (re-exports everything) │ + └───────────────────┬───────────────────┘ + ┌──────────────┬───────────────┼───────────────┬──────────────┐ + ▼ ▼ ▼ ▼ ▼ + ra3.ui ra3.render ra3.skirmish ra3.client ra3.game + SDL3 window framebuffer match rules display/loop RA3 sides + │ │ │ │ │ + └──────┬───────┘ └───────┬───────┘ │ + ▼ ▼ │ + ra3.core ra3.logic │ + simulation │ + ┌───────────────────────────────────────┬──────────────────────┘ + ▼ ▼ + ra3.map ra3.fs + map catalog BIG4 + RefPack ``` ## Module responsibilities @@ -64,6 +68,17 @@ The minimal match: two players, unit classes (harvester/infantry/tank/base), passive + harvester income, a simple build AI, movement and combat on a fixed 30 Hz step, and a base-destruction win condition. Fully deterministic. +### `ra3.render` +A dependency-free software renderer: an ARGB8888 `image` framebuffer with +blit/line/circle primitives, a TGA decoder for the game's map art, a 24-bit BMP +encoder for headless output, and `compose` which overlays a world grid and +markers (start positions, live units) onto the map art. + +### `ra3.ui` +The windowed viewer. The SDL3 backend streams the rendered image to a texture +with pan/zoom and Esc-to-quit; when the build has no SDL3, a null backend +returns `false` so the caller writes an offscreen image instead. + ## Design rules 1. **No raw owning pointers.** Ownership is `std::unique_ptr`; the partition diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f18edb1..302bab9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -11,12 +11,15 @@ that each one produces something that builds and runs. CMake/Ninja, Docker `dev`/`deploy`, GitLab CI. Reads `BIG4`/RefPack data from a local install, recovers map start waypoints, and runs a deterministic headless two-player skirmish to a decision. +- [x] **v0.1.0 — map renderer + window.** Software ARGB framebuffer, TGA + decoder for the map art, BMP output, map/grid/unit compositing, and an + SDL3 window viewer (pan/zoom). ## Next -- [ ] **v0.1.0 — complete map parsing.** Parse `MPPositionList` for maps that do - not use `Player_N_Start` waypoints; recover map dimensions, terrain - heightmap and placement grid; place real starting structures/units. +- [ ] **v0.1.1 — correct world calibration.** Parse the map heightmap + dimensions (and `MPPositionList` starts) so the unit overlay lines up with + the map art instead of using an assumed world size. - [ ] **v0.2.0 — data & file formats.** Parse compiled gameplay assets (`GameObject`, `WeaponTemplate`, `ArmorTemplate`, `LocomotorTemplate`) so units use the real balance numbers instead of OpenRA3's stand-ins. @@ -25,8 +28,9 @@ that each one produces something that builds and runs. tree, pathfinding and shroud. - [ ] **v0.4.0 — AI.** Skirmish AI: build states, team composition, attack waves (the reference tree's `AI*` modules). -- [ ] **v0.5.0 — renderer.** A W3D/D3D9 (or portable Vulkan/OpenGL) client - backend implementing `ra3::client::display`, plus input. +- [ ] **v0.5.0 — full renderer.** Terrain heightmap rendering, W3D models and a + D3D9/Vulkan/OpenGL client backend implementing `ra3::client::display`, + plus full input. - [ ] **v0.6.0 — content.** Load real maps, units, powers and strings; play a skirmish end-to-end with a UI. diff --git a/src/core/ra3.core.cppm b/src/core/ra3.core.cppm index abf0ff8..5345f81 100644 --- a/src/core/ra3.core.cppm +++ b/src/core/ra3.core.cppm @@ -24,8 +24,8 @@ export module ra3.core; */ export namespace ra3::core { inline constexpr int version_major = 0; - inline constexpr int version_minor = 0; - inline constexpr int version_patch = 1; + inline constexpr int version_minor = 1; + inline constexpr int version_patch = 0; using real = float; using int32 = std::int32_t; diff --git a/src/ra3.cppm b/src/ra3.cppm index 3540b42..8884017 100644 --- a/src/ra3.cppm +++ b/src/ra3.cppm @@ -13,3 +13,5 @@ export import ra3.game; export import ra3.fs; export import ra3.map; export import ra3.skirmish; +export import ra3.render; +export import ra3.ui; diff --git a/src/render/ra3.render.cppm b/src/render/ra3.render.cppm new file mode 100644 index 0000000..4e8cffa --- /dev/null +++ b/src/render/ra3.render.cppm @@ -0,0 +1,311 @@ +module; + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +export module ra3.render; + +export import ra3.core; + +/** + * A dependency-free software renderer: an ARGB framebuffer, a TGA decoder for + * the game's map art, a BMP encoder for headless output, and map compositing. + * + * The windowed viewer lives in `ra3.ui`; this module is pure computation so it + * builds and runs anywhere, including CI. + */ +export namespace ra3::render { + using ra3::core::uint8; + using ra3::core::uint32; + using ra3::core::usize; + + /** Thrown when an image payload is malformed. */ + class image_error : public std::runtime_error { + public: + using std::runtime_error::runtime_error; + }; + + /** Pack 8-bit channels into the engine's 0xAARRGGBB pixel format. */ + [[nodiscard]] constexpr auto argb(uint8 r, uint8 g, uint8 b, uint8 a = 255U) -> uint32 { + return (static_cast(a) << 24U) | (static_cast(r) << 16U) | (static_cast(g) << 8U) | static_cast(b); + } + + inline constexpr uint32 black = argb(0, 0, 0); + inline constexpr uint32 white = argb(255, 255, 255); + inline constexpr uint32 red = argb(220, 40, 40); + inline constexpr uint32 green = argb(60, 200, 80); + inline constexpr uint32 blue = argb(70, 120, 230); + inline constexpr uint32 yellow = argb(230, 210, 60); + + /** + * A top-left-origin ARGB8888 image. + */ + class image { + public: + image() = default; + + image(uint32 width, uint32 height, uint32 fill = black) : width_(width), height_(height), pixels_(static_cast(width) * height, fill) {} + + [[nodiscard]] auto width() const -> uint32 { return width_; } + [[nodiscard]] auto height() const -> uint32 { return height_; } + [[nodiscard]] auto empty() const -> bool { return pixels_.empty(); } + [[nodiscard]] auto data() const -> const uint32 * { return pixels_.data(); } + [[nodiscard]] auto data() -> uint32 * { return pixels_.data(); } + + auto set(int x, int y, uint32 color) -> void { + if (x < 0 || y < 0 || x >= static_cast(width_) || y >= static_cast(height_)) return; + pixels_[static_cast(y) * width_ + static_cast(x)] = color; + } + + auto fill(uint32 color) -> void { std::fill(pixels_.begin(), pixels_.end(), color); } + + auto blend(int x, int y, uint32 color, uint8 alpha) -> void { + if (alpha == 0U) return; + if (x < 0 || y < 0 || x >= static_cast(width_) || y >= static_cast(height_)) return; + auto &dst = pixels_[static_cast(y) * width_ + static_cast(x)]; + const auto inv = static_cast(255U - alpha); + const auto mix = [alpha, inv](uint32 lo, uint32 hi) { return (hi * alpha + lo * inv) / 255U; }; + dst = (0xFF000000U) | (mix((dst >> 16U) & 0xFFU, (color >> 16U) & 0xFFU) << 16U) | (mix((dst >> 8U) & 0xFFU, (color >> 8U) & 0xFFU) << 8U) | + mix(dst & 0xFFU, color & 0xFFU); + } + + /** Copy `src` with its top-left at (dst_x, dst_y). */ + auto blit(const image &src, int dst_x, int dst_y) -> void { + for (uint32 sy = 0; sy < src.height_; ++sy) { + for (uint32 sx = 0; sx < src.width_; ++sx) { + this->set(dst_x + static_cast(sx), dst_y + static_cast(sy), src.pixels_[static_cast(sy) * src.width_ + sx]); + } + } + } + + auto draw_rect(int x, int y, int w, int h, uint32 color) -> void { + for (int i = 0; i < h; ++i) { + for (int j = 0; j < w; ++j) this->set(x + j, y + i, color); + } + } + + auto draw_line(int x0, int y0, int x1, int y1, uint32 color) -> void { + const int dx = std::abs(x1 - x0); + const int dy = -std::abs(y1 - y0); + const int sx = x0 < x1 ? 1 : -1; + const int sy = y0 < y1 ? 1 : -1; + int error = dx + dy; + for (;;) { + this->set(x0, y0, color); + if (x0 == x1 && y0 == y1) break; + const int twice = 2 * error; + if (twice >= dy) { + error += dy; + x0 += sx; + } + if (twice <= dx) { + error += dx; + y0 += sy; + } + } + } + + auto fill_circle(int cx, int cy, int radius, uint32 color) -> void { + for (int y = -radius; y <= radius; ++y) { + for (int x = -radius; x <= radius; ++x) { + if (x * x + y * y <= radius * radius) this->set(cx + x, cy + y, color); + } + } + } + + auto draw_circle(int cx, int cy, int radius, uint32 color) -> void { + for (int a = 0; a < 360; ++a) { + const auto rad = static_cast(a) * 3.14159265358979323846 / 180.0; + this->set(cx + static_cast(std::lround(std::cos(rad) * radius)), cy + static_cast(std::lround(std::sin(rad) * radius)), color); + } + } + + private: + uint32 width_ = 0; + uint32 height_ = 0; + std::vector pixels_; + }; + + [[nodiscard]] inline auto read_u16(std::span data, usize at) -> uint32 { + return static_cast(data[at]) | (static_cast(data[at + 1U]) << 8U); + } + + /** + * Decode a Truevision TGA image (types 2/3/10/11, 8/24/32-bit). + * + * RA3's map art and minimaps are uncompressed 24/32-bit TGAs with a + * bottom-left origin; the decoder handles both origins. + * + * @throws image_error if the header is short or the type is unsupported. + */ + [[nodiscard]] inline auto decode_tga(std::span data) -> image { + if (data.size() < 18U) throw image_error("TGA too short"); + const auto id_length = data[0]; + const auto color_map_type = data[1]; + const auto image_type = data[2]; + const auto width = read_u16(data, 12); + const auto height = read_u16(data, 14); + const auto depth = data[16]; + const auto descriptor = data[17]; + + if (color_map_type != 0U) throw image_error("color-mapped TGA unsupported"); + const bool rle = image_type == 10U || image_type == 11U; + const bool grayscale = image_type == 3U || image_type == 11U; + if (!grayscale && image_type != 2U && image_type != 10U) throw image_error("unsupported TGA image type"); + + const usize bytes_per_pixel = static_cast(depth / 8U); + if (bytes_per_pixel < 1U || bytes_per_pixel > 4U) throw image_error("unsupported TGA depth"); + + const bool top_origin = (descriptor & 0x20U) != 0U; + image out(width, height); + std::vector row(width); + + usize pos = 18U + id_length; + auto read_pixel = [&]() -> uint32 { + uint8 r = 0; + uint8 g = 0; + uint8 b = 0; + if (grayscale) { + r = g = b = data[pos]; + } else { + b = data[pos]; + g = data[pos + 1U]; + r = data[pos + 2U]; + } + pos += bytes_per_pixel; + return argb(r, g, b); + }; + + for (uint32 y = 0; y < height; ++y) { + if (rle) { + uint32 x = 0; + while (x < width) { + const auto packet = data[pos++]; + const uint32 run = (static_cast(packet) & 0x7FU) + 1U; + if ((packet & 0x80U) != 0U) { + const auto value = read_pixel(); + for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = value; + } else { + for (uint32 i = 0; i < run && x < width; ++i, ++x) row[x] = read_pixel(); + } + } + } else { + for (uint32 x = 0; x < width; ++x) row[x] = read_pixel(); + } + const auto dest_y = top_origin ? y : (height - 1U - y); + for (uint32 x = 0; x < width; ++x) out.set(static_cast(x), static_cast(dest_y), row[x]); + } + return out; + } + + /** + * Encode an image as a 24-bit bottom-up BMP. + */ + [[nodiscard]] inline auto encode_bmp(const image &source) -> std::vector { + const auto width = static_cast(source.width()); + const auto height = static_cast(source.height()); + const uint32 row_bytes = ((width * 3U + 3U) / 4U) * 4U; + const uint32 pixel_bytes = row_bytes * height; + const uint32 file_size = 54U + pixel_bytes; + + std::vector out(file_size, 0U); + auto put32 = [&out](usize at, uint32 value) { + out[at] = static_cast(value); + out[at + 1U] = static_cast(value >> 8U); + out[at + 2U] = static_cast(value >> 16U); + out[at + 3U] = static_cast(value >> 24U); + }; + auto put16 = [&out](usize at, uint16_t value) { + out[at] = static_cast(value); + out[at + 1U] = static_cast(value >> 8U); + }; + + out[0] = 'B'; + out[1] = 'M'; + put32(2, file_size); + put32(10, 54U); // pixel data offset + put32(14, 40U); // DIB header size + put32(18, width); + put32(22, height); + put16(26, 1U); // planes + put16(28, 24U); // bits per pixel + put32(34, pixel_bytes); + put32(38, 2835U); // 72 DPI + put32(42, 2835U); + + for (uint32 y = 0; y < height; ++y) { + const auto src_y = height - 1U - y; // BMP is bottom-up + const auto row_base = 54U + static_cast(y) * row_bytes; + for (uint32 x = 0; x < width; ++x) { + const auto px = source.data()[static_cast(src_y) * width + x]; + out[row_base + x * 3U] = static_cast(px & 0xFFU); + out[row_base + x * 3U + 1U] = static_cast((px >> 8U) & 0xFFU); + out[row_base + x * 3U + 2U] = static_cast((px >> 16U) & 0xFFU); + } + } + return out; + } + + /** A point to overlay on a map, in world coordinates. */ + struct marker { + double x = 0.0; + double y = 0.0; + uint32 color = white; + int radius = 4; + }; + + /** How to composite a map scene. */ + struct scene_options { + std::string title; + double world_width = 5120.0; + double world_height = 5120.0; + bool show_grid = true; + int grid_divisions = 8; + uint32 grid_color = argb(120, 140, 170); + uint32 background = argb(24, 28, 36); + }; + + /** + * Composite a map image with a world grid and marker overlays. + * + * World `(0,0)` maps to the bottom-left of `base`; world `+Y` points up, so + * the image is flipped vertically. This is the engine's own top-down view + * of the map; precise world calibration is a later milestone. + */ + [[nodiscard]] inline auto compose(const image &base, std::span markers, const scene_options &options) -> image { + image scene(base.width(), base.height(), options.background); + scene.blit(base, 0, 0); + + const auto to_px = [&](double world_x, double world_y) -> std::pair { + const auto fx = options.world_width > 0.0 ? world_x / options.world_width : 0.0; + const auto fy = options.world_height > 0.0 ? world_y / options.world_height : 0.0; + return {static_cast(std::lround(fx * static_cast(base.width()))), + static_cast(std::lround((1.0 - fy) * static_cast(base.height())))}; + }; + + if (options.show_grid && options.grid_divisions > 0) { + for (int i = 0; i <= options.grid_divisions; ++i) { + const auto fx = static_cast(i) / options.grid_divisions; + const auto x = static_cast(std::lround(fx * base.width())); + const auto y = static_cast(std::lround(fx * base.height())); + scene.draw_line(x, 0, x, static_cast(base.height()) - 1, options.grid_color); + scene.draw_line(0, y, static_cast(base.width()) - 1, y, options.grid_color); + } + } + + for (const auto &point: markers) { + const auto [px, py] = to_px(point.x, point.y); + scene.fill_circle(px, py, point.radius, point.color); + scene.draw_circle(px, py, point.radius + 1, black); + } + return scene; + } +} diff --git a/src/ui/ra3.ui.null.cppm b/src/ui/ra3.ui.null.cppm new file mode 100644 index 0000000..af0169a --- /dev/null +++ b/src/ui/ra3.ui.null.cppm @@ -0,0 +1,24 @@ +module; + +#include + +export module ra3.ui; + +export import ra3.core; +import ra3.render; + +/** + * Fallback viewer used when the build has no SDL3. It never opens a window; + * the caller should write an offscreen image instead. + */ +export namespace ra3::ui { + /** Window parameters (mirrors the SDL backend). */ + struct viewer_options { + std::string title = "OpenRA3"; + int width = 1024; + int height = 768; + }; + + /** Always returns false: this build has no windowing backend. */ + [[nodiscard]] inline auto run_viewer(const ra3::render::image &, const viewer_options &) -> bool { return false; } +} diff --git a/src/ui/ra3.ui.sdl.cppm b/src/ui/ra3.ui.sdl.cppm new file mode 100644 index 0000000..91bead5 --- /dev/null +++ b/src/ui/ra3.ui.sdl.cppm @@ -0,0 +1,93 @@ +module; + +#include + +#include +#include + +export module ra3.ui; + +export import ra3.core; +import ra3.render; + +/** + * A minimal SDL3 window for viewing a rendered scene. + * + * Controls: drag with the left mouse button to pan, wheel to zoom, Esc/close + * to quit. When no display is available `run_viewer` returns false so the + * caller can fall back to an offscreen image. + */ +export namespace ra3::ui { + /** Window parameters. */ + struct viewer_options { + std::string title = "OpenRA3"; + int width = 1024; + int height = 768; + }; + + /** + * Show `scene` in a window until the user quits. + * + * @return true if the window ran; false if video init or window creation + * failed (typically no display). + */ + [[nodiscard]] inline auto run_viewer(const ra3::render::image &scene, const viewer_options &options) -> bool { + if (scene.empty()) return false; + if (!SDL_Init(SDL_INIT_VIDEO)) return false; + + SDL_Window *window = SDL_CreateWindow(options.title.c_str(), options.width, options.height, SDL_WINDOW_RESIZABLE); + if (window == nullptr) { + SDL_Quit(); + return false; + } + SDL_Renderer *renderer = SDL_CreateRenderer(window, nullptr); + if (renderer == nullptr) { + SDL_DestroyWindow(window); + SDL_Quit(); + return false; + } + SDL_Texture *texture = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, static_cast(scene.width()), + static_cast(scene.height())); + if (texture == nullptr) { + SDL_DestroyRenderer(renderer); + SDL_DestroyWindow(window); + SDL_Quit(); + return false; + } + SDL_UpdateTexture(texture, nullptr, scene.data(), static_cast(scene.width()) * 4); + SDL_SetTextureScaleMode(texture, SDL_SCALEMODE_NEAREST); + + float zoom = 1.0F; + float pan_x = 0.0F; + float pan_y = 0.0F; + bool running = true; + SDL_Event event; + while (running) { + while (SDL_PollEvent(&event)) { + if (event.type == SDL_EVENT_QUIT) { + running = false; + } else if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_ESCAPE) { + running = false; + } else if (event.type == SDL_EVENT_MOUSE_WHEEL) { + zoom *= event.wheel.y > 0.0F ? 1.1F : (1.0F / 1.1F); + zoom = std::clamp(zoom, 0.1F, 20.0F); + } else if (event.type == SDL_EVENT_MOUSE_MOTION && (event.motion.state & SDL_BUTTON_LMASK) != 0U) { + pan_x += event.motion.xrel; + pan_y += event.motion.yrel; + } + } + + SDL_SetRenderDrawColor(renderer, 0, 0, 0, 255); + SDL_RenderClear(renderer); + const SDL_FRect dst{pan_x, pan_y, static_cast(scene.width()) * zoom, static_cast(scene.height()) * zoom}; + SDL_RenderTexture(renderer, texture, nullptr, &dst); + SDL_RenderPresent(renderer); + } + + SDL_DestroyTexture(texture); + SDL_DestroyRenderer(renderer); + SDL_DestroyWindow(window); + SDL_Quit(); + return true; + } +} diff --git a/tests/ra3_tests.cpp b/tests/ra3_tests.cpp index 07afb39..88878ee 100644 --- a/tests/ra3_tests.cpp +++ b/tests/ra3_tests.cpp @@ -107,6 +107,27 @@ auto main() -> int { std::error_code ignored; std::filesystem::remove(archive_path, ignored); + // TGA: a 2x2 top-origin truecolor image decodes with correct pixels. + std::vector tga{0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 2, 0, 24, 0x20}; + const core::uint8 tga_pixels[] = {0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255}; + tga.insert(tga.end(), tga_pixels, tga_pixels + 12); + const auto decoded_tga = render::decode_tga(tga); + check(decoded_tga.width() == 2U && decoded_tga.height() == 2U, "TGA dimensions"); + check(decoded_tga.data()[0] == render::argb(255, 0, 0), "TGA top-left is red"); + check(decoded_tga.data()[3] == render::argb(255, 255, 255), "TGA bottom-right is white"); + + const auto bmp = render::encode_bmp(decoded_tga); + check(bmp.size() > 54U && bmp[0] == 'B' && bmp[1] == 'M', "BMP header"); + check(bmp[18] == 2U && bmp[19] == 0U && bmp[22] == 2U, "BMP dimensions"); + + // Compositing keeps the source size and accepts markers. + render::scene_options scene; + scene.world_width = 100.0; + scene.world_height = 100.0; + const std::vector markers{{10.0, 20.0, render::red, 3}}; + const auto composed = render::compose(decoded_tga, markers, scene); + check(composed.width() == 2U && composed.height() == 2U, "compose preserves size"); + // Skirmish: deterministic, resolves within the frame cap. skirmish::match_config config; config.seed = 42U;