v0.1.0: software map renderer + SDL3 window viewer

This commit is contained in:
EnderTheCoder
2026-09-12 01:02:49 +08:00
parent 3ad380c81e
commit b35372da52
13 changed files with 659 additions and 31 deletions
+33 -1
View File
@@ -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 ---
+12 -4
View File
@@ -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
+30 -2
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
0.0.1
0.1.0
+90
View File
@@ -1,6 +1,8 @@
#include <algorithm>
#include <cstdio>
#include <exception>
#include <filesystem>
#include <fstream>
#include <optional>
#include <string>
#include <string_view>
@@ -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 (`<stem>_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<std::string> &args, const std::optional<std::filesystem::path> &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<ra3::core::uint32>(std::stoul(*seed));
if (const auto frames = option_value(args, "--frames")) config.max_frames = static_cast<ra3::core::uint32>(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<ra3::render::marker> markers;
for (const auto &player: match.players()) {
markers.push_back({static_cast<double>(player.start.x), static_cast<double>(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<double>(entry.position.x), static_cast<double>(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<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", 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;
+31 -16
View File
@@ -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
+9 -5
View File
@@ -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.
+2 -2
View File
@@ -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;
+2
View File
@@ -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;
+311
View File
@@ -0,0 +1,311 @@
module;
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <span>
#include <stdexcept>
#include <string>
#include <utility>
#include <vector>
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<uint32>(a) << 24U) | (static_cast<uint32>(r) << 16U) | (static_cast<uint32>(g) << 8U) | static_cast<uint32>(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<usize>(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<int>(width_) || y >= static_cast<int>(height_)) return;
pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(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<int>(width_) || y >= static_cast<int>(height_)) return;
auto &dst = pixels_[static_cast<usize>(y) * width_ + static_cast<usize>(x)];
const auto inv = static_cast<uint32>(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<int>(sx), dst_y + static_cast<int>(sy), src.pixels_[static_cast<usize>(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<double>(a) * 3.14159265358979323846 / 180.0;
this->set(cx + static_cast<int>(std::lround(std::cos(rad) * radius)), cy + static_cast<int>(std::lround(std::sin(rad) * radius)), color);
}
}
private:
uint32 width_ = 0;
uint32 height_ = 0;
std::vector<uint32> pixels_;
};
[[nodiscard]] inline auto read_u16(std::span<const uint8> data, usize at) -> uint32 {
return static_cast<uint32>(data[at]) | (static_cast<uint32>(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<const uint8> 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<usize>(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<uint32> 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<uint32>(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<int>(x), static_cast<int>(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<uint8> {
const auto width = static_cast<uint32>(source.width());
const auto height = static_cast<uint32>(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<uint8> out(file_size, 0U);
auto put32 = [&out](usize at, uint32 value) {
out[at] = static_cast<uint8>(value);
out[at + 1U] = static_cast<uint8>(value >> 8U);
out[at + 2U] = static_cast<uint8>(value >> 16U);
out[at + 3U] = static_cast<uint8>(value >> 24U);
};
auto put16 = [&out](usize at, uint16_t value) {
out[at] = static_cast<uint8>(value);
out[at + 1U] = static_cast<uint8>(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<usize>(y) * row_bytes;
for (uint32 x = 0; x < width; ++x) {
const auto px = source.data()[static_cast<usize>(src_y) * width + x];
out[row_base + x * 3U] = static_cast<uint8>(px & 0xFFU);
out[row_base + x * 3U + 1U] = static_cast<uint8>((px >> 8U) & 0xFFU);
out[row_base + x * 3U + 2U] = static_cast<uint8>((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<const marker> 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<int, int> {
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<int>(std::lround(fx * static_cast<double>(base.width()))),
static_cast<int>(std::lround((1.0 - fy) * static_cast<double>(base.height())))};
};
if (options.show_grid && options.grid_divisions > 0) {
for (int i = 0; i <= options.grid_divisions; ++i) {
const auto fx = static_cast<double>(i) / options.grid_divisions;
const auto x = static_cast<int>(std::lround(fx * base.width()));
const auto y = static_cast<int>(std::lround(fx * base.height()));
scene.draw_line(x, 0, x, static_cast<int>(base.height()) - 1, options.grid_color);
scene.draw_line(0, y, static_cast<int>(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;
}
}
+24
View File
@@ -0,0 +1,24 @@
module;
#include <string>
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; }
}
+93
View File
@@ -0,0 +1,93 @@
module;
#include <SDL3/SDL.h>
#include <algorithm>
#include <string>
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<int>(scene.width()),
static_cast<int>(scene.height()));
if (texture == nullptr) {
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
return false;
}
SDL_UpdateTexture(texture, nullptr, scene.data(), static_cast<int>(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<float>(scene.width()) * zoom, static_cast<float>(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;
}
}
+21
View File
@@ -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<core::uint8> 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<render::marker> 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;