Read the buildings and props a map places from its compiled art (the uncompressed worldbuilder stream + embedded DDS textures): - ra3.models: BAB asset-stream parser (lazy slices), W3DMesh + D3DHierarchy decode, DDS (DXT1/3/5 + uncompressed) decode, per-mesh bone remap and bind-pose single-joint skinning, flattened to one world-space triangle soup. - ra3.map: parse the ObjectsList chunk into (type, x, y, z, angle). - ra3.terrain: render3d rasterises the scene over the raymarched terrain with a z-buffer; gpu_terrain carries the scene for the GPU backends. - ra3.vulkan: second pipeline + depth attachment, terrain.frag writes gl_FragDepth, and a small depth bias keeps ground decals from z-fighting. - objects.fx shaders (compiled to SPIR-V), embedded like scene/terrain. Only opaque parts are drawn: FX-light billboards (DefaultW3D.fx / BasicW3D.fx) and damage-fill shells (BuildingsGenericDamageFill.fx) are skipped, since the latter paint the wrecked interior (e.g. orange CBBuilding_Wood) over the shell. Ground-decal meshes with no diffuse role and the Road templates themselves are still not drawn.
308 lines
15 KiB
C++
308 lines
15 KiB
C++
import std;
|
|
import ra3;
|
|
|
|
namespace {
|
|
int failures = 0;
|
|
|
|
auto check(bool condition, std::string_view what) -> void {
|
|
if (!condition) {
|
|
std::printf("FAIL: %.*s\n", static_cast<int>(what.size()), what.data());
|
|
++failures;
|
|
}
|
|
}
|
|
|
|
auto append_be32(std::vector<std::uint8_t> &out, std::uint32_t value) -> void {
|
|
out.push_back(static_cast<std::uint8_t>(value >> 24U));
|
|
out.push_back(static_cast<std::uint8_t>(value >> 16U));
|
|
out.push_back(static_cast<std::uint8_t>(value >> 8U));
|
|
out.push_back(static_cast<std::uint8_t>(value));
|
|
}
|
|
|
|
auto append_le32(std::vector<std::uint8_t> &out, std::uint32_t value) -> void {
|
|
out.push_back(static_cast<std::uint8_t>(value));
|
|
out.push_back(static_cast<std::uint8_t>(value >> 8U));
|
|
out.push_back(static_cast<std::uint8_t>(value >> 16U));
|
|
out.push_back(static_cast<std::uint8_t>(value >> 24U));
|
|
}
|
|
|
|
/** Build a one-entry BIG4 archive and return its path. */
|
|
auto make_test_archive(const std::filesystem::path &path) -> void {
|
|
const std::string name = "data\\hello.txt";
|
|
const std::string payload = "hello big4";
|
|
|
|
std::vector<std::uint8_t> index;
|
|
append_be32(index, 0); // offset, patched below
|
|
append_be32(index, static_cast<std::uint32_t>(payload.size()));
|
|
index.insert(index.end(), name.begin(), name.end());
|
|
index.push_back(0);
|
|
|
|
const std::uint32_t payload_offset = 16U + static_cast<std::uint32_t>(index.size());
|
|
std::vector<std::uint8_t> index_patched;
|
|
append_be32(index_patched, payload_offset);
|
|
index_patched.insert(index_patched.end(), index.begin() + 4, index.end());
|
|
|
|
std::vector<std::uint8_t> file;
|
|
file.insert(file.end(), {'B', 'I', 'G', '4'});
|
|
append_le32(file, static_cast<std::uint32_t>(16U + index_patched.size() + payload.size()));
|
|
append_be32(file, 1U);
|
|
append_le32(file, 0U);
|
|
file.insert(file.end(), index_patched.begin(), index_patched.end());
|
|
file.insert(file.end(), payload.begin(), payload.end());
|
|
|
|
std::ofstream out(path, std::ios::binary);
|
|
out.write(reinterpret_cast<const char *>(file.data()), static_cast<std::streamsize>(file.size()));
|
|
}
|
|
}
|
|
|
|
auto main() -> int {
|
|
using namespace ra3;
|
|
|
|
check(core::make_name_key("Tank") == core::make_name_key("tank"), "name keys are case-insensitive");
|
|
|
|
core::message_stream stream;
|
|
stream.append(core::message_id::begin_block);
|
|
stream.append(core::message_id::new_game);
|
|
check(stream.size() == 2U, "message stream counts entries");
|
|
check(stream.pop()->id == core::message_id::begin_block, "message stream is FIFO");
|
|
|
|
logic::game_logic simulation;
|
|
simulation.prepare_new_game(7U);
|
|
check(simulation.starting(), "prepare raises the starting flag");
|
|
simulation.start_new_game();
|
|
check(!simulation.messages().empty(), "start appends new_game");
|
|
simulation.update();
|
|
check(simulation.frame() == 1U, "update advances the frame counter");
|
|
check(simulation.messages().empty(), "update drains the message stream");
|
|
|
|
auto &side = simulation.players().add(std::make_unique<logic::player>(0U, "Test"));
|
|
side.set_money(250);
|
|
side.add_money(-100);
|
|
check(side.money() == 150, "money clamps and accumulates");
|
|
|
|
check(game::to_string(game::faction::soviet) == "Soviet", "faction names round-trip");
|
|
|
|
// RA3 data: damage types, armour resolution and weapon target masks.
|
|
check(data::to_string(data::damage_type::auto_cannon) == "AUTO_CANNON", "damage type names round-trip");
|
|
check(data::damage_type_from_name("PRISM") == data::damage_type::prism, "damage type parses from asset name");
|
|
check(data::allied_scout_infantry_armor.adjust(data::damage_type::gun, 100.0F) == 1.0F, "scout armour takes 1% from GUN");
|
|
check(data::allied_scout_infantry_armor.adjust(data::damage_type::tesla, 100.0F) == 50.0F, "scout armour takes 50% from TESLA");
|
|
check(data::allied_anti_infantry_infantry_armor.adjust(data::damage_type::tesla, 100.0F) == 1000.0F, "peacekeeper armour takes 10x from TESLA");
|
|
check(data::allied_anti_infantry_infantry_armor.adjust(data::damage_type::unresistable, 100.0F) == 100.0F, "UNRESISTABLE bypasses armour");
|
|
check(data::maul_weapon.can_target(data::target_class::infantry), "the maul can target infantry");
|
|
check(!data::maul_weapon.can_target(data::target_class::vehicle), "the maul cannot target vehicles");
|
|
check(!data::maul_weapon.can_target(data::target_class::structure), "the maul cannot target structures");
|
|
check(!data::maul_weapon.can_target(data::target_class::aircraft), "the maul cannot target aircraft");
|
|
check(data::shotgun_weapon.can_target(data::target_class::infantry) && data::shotgun_weapon.can_target(data::target_class::structure),
|
|
"the shotgun hits infantry and structures");
|
|
check(!data::shotgun_weapon.can_target(data::target_class::aircraft), "the shotgun cannot hit aircraft");
|
|
check(data::cost_of(data::entity_kind::guardian_tank) == 950, "guardian tank costs 950");
|
|
check(data::max_health_of(data::entity_kind::power_plant) == 1000.0F, "power plant has 1000 health");
|
|
check(data::structure_of(data::entity_kind::power_plant).energy == 100, "power plant supplies 100 energy");
|
|
check(data::seconds_to_frames(1.0F) == 30U, "one second is 30 logic frames");
|
|
|
|
// RefPack: a literal-only stream decodes to its payload.
|
|
const std::vector<core::uint8> refpack{0x10, 0xFB, 0x00, 0x00, 0x03, 0xFF, 'A', 'B', 'C'};
|
|
check(fs::is_refpack(refpack), "literal RefPack stream is recognised");
|
|
const auto decoded = fs::refpack_decompress(refpack);
|
|
check(decoded.size() == 3U && decoded[0] == 'A' && decoded[2] == 'C', "RefPack literals decode");
|
|
check(fs::maybe_decompress(std::vector<core::uint8>{'x', 'y'}).size() == 2U, "non-RefPack passes through");
|
|
|
|
// BIG4: build and read a synthetic archive.
|
|
const auto archive_path = std::filesystem::temp_directory_path() / "openra3_test.big";
|
|
make_test_archive(archive_path);
|
|
const auto archive = fs::big_archive::open(archive_path);
|
|
check(archive.size() == 1U, "BIG4 index has one entry");
|
|
check(archive.contains("data\\hello.txt"), "BIG4 entry is indexed");
|
|
const auto payload = archive.read("data\\hello.txt");
|
|
check(std::string(payload.begin(), payload.end()) == "hello big4", "BIG4 payload reads back");
|
|
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");
|
|
|
|
// Camera: wheel zoom, clamped pan and RA3-style edge scrolling.
|
|
render::view_camera camera;
|
|
camera.zoom = 2.0F;
|
|
camera.center_x = 0.5F;
|
|
camera.center_y = 0.5F;
|
|
camera.scroll(10.0F, 10.0F);
|
|
check(camera.center_x == 0.75F && camera.center_y == 0.75F, "camera clamps the centre to the map");
|
|
camera.zoom_by(100.0F);
|
|
check(camera.zoom == camera.max_zoom, "camera clamps the maximum zoom");
|
|
camera.zoom_by(0.001F);
|
|
check(camera.zoom == camera.min_zoom, "camera clamps the minimum zoom");
|
|
const auto fit_rect = camera.rect(512.0F, 512.0F, 1024.0F, 768.0F);
|
|
check(fit_rect.w == 768.0F && fit_rect.h == 768.0F && fit_rect.x == 128.0F && fit_rect.y == 0.0F, "camera letterboxes a square map into a wide window");
|
|
|
|
render::view_camera edge;
|
|
edge.zoom = 2.0F;
|
|
edge.center_x = 0.5F;
|
|
edge.center_y = 0.5F;
|
|
edge.edge_scroll(0.0F, 0.0F, 1024.0F, 768.0F, 1.0F);
|
|
check(edge.center_x < 0.5F && edge.center_y < 0.5F, "the cursor at the top-left edge scrolls up-left");
|
|
render::view_camera whole;
|
|
whole.edge_scroll(0.0F, 0.0F, 1024.0F, 768.0F, 1.0F);
|
|
check(whole.center_x == 0.5F && whole.center_y == 0.5F, "no edge scroll while the whole map fits");
|
|
|
|
// Skirmish: deterministic, resolves within the frame cap.
|
|
skirmish::match_config config;
|
|
config.seed = 42U;
|
|
const auto starts = skirmish::builtin_start_positions();
|
|
auto first = skirmish::skirmish_match::create(config, starts);
|
|
check(first.unit_count(0U) == 1U && first.unit_count(1U) == 1U, "each side starts with a construction yard");
|
|
const auto first_result = first.run();
|
|
auto second = skirmish::skirmish_match::create(config, starts);
|
|
const auto second_result = second.run();
|
|
check(first_result.decided, "skirmish reaches a decision");
|
|
check(first_result.winner == 0 || first_result.winner == 1, "skirmish has a winner");
|
|
check(first_result.frames == second_result.frames && first_result.winner == second_result.winner, "skirmish is deterministic");
|
|
|
|
// Text/font: glyphs paint, spaces are blank and widths scale.
|
|
check(render::text_width("ABC", 2U) == 48U, "text width scales with glyph size");
|
|
render::image text_image(64U, 16U, render::black);
|
|
render::draw_text(text_image, 0, 0, "A", render::white, 2U);
|
|
int lit = 0;
|
|
for (std::size_t i = 0; i < 64U * 16U; ++i) {
|
|
if (text_image.data()[i] != render::black) ++lit;
|
|
}
|
|
check(lit > 0, "draw_text paints glyph pixels");
|
|
render::image space_image(64U, 16U, render::black);
|
|
render::draw_text(space_image, 0, 0, " ", render::white, 2U);
|
|
bool space_lit = false;
|
|
for (std::size_t i = 0; i < 64U * 16U; ++i) {
|
|
if (space_image.data()[i] != render::black) space_lit = true;
|
|
}
|
|
check(!space_lit, "a space paints nothing");
|
|
|
|
const auto box = render::fit_rect(1280.0F, 720.0F, 1920.0F, 1080.0F);
|
|
check(box.x == 0.0F && box.y == 0.0F && box.w == 1920.0F && box.h == 1080.0F, "fit_rect fills a same-aspect window");
|
|
|
|
// Map display names: decode a synthetic `gamestrings.csf` `MAP:` label.
|
|
std::vector<core::uint8> csf;
|
|
const auto put_le32 = [&csf](std::uint32_t v) {
|
|
for (int i = 0; i < 4; ++i) csf.push_back(static_cast<core::uint8>(v >> (8 * i)));
|
|
};
|
|
csf.insert(csf.end(), {' ', 'F', 'S', 'C'});
|
|
put_le32(3U);
|
|
put_le32(1U);
|
|
put_le32(1U);
|
|
for (int i = 0; i < 8; ++i) csf.push_back(0U);
|
|
csf.insert(csf.end(), {' ', 'L', 'B', 'L'});
|
|
put_le32(1U);
|
|
const std::string csf_label = "MAP:MAP_MP_2_FEASEL4";
|
|
put_le32(static_cast<std::uint32_t>(csf_label.size()));
|
|
csf.insert(csf.end(), csf_label.begin(), csf_label.end());
|
|
csf.insert(csf.end(), {' ', 'R', 'T', 'S'});
|
|
const std::string csf_value = "Battlebase Beta";
|
|
put_le32(static_cast<std::uint32_t>(csf_value.size()));
|
|
for (const auto ch: csf_value) {
|
|
csf.push_back(static_cast<core::uint8>(ch ^ 0xFF));
|
|
csf.push_back(0xFFU);
|
|
}
|
|
const auto names = map::parse_map_names(csf);
|
|
check(names.lookup("map_mp_2_feasel4") == "Battlebase Beta", "CSF map names decode (byte-XOR 0xFF)");
|
|
check(names.lookup("unknown_map") == "unknown_map", "an unknown id falls back to itself");
|
|
|
|
// Map objects (`ObjectsList`): decode a synthetic one-object chunk.
|
|
const auto put16 = [](std::vector<core::uint8> &out, std::uint16_t v) {
|
|
out.push_back(static_cast<core::uint8>(v));
|
|
out.push_back(static_cast<core::uint8>(v >> 8U));
|
|
};
|
|
const auto put32 = [](std::vector<core::uint8> &out, std::uint32_t v) {
|
|
for (int i = 0; i < 4; ++i) out.push_back(static_cast<core::uint8>(v >> (8 * i)));
|
|
};
|
|
const auto putf = [&put32](std::vector<core::uint8> &out, float f) {
|
|
std::uint32_t bits = 0;
|
|
std::memcpy(&bits, &f, sizeof(bits));
|
|
put32(out, bits);
|
|
};
|
|
|
|
std::vector<core::uint8> object_data;
|
|
putf(object_data, 100.0F);
|
|
putf(object_data, 200.0F);
|
|
putf(object_data, 0.0F);
|
|
putf(object_data, 0.5F);
|
|
put32(object_data, 0U); // road type
|
|
const std::string object_type = "BB_TEST";
|
|
put16(object_data, static_cast<std::uint16_t>(object_type.size()));
|
|
object_data.insert(object_data.end(), object_type.begin(), object_type.end());
|
|
put16(object_data, 0U); // no properties
|
|
std::vector<core::uint8> object_asset;
|
|
put32(object_asset, 1U); // asset index
|
|
put16(object_asset, 1U); // version
|
|
put32(object_asset, static_cast<std::uint32_t>(object_data.size()));
|
|
object_asset.insert(object_asset.end(), object_data.begin(), object_data.end());
|
|
|
|
std::vector<core::uint8> ckmp;
|
|
ckmp.insert(ckmp.end(), {'C', 'k', 'M', 'p'});
|
|
put32(ckmp, 1U); // one asset name
|
|
const std::string chunk_name = "ObjectsList";
|
|
ckmp.push_back(static_cast<core::uint8>(chunk_name.size()));
|
|
ckmp.insert(ckmp.end(), chunk_name.begin(), chunk_name.end());
|
|
put32(ckmp, 1U); // name index
|
|
put32(ckmp, 1U); // chunk index
|
|
put16(ckmp, 3U); // chunk version
|
|
put32(ckmp, static_cast<std::uint32_t>(object_asset.size()));
|
|
ckmp.insert(ckmp.end(), object_asset.begin(), object_asset.end());
|
|
|
|
const auto objects = map::parse_objects(ckmp);
|
|
check(objects.size() == 1U, "ObjectsList parses one object");
|
|
check(!objects.empty() && objects[0].type == "BB_TEST", "object type-name decodes");
|
|
check(!objects.empty() && objects[0].x == 100.0F && objects[0].y == 200.0F && objects[0].angle == 0.5F, "object position/angle decode");
|
|
|
|
// DDS: a 1x1 uncompressed RGB32 image decodes with correct channels.
|
|
std::vector<core::uint8> dds(128U, 0U);
|
|
dds[0] = 'D';
|
|
dds[1] = 'D';
|
|
dds[2] = 'S';
|
|
dds[3] = ' ';
|
|
const auto put_dds = [&dds](std::size_t off, std::uint32_t v) {
|
|
for (int i = 0; i < 4; ++i) dds[off + static_cast<std::size_t>(i)] = static_cast<core::uint8>(v >> (8 * i));
|
|
};
|
|
put_dds(4U, 124U);
|
|
put_dds(8U, 0x1U); // flags: DDSD_CAPS|... (unused)
|
|
put_dds(12U, 1U); // height
|
|
put_dds(16U, 1U); // width
|
|
put_dds(76U, 32U); // pixel format size
|
|
put_dds(80U, 0x40U); // DDPF_RGB
|
|
put_dds(84U, 0U); // no fourcc
|
|
put_dds(88U, 32U); // bits per pixel
|
|
put_dds(92U, 0x00FF0000U);
|
|
put_dds(96U, 0x0000FF00U);
|
|
put_dds(100U, 0x000000FFU);
|
|
put_dds(104U, 0xFF000000U);
|
|
dds.push_back(0x00U); // B
|
|
dds.push_back(0x00U); // G
|
|
dds.push_back(0xFFU); // R
|
|
dds.push_back(0xFFU); // A
|
|
const auto decoded_dds = models::decode_dds(dds);
|
|
check(decoded_dds.width() == 1U && decoded_dds.height() == 1U, "DDS dimensions decode");
|
|
check(!decoded_dds.empty() && decoded_dds.data()[0] == render::argb(255, 0, 0), "DDS RGB32 channels map to ARGB");
|
|
|
|
check(models::decode_dds(std::vector<core::uint8>{1U, 2U, 3U}).empty(), "a non-DDS payload yields no image");
|
|
|
|
if (failures == 0) {
|
|
std::puts("ra3_tests: OK");
|
|
}
|
|
return failures == 0 ? 0 : 1;
|
|
}
|