mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-05 00:33:51 +00:00
chore(testing): add unit tests and update core render/network pipelines
- add new tests: - test_blp_loader.cpp - test_dbc_loader.cpp - test_entity.cpp - test_frustum.cpp - test_m2_structs.cpp - test_opcode_table.cpp - test_packet.cpp - test_srp.cpp - CMakeLists.txt - add docs and progress tracking: - TESTING.md - perf_baseline.md - update project config/build: - .gitignore - CMakeLists.txt - test.sh - core engine updates: - application.cpp - game_handler.cpp - world_socket.cpp - adt_loader.cpp - asset_manager.cpp - m2_renderer.cpp - post_process_pipeline.cpp - renderer.cpp - terrain_manager.cpp - game_screen.cpp - add profiler header: - profiler.hpp
This commit is contained in:
parent
a2814ab082
commit
2cb47bf126
25 changed files with 2042 additions and 96 deletions
142
tests/CMakeLists.txt
Normal file
142
tests/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# Phase 0: Unit test infrastructure using Catch2 v3 (amalgamated)
|
||||
|
||||
# Catch2 amalgamated as a library target
|
||||
add_library(catch2_main STATIC
|
||||
${CMAKE_SOURCE_DIR}/extern/catch2/catch_amalgamated.cpp
|
||||
)
|
||||
target_include_directories(catch2_main PUBLIC
|
||||
${CMAKE_SOURCE_DIR}/extern/catch2
|
||||
)
|
||||
# Catch2 v3 needs C++17 minimum
|
||||
target_compile_features(catch2_main PUBLIC cxx_std_17)
|
||||
|
||||
# ── ASAN / UBSan propagation ────────────────────────────────
|
||||
# Collect all test target names so we can apply sanitizer flags at the end.
|
||||
set(ALL_TEST_TARGETS "")
|
||||
|
||||
# Helper: register a test target for ASAN/UBSan if enabled.
|
||||
macro(register_test_target _target)
|
||||
list(APPEND ALL_TEST_TARGETS ${_target})
|
||||
endmacro()
|
||||
|
||||
# Shared source files used across multiple tests
|
||||
set(TEST_COMMON_SOURCES
|
||||
${CMAKE_SOURCE_DIR}/src/core/logger.cpp
|
||||
)
|
||||
|
||||
# Include directories matching the main target
|
||||
set(TEST_INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/include
|
||||
${CMAKE_SOURCE_DIR}/src
|
||||
)
|
||||
set(TEST_SYSTEM_INCLUDE_DIRS
|
||||
${CMAKE_SOURCE_DIR}/extern
|
||||
)
|
||||
|
||||
# ── test_packet ──────────────────────────────────────────────
|
||||
add_executable(test_packet
|
||||
test_packet.cpp
|
||||
${TEST_COMMON_SOURCES}
|
||||
${CMAKE_SOURCE_DIR}/src/network/packet.cpp
|
||||
)
|
||||
target_include_directories(test_packet PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_packet SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_packet PRIVATE catch2_main)
|
||||
add_test(NAME packet COMMAND test_packet)
|
||||
register_test_target(test_packet)
|
||||
|
||||
# ── test_srp ─────────────────────────────────────────────────
|
||||
add_executable(test_srp
|
||||
test_srp.cpp
|
||||
${TEST_COMMON_SOURCES}
|
||||
${CMAKE_SOURCE_DIR}/src/auth/srp.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/auth/big_num.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/auth/crypto.cpp
|
||||
)
|
||||
target_include_directories(test_srp PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_srp SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_srp PRIVATE catch2_main OpenSSL::SSL OpenSSL::Crypto)
|
||||
add_test(NAME srp COMMAND test_srp)
|
||||
register_test_target(test_srp)
|
||||
|
||||
# ── test_opcode_table ────────────────────────────────────────
|
||||
add_executable(test_opcode_table
|
||||
test_opcode_table.cpp
|
||||
${TEST_COMMON_SOURCES}
|
||||
${CMAKE_SOURCE_DIR}/src/game/opcode_table.cpp
|
||||
)
|
||||
target_include_directories(test_opcode_table PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_opcode_table SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_opcode_table PRIVATE catch2_main)
|
||||
add_test(NAME opcode_table COMMAND test_opcode_table)
|
||||
register_test_target(test_opcode_table)
|
||||
|
||||
# ── test_entity ──────────────────────────────────────────────
|
||||
add_executable(test_entity
|
||||
test_entity.cpp
|
||||
${TEST_COMMON_SOURCES}
|
||||
${CMAKE_SOURCE_DIR}/src/game/entity.cpp
|
||||
)
|
||||
target_include_directories(test_entity PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_entity SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_entity PRIVATE catch2_main)
|
||||
add_test(NAME entity COMMAND test_entity)
|
||||
register_test_target(test_entity)
|
||||
|
||||
# ── test_dbc_loader ──────────────────────────────────────────
|
||||
add_executable(test_dbc_loader
|
||||
test_dbc_loader.cpp
|
||||
${TEST_COMMON_SOURCES}
|
||||
${CMAKE_SOURCE_DIR}/src/pipeline/dbc_loader.cpp
|
||||
)
|
||||
target_include_directories(test_dbc_loader PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_dbc_loader SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_dbc_loader PRIVATE catch2_main)
|
||||
add_test(NAME dbc_loader COMMAND test_dbc_loader)
|
||||
register_test_target(test_dbc_loader)
|
||||
|
||||
# ── test_m2_structs ──────────────────────────────────────────
|
||||
# Header-only struct layout tests — no source files needed
|
||||
add_executable(test_m2_structs
|
||||
test_m2_structs.cpp
|
||||
)
|
||||
target_include_directories(test_m2_structs PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_m2_structs SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_m2_structs PRIVATE catch2_main)
|
||||
add_test(NAME m2_structs COMMAND test_m2_structs)
|
||||
register_test_target(test_m2_structs)
|
||||
|
||||
# ── test_blp_loader ──────────────────────────────────────────
|
||||
add_executable(test_blp_loader
|
||||
test_blp_loader.cpp
|
||||
${TEST_COMMON_SOURCES}
|
||||
${CMAKE_SOURCE_DIR}/src/pipeline/blp_loader.cpp
|
||||
)
|
||||
target_include_directories(test_blp_loader PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_blp_loader SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_blp_loader PRIVATE catch2_main)
|
||||
add_test(NAME blp_loader COMMAND test_blp_loader)
|
||||
register_test_target(test_blp_loader)
|
||||
|
||||
# ── test_frustum ─────────────────────────────────────────────
|
||||
add_executable(test_frustum
|
||||
test_frustum.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/rendering/frustum.cpp
|
||||
)
|
||||
target_include_directories(test_frustum PRIVATE ${TEST_INCLUDE_DIRS})
|
||||
target_include_directories(test_frustum SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||
target_link_libraries(test_frustum PRIVATE catch2_main)
|
||||
add_test(NAME frustum COMMAND test_frustum)
|
||||
register_test_target(test_frustum)
|
||||
|
||||
# ── ASAN / UBSan for test targets ────────────────────────────
|
||||
if(WOWEE_ENABLE_ASAN AND NOT MSVC)
|
||||
foreach(_t IN LISTS ALL_TEST_TARGETS)
|
||||
target_compile_options(${_t} PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
|
||||
target_link_options(${_t} PRIVATE -fsanitize=address,undefined)
|
||||
endforeach()
|
||||
# catch2_main must also be compiled with the same flags
|
||||
target_compile_options(catch2_main PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
|
||||
target_link_options(catch2_main PRIVATE -fsanitize=address,undefined)
|
||||
message(STATUS "Test targets: ASAN + UBSan ENABLED")
|
||||
endif()
|
||||
92
tests/test_blp_loader.cpp
Normal file
92
tests/test_blp_loader.cpp
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// Phase 0 – BLP loader tests: isValid, format names, invalid data handling
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "pipeline/blp_loader.hpp"
|
||||
|
||||
using wowee::pipeline::BLPLoader;
|
||||
using wowee::pipeline::BLPImage;
|
||||
using wowee::pipeline::BLPFormat;
|
||||
using wowee::pipeline::BLPCompression;
|
||||
|
||||
TEST_CASE("BLPImage default is invalid", "[blp]") {
|
||||
BLPImage img;
|
||||
REQUIRE_FALSE(img.isValid());
|
||||
REQUIRE(img.width == 0);
|
||||
REQUIRE(img.height == 0);
|
||||
REQUIRE(img.format == BLPFormat::UNKNOWN);
|
||||
REQUIRE(img.compression == BLPCompression::NONE);
|
||||
}
|
||||
|
||||
TEST_CASE("BLPImage isValid with data", "[blp]") {
|
||||
BLPImage img;
|
||||
img.width = 64;
|
||||
img.height = 64;
|
||||
img.data.resize(64 * 64 * 4, 0xFF); // RGBA
|
||||
REQUIRE(img.isValid());
|
||||
}
|
||||
|
||||
TEST_CASE("BLPImage isValid requires non-empty data", "[blp]") {
|
||||
BLPImage img;
|
||||
img.width = 64;
|
||||
img.height = 64;
|
||||
// data is empty
|
||||
REQUIRE_FALSE(img.isValid());
|
||||
}
|
||||
|
||||
TEST_CASE("BLPLoader::load empty data returns invalid", "[blp]") {
|
||||
std::vector<uint8_t> empty;
|
||||
auto img = BLPLoader::load(empty);
|
||||
REQUIRE_FALSE(img.isValid());
|
||||
}
|
||||
|
||||
TEST_CASE("BLPLoader::load too small data returns invalid", "[blp]") {
|
||||
std::vector<uint8_t> tiny = {0x42, 0x4C, 0x50}; // BLP but truncated
|
||||
auto img = BLPLoader::load(tiny);
|
||||
REQUIRE_FALSE(img.isValid());
|
||||
}
|
||||
|
||||
TEST_CASE("BLPLoader::load invalid magic returns invalid", "[blp]") {
|
||||
// Provide enough bytes but with wrong magic
|
||||
std::vector<uint8_t> bad(256, 0);
|
||||
bad[0] = 'N'; bad[1] = 'O'; bad[2] = 'T'; bad[3] = '!';
|
||||
auto img = BLPLoader::load(bad);
|
||||
REQUIRE_FALSE(img.isValid());
|
||||
}
|
||||
|
||||
TEST_CASE("BLPLoader getFormatName returns non-null", "[blp]") {
|
||||
REQUIRE(BLPLoader::getFormatName(BLPFormat::UNKNOWN) != nullptr);
|
||||
REQUIRE(BLPLoader::getFormatName(BLPFormat::BLP1) != nullptr);
|
||||
REQUIRE(BLPLoader::getFormatName(BLPFormat::BLP2) != nullptr);
|
||||
|
||||
// Check that names are distinct
|
||||
REQUIRE(std::string(BLPLoader::getFormatName(BLPFormat::BLP1)) !=
|
||||
std::string(BLPLoader::getFormatName(BLPFormat::BLP2)));
|
||||
}
|
||||
|
||||
TEST_CASE("BLPLoader getCompressionName returns non-null", "[blp]") {
|
||||
REQUIRE(BLPLoader::getCompressionName(BLPCompression::NONE) != nullptr);
|
||||
REQUIRE(BLPLoader::getCompressionName(BLPCompression::DXT1) != nullptr);
|
||||
REQUIRE(BLPLoader::getCompressionName(BLPCompression::DXT3) != nullptr);
|
||||
REQUIRE(BLPLoader::getCompressionName(BLPCompression::DXT5) != nullptr);
|
||||
REQUIRE(BLPLoader::getCompressionName(BLPCompression::PALETTE) != nullptr);
|
||||
REQUIRE(BLPLoader::getCompressionName(BLPCompression::ARGB8888) != nullptr);
|
||||
|
||||
// Check that DXT names differ
|
||||
REQUIRE(std::string(BLPLoader::getCompressionName(BLPCompression::DXT1)) !=
|
||||
std::string(BLPLoader::getCompressionName(BLPCompression::DXT5)));
|
||||
}
|
||||
|
||||
TEST_CASE("BLPImage mipmap storage", "[blp]") {
|
||||
BLPImage img;
|
||||
img.width = 128;
|
||||
img.height = 128;
|
||||
img.data.resize(128 * 128 * 4, 0);
|
||||
img.mipLevels = 3;
|
||||
|
||||
// Add mipmap data
|
||||
img.mipmaps.push_back(std::vector<uint8_t>(64 * 64 * 4, 0));
|
||||
img.mipmaps.push_back(std::vector<uint8_t>(32 * 32 * 4, 0));
|
||||
img.mipmaps.push_back(std::vector<uint8_t>(16 * 16 * 4, 0));
|
||||
|
||||
REQUIRE(img.isValid());
|
||||
REQUIRE(img.mipmaps.size() == 3);
|
||||
}
|
||||
208
tests/test_dbc_loader.cpp
Normal file
208
tests/test_dbc_loader.cpp
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
// Phase 0 – DBC binary parsing tests with synthetic data
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "pipeline/dbc_loader.hpp"
|
||||
#include <cstring>
|
||||
|
||||
using wowee::pipeline::DBCFile;
|
||||
|
||||
// Build a minimal valid DBC in memory:
|
||||
// Header: "WDBC" + recordCount(uint32) + fieldCount(uint32) + recordSize(uint32) + stringBlockSize(uint32)
|
||||
// Records: contiguous fixed-size rows
|
||||
// String block: null-terminated strings
|
||||
static std::vector<uint8_t> buildSyntheticDBC(
|
||||
uint32_t numRecords, uint32_t numFields,
|
||||
const std::vector<std::vector<uint32_t>>& records,
|
||||
const std::string& stringBlock)
|
||||
{
|
||||
const uint32_t recordSize = numFields * 4;
|
||||
const uint32_t stringBlockSize = static_cast<uint32_t>(stringBlock.size());
|
||||
|
||||
std::vector<uint8_t> data;
|
||||
// Reserve enough space
|
||||
data.reserve(20 + numRecords * recordSize + stringBlockSize);
|
||||
|
||||
// Magic
|
||||
data.push_back('W'); data.push_back('D'); data.push_back('B'); data.push_back('C');
|
||||
|
||||
auto writeU32 = [&](uint32_t v) {
|
||||
data.push_back(static_cast<uint8_t>(v & 0xFF));
|
||||
data.push_back(static_cast<uint8_t>((v >> 8) & 0xFF));
|
||||
data.push_back(static_cast<uint8_t>((v >> 16) & 0xFF));
|
||||
data.push_back(static_cast<uint8_t>((v >> 24) & 0xFF));
|
||||
};
|
||||
|
||||
writeU32(numRecords);
|
||||
writeU32(numFields);
|
||||
writeU32(recordSize);
|
||||
writeU32(stringBlockSize);
|
||||
|
||||
// Records
|
||||
for (const auto& rec : records) {
|
||||
for (uint32_t field : rec) {
|
||||
writeU32(field);
|
||||
}
|
||||
}
|
||||
|
||||
// String block
|
||||
for (char c : stringBlock) {
|
||||
data.push_back(static_cast<uint8_t>(c));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile default state", "[dbc]") {
|
||||
DBCFile dbc;
|
||||
REQUIRE_FALSE(dbc.isLoaded());
|
||||
REQUIRE(dbc.getRecordCount() == 0);
|
||||
REQUIRE(dbc.getFieldCount() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile load valid DBC", "[dbc]") {
|
||||
// 2 records, 3 fields each: [id, intVal, stringOffset]
|
||||
// String block: "\0Hello\0World\0" → offset 0="" 1="Hello" 7="World"
|
||||
std::string strings;
|
||||
strings += '\0'; // offset 0: empty string
|
||||
strings += "Hello";
|
||||
strings += '\0'; // offset 1-6: "Hello"
|
||||
strings += "World";
|
||||
strings += '\0'; // offset 7-12: "World"
|
||||
|
||||
auto data = buildSyntheticDBC(2, 3,
|
||||
{
|
||||
{1, 100, 1}, // Record 0: id=1, intVal=100, stringOffset=1 → "Hello"
|
||||
{2, 200, 7}, // Record 1: id=2, intVal=200, stringOffset=7 → "World"
|
||||
},
|
||||
strings);
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
REQUIRE(dbc.isLoaded());
|
||||
REQUIRE(dbc.getRecordCount() == 2);
|
||||
REQUIRE(dbc.getFieldCount() == 3);
|
||||
REQUIRE(dbc.getRecordSize() == 12);
|
||||
REQUIRE(dbc.getStringBlockSize() == strings.size());
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile getUInt32 and getInt32", "[dbc]") {
|
||||
auto data = buildSyntheticDBC(1, 2,
|
||||
{ {42, 0xFFFFFFFF} },
|
||||
std::string(1, '\0'));
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
|
||||
REQUIRE(dbc.getUInt32(0, 0) == 42);
|
||||
REQUIRE(dbc.getUInt32(0, 1) == 0xFFFFFFFF);
|
||||
REQUIRE(dbc.getInt32(0, 1) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile getFloat", "[dbc]") {
|
||||
float testVal = 3.14f;
|
||||
uint32_t bits;
|
||||
std::memcpy(&bits, &testVal, 4);
|
||||
|
||||
auto data = buildSyntheticDBC(1, 2,
|
||||
{ {1, bits} },
|
||||
std::string(1, '\0'));
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
REQUIRE(dbc.getFloat(0, 1) == Catch::Approx(3.14f));
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile getString", "[dbc]") {
|
||||
std::string strings;
|
||||
strings += '\0';
|
||||
strings += "TestString";
|
||||
strings += '\0';
|
||||
|
||||
auto data = buildSyntheticDBC(1, 2,
|
||||
{ {1, 1} }, // field 1 = string offset 1 → "TestString"
|
||||
strings);
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
REQUIRE(dbc.getString(0, 1) == "TestString");
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile getStringView", "[dbc]") {
|
||||
std::string strings;
|
||||
strings += '\0';
|
||||
strings += "ViewTest";
|
||||
strings += '\0';
|
||||
|
||||
auto data = buildSyntheticDBC(1, 2,
|
||||
{ {1, 1} },
|
||||
strings);
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
REQUIRE(dbc.getStringView(0, 1) == "ViewTest");
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile findRecordById", "[dbc]") {
|
||||
auto data = buildSyntheticDBC(3, 2,
|
||||
{
|
||||
{10, 100},
|
||||
{20, 200},
|
||||
{30, 300},
|
||||
},
|
||||
std::string(1, '\0'));
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
|
||||
REQUIRE(dbc.findRecordById(10) == 0);
|
||||
REQUIRE(dbc.findRecordById(20) == 1);
|
||||
REQUIRE(dbc.findRecordById(30) == 2);
|
||||
REQUIRE(dbc.findRecordById(99) == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile getRecord returns pointer", "[dbc]") {
|
||||
auto data = buildSyntheticDBC(1, 2,
|
||||
{ {0xAB, 0xCD} },
|
||||
std::string(1, '\0'));
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
|
||||
const uint8_t* rec = dbc.getRecord(0);
|
||||
REQUIRE(rec != nullptr);
|
||||
|
||||
// First field should be 0xAB in little-endian
|
||||
uint32_t val;
|
||||
std::memcpy(&val, rec, 4);
|
||||
REQUIRE(val == 0xAB);
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile load too small data", "[dbc]") {
|
||||
std::vector<uint8_t> tiny = {'W', 'D', 'B'};
|
||||
DBCFile dbc;
|
||||
REQUIRE_FALSE(dbc.load(tiny));
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile load wrong magic", "[dbc]") {
|
||||
auto data = buildSyntheticDBC(0, 1, {}, std::string(1, '\0'));
|
||||
// Corrupt magic
|
||||
data[0] = 'X';
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE_FALSE(dbc.load(data));
|
||||
}
|
||||
|
||||
TEST_CASE("DBCFile getStringByOffset", "[dbc]") {
|
||||
std::string strings;
|
||||
strings += '\0';
|
||||
strings += "Offset5"; // This would be at offset 1 actually, let me be precise
|
||||
strings += '\0';
|
||||
|
||||
auto data = buildSyntheticDBC(1, 1,
|
||||
{ {0} },
|
||||
strings);
|
||||
|
||||
DBCFile dbc;
|
||||
REQUIRE(dbc.load(data));
|
||||
REQUIRE(dbc.getStringByOffset(1) == "Offset5");
|
||||
REQUIRE(dbc.getStringByOffset(0).empty());
|
||||
}
|
||||
220
tests/test_entity.cpp
Normal file
220
tests/test_entity.cpp
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
// Phase 0 – Entity, Unit, Player, GameObject, EntityManager tests
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "game/entity.hpp"
|
||||
#include <memory>
|
||||
|
||||
using namespace wowee::game;
|
||||
|
||||
TEST_CASE("Entity default construction", "[entity]") {
|
||||
Entity e;
|
||||
REQUIRE(e.getGuid() == 0);
|
||||
REQUIRE(e.getType() == ObjectType::OBJECT);
|
||||
REQUIRE(e.getX() == 0.0f);
|
||||
REQUIRE(e.getY() == 0.0f);
|
||||
REQUIRE(e.getZ() == 0.0f);
|
||||
REQUIRE(e.getOrientation() == 0.0f);
|
||||
}
|
||||
|
||||
TEST_CASE("Entity GUID constructor", "[entity]") {
|
||||
Entity e(0xDEADBEEF);
|
||||
REQUIRE(e.getGuid() == 0xDEADBEEF);
|
||||
}
|
||||
|
||||
TEST_CASE("Entity position set/get", "[entity]") {
|
||||
Entity e;
|
||||
e.setPosition(1.0f, 2.0f, 3.0f, 1.57f);
|
||||
REQUIRE(e.getX() == Catch::Approx(1.0f));
|
||||
REQUIRE(e.getY() == Catch::Approx(2.0f));
|
||||
REQUIRE(e.getZ() == Catch::Approx(3.0f));
|
||||
REQUIRE(e.getOrientation() == Catch::Approx(1.57f));
|
||||
}
|
||||
|
||||
TEST_CASE("Entity field set/get/has", "[entity]") {
|
||||
Entity e;
|
||||
REQUIRE_FALSE(e.hasField(10));
|
||||
|
||||
e.setField(10, 0xCAFE);
|
||||
REQUIRE(e.hasField(10));
|
||||
REQUIRE(e.getField(10) == 0xCAFE);
|
||||
|
||||
// Overwrite
|
||||
e.setField(10, 0xBEEF);
|
||||
REQUIRE(e.getField(10) == 0xBEEF);
|
||||
|
||||
// Non-existent returns 0
|
||||
REQUIRE(e.getField(999) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Unit construction and type", "[entity]") {
|
||||
Unit u;
|
||||
REQUIRE(u.getType() == ObjectType::UNIT);
|
||||
|
||||
Unit u2(0x123);
|
||||
REQUIRE(u2.getGuid() == 0x123);
|
||||
REQUIRE(u2.getType() == ObjectType::UNIT);
|
||||
}
|
||||
|
||||
TEST_CASE("Unit name", "[entity]") {
|
||||
Unit u;
|
||||
REQUIRE(u.getName().empty());
|
||||
u.setName("Hogger");
|
||||
REQUIRE(u.getName() == "Hogger");
|
||||
}
|
||||
|
||||
TEST_CASE("Unit health", "[entity]") {
|
||||
Unit u;
|
||||
REQUIRE(u.getHealth() == 0);
|
||||
REQUIRE(u.getMaxHealth() == 0);
|
||||
|
||||
u.setHealth(500);
|
||||
u.setMaxHealth(1000);
|
||||
REQUIRE(u.getHealth() == 500);
|
||||
REQUIRE(u.getMaxHealth() == 1000);
|
||||
}
|
||||
|
||||
TEST_CASE("Unit power by type", "[entity]") {
|
||||
Unit u;
|
||||
u.setPowerType(0); // mana
|
||||
u.setPower(200);
|
||||
u.setMaxPower(500);
|
||||
|
||||
REQUIRE(u.getPower() == 200);
|
||||
REQUIRE(u.getMaxPower() == 500);
|
||||
REQUIRE(u.getPowerByType(0) == 200);
|
||||
REQUIRE(u.getMaxPowerByType(0) == 500);
|
||||
|
||||
// Set rage (type 1)
|
||||
u.setPowerByType(1, 50);
|
||||
u.setMaxPowerByType(1, 100);
|
||||
REQUIRE(u.getPowerByType(1) == 50);
|
||||
REQUIRE(u.getMaxPowerByType(1) == 100);
|
||||
|
||||
// Out of bounds clamps
|
||||
REQUIRE(u.getPowerByType(7) == 0);
|
||||
REQUIRE(u.getMaxPowerByType(7) == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Unit level, entry, displayId", "[entity]") {
|
||||
Unit u;
|
||||
REQUIRE(u.getLevel() == 1); // default
|
||||
u.setLevel(80);
|
||||
REQUIRE(u.getLevel() == 80);
|
||||
|
||||
u.setEntry(1234);
|
||||
REQUIRE(u.getEntry() == 1234);
|
||||
|
||||
u.setDisplayId(5678);
|
||||
REQUIRE(u.getDisplayId() == 5678);
|
||||
}
|
||||
|
||||
TEST_CASE("Unit flags", "[entity]") {
|
||||
Unit u;
|
||||
u.setUnitFlags(0x01);
|
||||
REQUIRE(u.getUnitFlags() == 0x01);
|
||||
|
||||
u.setDynamicFlags(0x02);
|
||||
REQUIRE(u.getDynamicFlags() == 0x02);
|
||||
|
||||
u.setNpcFlags(0x04);
|
||||
REQUIRE(u.getNpcFlags() == 0x04);
|
||||
REQUIRE(u.isInteractable());
|
||||
|
||||
u.setNpcFlags(0);
|
||||
REQUIRE_FALSE(u.isInteractable());
|
||||
}
|
||||
|
||||
TEST_CASE("Unit faction and hostility", "[entity]") {
|
||||
Unit u;
|
||||
u.setFactionTemplate(14); // Undercity
|
||||
REQUIRE(u.getFactionTemplate() == 14);
|
||||
|
||||
REQUIRE_FALSE(u.isHostile());
|
||||
u.setHostile(true);
|
||||
REQUIRE(u.isHostile());
|
||||
}
|
||||
|
||||
TEST_CASE("Unit mount display ID", "[entity]") {
|
||||
Unit u;
|
||||
REQUIRE(u.getMountDisplayId() == 0);
|
||||
u.setMountDisplayId(14374);
|
||||
REQUIRE(u.getMountDisplayId() == 14374);
|
||||
}
|
||||
|
||||
TEST_CASE("Player inherits Unit", "[entity]") {
|
||||
Player p(0xABC);
|
||||
REQUIRE(p.getType() == ObjectType::PLAYER);
|
||||
REQUIRE(p.getGuid() == 0xABC);
|
||||
|
||||
// Player inherits Unit name — regression test for the shadowed-field fix
|
||||
p.setName("Arthas");
|
||||
REQUIRE(p.getName() == "Arthas");
|
||||
|
||||
p.setLevel(80);
|
||||
REQUIRE(p.getLevel() == 80);
|
||||
}
|
||||
|
||||
TEST_CASE("GameObject construction", "[entity]") {
|
||||
GameObject go(0x999);
|
||||
REQUIRE(go.getType() == ObjectType::GAMEOBJECT);
|
||||
REQUIRE(go.getGuid() == 0x999);
|
||||
|
||||
go.setName("Mailbox");
|
||||
REQUIRE(go.getName() == "Mailbox");
|
||||
|
||||
go.setEntry(42);
|
||||
REQUIRE(go.getEntry() == 42);
|
||||
|
||||
go.setDisplayId(100);
|
||||
REQUIRE(go.getDisplayId() == 100);
|
||||
}
|
||||
|
||||
TEST_CASE("EntityManager add/get/has/remove", "[entity]") {
|
||||
EntityManager mgr;
|
||||
REQUIRE(mgr.getEntityCount() == 0);
|
||||
|
||||
auto unit = std::make_shared<Unit>(1);
|
||||
unit->setName("TestUnit");
|
||||
mgr.addEntity(1, unit);
|
||||
|
||||
REQUIRE(mgr.getEntityCount() == 1);
|
||||
REQUIRE(mgr.hasEntity(1));
|
||||
REQUIRE_FALSE(mgr.hasEntity(2));
|
||||
|
||||
auto retrieved = mgr.getEntity(1);
|
||||
REQUIRE(retrieved != nullptr);
|
||||
REQUIRE(retrieved->getGuid() == 1);
|
||||
|
||||
mgr.removeEntity(1);
|
||||
REQUIRE_FALSE(mgr.hasEntity(1));
|
||||
REQUIRE(mgr.getEntityCount() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("EntityManager clear", "[entity]") {
|
||||
EntityManager mgr;
|
||||
mgr.addEntity(1, std::make_shared<Entity>(1));
|
||||
mgr.addEntity(2, std::make_shared<Entity>(2));
|
||||
REQUIRE(mgr.getEntityCount() == 2);
|
||||
|
||||
mgr.clear();
|
||||
REQUIRE(mgr.getEntityCount() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("EntityManager null entity rejected", "[entity]") {
|
||||
EntityManager mgr;
|
||||
mgr.addEntity(1, nullptr);
|
||||
// Null should be rejected (logged warning, not stored)
|
||||
REQUIRE(mgr.getEntityCount() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("EntityManager getEntities returns all", "[entity]") {
|
||||
EntityManager mgr;
|
||||
mgr.addEntity(10, std::make_shared<Unit>(10));
|
||||
mgr.addEntity(20, std::make_shared<Player>(20));
|
||||
mgr.addEntity(30, std::make_shared<GameObject>(30));
|
||||
|
||||
const auto& all = mgr.getEntities();
|
||||
REQUIRE(all.size() == 3);
|
||||
REQUIRE(all.count(10) == 1);
|
||||
REQUIRE(all.count(20) == 1);
|
||||
REQUIRE(all.count(30) == 1);
|
||||
}
|
||||
132
tests/test_frustum.cpp
Normal file
132
tests/test_frustum.cpp
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// Phase 0 – Frustum plane extraction and intersection tests
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "rendering/frustum.hpp"
|
||||
|
||||
#include <glm/glm.hpp>
|
||||
#include <glm/gtc/matrix_transform.hpp>
|
||||
|
||||
using wowee::rendering::Frustum;
|
||||
using wowee::rendering::Plane;
|
||||
|
||||
TEST_CASE("Plane distanceToPoint", "[frustum]") {
|
||||
// Plane facing +Y at y=5
|
||||
Plane p(glm::vec3(0.0f, 1.0f, 0.0f), -5.0f);
|
||||
|
||||
// Point at y=10 → distance = 10 + (-5) = 5 (in front)
|
||||
REQUIRE(p.distanceToPoint(glm::vec3(0, 10, 0)) == Catch::Approx(5.0f));
|
||||
|
||||
// Point at y=5 → distance = 0 (on plane)
|
||||
REQUIRE(p.distanceToPoint(glm::vec3(0, 5, 0)) == Catch::Approx(0.0f));
|
||||
|
||||
// Point at y=0 → distance = -5 (behind)
|
||||
REQUIRE(p.distanceToPoint(glm::vec3(0, 0, 0)) == Catch::Approx(-5.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("Frustum extractFromMatrix with identity", "[frustum]") {
|
||||
Frustum f;
|
||||
f.extractFromMatrix(glm::mat4(1.0f));
|
||||
|
||||
// Identity matrix gives clip-space frustum: [-1,1]^3 (or [0,1] for z)
|
||||
// The origin should be inside
|
||||
REQUIRE(f.containsPoint(glm::vec3(0.0f, 0.0f, 0.5f)));
|
||||
}
|
||||
|
||||
TEST_CASE("Frustum containsPoint perspective", "[frustum]") {
|
||||
// Create a typical perspective projection and look-at
|
||||
glm::mat4 proj = glm::perspective(glm::radians(90.0f), 1.0f, 0.1f, 100.0f);
|
||||
glm::mat4 view = glm::lookAt(
|
||||
glm::vec3(0, 0, 0), // eye
|
||||
glm::vec3(0, 0, -1), // center (looking -Z)
|
||||
glm::vec3(0, 1, 0) // up
|
||||
);
|
||||
glm::mat4 vp = proj * view;
|
||||
|
||||
Frustum f;
|
||||
f.extractFromMatrix(vp);
|
||||
|
||||
// Point in front (at -Z)
|
||||
REQUIRE(f.containsPoint(glm::vec3(0, 0, -10)));
|
||||
|
||||
// Point behind camera (at +Z) should be outside
|
||||
REQUIRE_FALSE(f.containsPoint(glm::vec3(0, 0, 10)));
|
||||
|
||||
// Point very far away inside the frustum
|
||||
REQUIRE(f.containsPoint(glm::vec3(0, 0, -50)));
|
||||
|
||||
// Point beyond far plane
|
||||
REQUIRE_FALSE(f.containsPoint(glm::vec3(0, 0, -200)));
|
||||
}
|
||||
|
||||
TEST_CASE("Frustum intersectsSphere", "[frustum]") {
|
||||
glm::mat4 proj = glm::perspective(glm::radians(60.0f), 1.0f, 1.0f, 100.0f);
|
||||
glm::mat4 view = glm::lookAt(
|
||||
glm::vec3(0, 0, 0),
|
||||
glm::vec3(0, 0, -1),
|
||||
glm::vec3(0, 1, 0)
|
||||
);
|
||||
Frustum f;
|
||||
f.extractFromMatrix(proj * view);
|
||||
|
||||
// Sphere clearly inside
|
||||
REQUIRE(f.intersectsSphere(glm::vec3(0, 0, -10), 1.0f));
|
||||
|
||||
// Sphere behind camera
|
||||
REQUIRE_FALSE(f.intersectsSphere(glm::vec3(0, 0, 50), 1.0f));
|
||||
|
||||
// Large sphere that straddles the near plane
|
||||
REQUIRE(f.intersectsSphere(glm::vec3(0, 0, 0), 5.0f));
|
||||
|
||||
// Sphere at edge of frustum — large radius should still intersect
|
||||
REQUIRE(f.intersectsSphere(glm::vec3(0, 0, -105), 10.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("Frustum intersectsAABB", "[frustum]") {
|
||||
glm::mat4 proj = glm::perspective(glm::radians(60.0f), 1.0f, 1.0f, 100.0f);
|
||||
glm::mat4 view = glm::lookAt(
|
||||
glm::vec3(0, 0, 0),
|
||||
glm::vec3(0, 0, -1),
|
||||
glm::vec3(0, 1, 0)
|
||||
);
|
||||
Frustum f;
|
||||
f.extractFromMatrix(proj * view);
|
||||
|
||||
// Box inside frustum
|
||||
REQUIRE(f.intersectsAABB(glm::vec3(-1, -1, -11), glm::vec3(1, 1, -9)));
|
||||
|
||||
// Box behind camera
|
||||
REQUIRE_FALSE(f.intersectsAABB(glm::vec3(-1, -1, 5), glm::vec3(1, 1, 10)));
|
||||
|
||||
// Box beyond far plane
|
||||
REQUIRE_FALSE(f.intersectsAABB(glm::vec3(-1, -1, -200), glm::vec3(1, 1, -150)));
|
||||
|
||||
// Large box straddling near/far
|
||||
REQUIRE(f.intersectsAABB(glm::vec3(-5, -5, -50), glm::vec3(5, 5, 0)));
|
||||
}
|
||||
|
||||
TEST_CASE("Frustum getPlane returns 6 planes", "[frustum]") {
|
||||
glm::mat4 proj = glm::perspective(glm::radians(90.0f), 1.0f, 1.0f, 100.0f);
|
||||
Frustum f;
|
||||
f.extractFromMatrix(proj);
|
||||
|
||||
// Access all 6 planes — should not crash
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
const auto& p = f.getPlane(static_cast<Frustum::Side>(i));
|
||||
// Normal should be a unit vector (after normalization)
|
||||
float len = glm::length(p.normal);
|
||||
REQUIRE(len == Catch::Approx(1.0f).margin(0.01f));
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Frustum box far right is outside", "[frustum]") {
|
||||
glm::mat4 proj = glm::perspective(glm::radians(45.0f), 1.0f, 1.0f, 100.0f);
|
||||
glm::mat4 view = glm::lookAt(
|
||||
glm::vec3(0, 0, 0),
|
||||
glm::vec3(0, 0, -1),
|
||||
glm::vec3(0, 1, 0)
|
||||
);
|
||||
Frustum f;
|
||||
f.extractFromMatrix(proj * view);
|
||||
|
||||
// Box far off to the right — outside the frustum
|
||||
REQUIRE_FALSE(f.intersectsAABB(glm::vec3(200, 0, -10), glm::vec3(201, 1, -9)));
|
||||
}
|
||||
164
tests/test_m2_structs.cpp
Normal file
164
tests/test_m2_structs.cpp
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// Phase 0 – M2 struct layout and field tests (header-only, no loader source)
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "pipeline/m2_loader.hpp"
|
||||
#include <cstring>
|
||||
|
||||
using namespace wowee::pipeline;
|
||||
|
||||
TEST_CASE("M2Sequence fields are default-initialized", "[m2]") {
|
||||
M2Sequence seq{};
|
||||
REQUIRE(seq.id == 0);
|
||||
REQUIRE(seq.duration == 0);
|
||||
REQUIRE(seq.movingSpeed == 0.0f);
|
||||
REQUIRE(seq.flags == 0);
|
||||
REQUIRE(seq.blendTime == 0);
|
||||
REQUIRE(seq.boundRadius == 0.0f);
|
||||
}
|
||||
|
||||
TEST_CASE("M2AnimationTrack hasData", "[m2]") {
|
||||
M2AnimationTrack track;
|
||||
REQUIRE_FALSE(track.hasData());
|
||||
|
||||
track.sequences.push_back({});
|
||||
REQUIRE(track.hasData());
|
||||
}
|
||||
|
||||
TEST_CASE("M2AnimationTrack default interpolation", "[m2]") {
|
||||
M2AnimationTrack track;
|
||||
REQUIRE(track.interpolationType == 0);
|
||||
REQUIRE(track.globalSequence == -1);
|
||||
}
|
||||
|
||||
TEST_CASE("M2Bone parent defaults to root", "[m2]") {
|
||||
M2Bone bone{};
|
||||
bone.parentBone = -1;
|
||||
REQUIRE(bone.parentBone == -1);
|
||||
REQUIRE(bone.keyBoneId == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("M2Vertex layout", "[m2]") {
|
||||
M2Vertex vert{};
|
||||
vert.position = glm::vec3(1.0f, 2.0f, 3.0f);
|
||||
vert.boneWeights[0] = 255;
|
||||
vert.boneWeights[1] = 0;
|
||||
vert.boneWeights[2] = 0;
|
||||
vert.boneWeights[3] = 0;
|
||||
vert.boneIndices[0] = 5;
|
||||
vert.normal = glm::vec3(0.0f, 1.0f, 0.0f);
|
||||
vert.texCoords[0] = glm::vec2(0.5f, 0.5f);
|
||||
|
||||
REQUIRE(vert.position.x == 1.0f);
|
||||
REQUIRE(vert.boneWeights[0] == 255);
|
||||
REQUIRE(vert.boneIndices[0] == 5);
|
||||
REQUIRE(vert.normal.y == 1.0f);
|
||||
REQUIRE(vert.texCoords[0].x == 0.5f);
|
||||
}
|
||||
|
||||
TEST_CASE("M2Texture stores filename", "[m2]") {
|
||||
M2Texture tex{};
|
||||
tex.type = 1;
|
||||
tex.filename = "Creature\\Hogger\\Hogger.blp";
|
||||
REQUIRE(tex.filename == "Creature\\Hogger\\Hogger.blp");
|
||||
}
|
||||
|
||||
TEST_CASE("M2Batch submesh fields", "[m2]") {
|
||||
M2Batch batch{};
|
||||
batch.skinSectionIndex = 3;
|
||||
batch.textureCount = 2;
|
||||
batch.indexStart = 100;
|
||||
batch.indexCount = 300;
|
||||
batch.vertexStart = 0;
|
||||
batch.vertexCount = 150;
|
||||
batch.submeshId = 0;
|
||||
batch.submeshLevel = 0;
|
||||
|
||||
REQUIRE(batch.skinSectionIndex == 3);
|
||||
REQUIRE(batch.textureCount == 2);
|
||||
REQUIRE(batch.indexCount == 300);
|
||||
REQUIRE(batch.vertexCount == 150);
|
||||
}
|
||||
|
||||
TEST_CASE("M2Material blend modes", "[m2]") {
|
||||
M2Material mat{};
|
||||
mat.flags = 0;
|
||||
mat.blendMode = 2; // Alpha blend
|
||||
REQUIRE(mat.blendMode == 2);
|
||||
|
||||
mat.blendMode = 0; // Opaque
|
||||
REQUIRE(mat.blendMode == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("M2Model isValid", "[m2]") {
|
||||
M2Model model{};
|
||||
REQUIRE_FALSE(model.isValid()); // no vertices or indices
|
||||
|
||||
model.vertices.push_back({});
|
||||
REQUIRE_FALSE(model.isValid()); // vertices but no indices
|
||||
|
||||
model.indices.push_back(0);
|
||||
REQUIRE(model.isValid()); // both present
|
||||
}
|
||||
|
||||
TEST_CASE("M2Model bounding box", "[m2]") {
|
||||
M2Model model{};
|
||||
model.boundMin = glm::vec3(-1.0f, -2.0f, -3.0f);
|
||||
model.boundMax = glm::vec3(1.0f, 2.0f, 3.0f);
|
||||
model.boundRadius = 5.0f;
|
||||
|
||||
glm::vec3 center = (model.boundMin + model.boundMax) * 0.5f;
|
||||
REQUIRE(center.x == Catch::Approx(0.0f));
|
||||
REQUIRE(center.y == Catch::Approx(0.0f));
|
||||
REQUIRE(center.z == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("M2ParticleEmitter defaults", "[m2]") {
|
||||
M2ParticleEmitter emitter{};
|
||||
emitter.textureRows = 1;
|
||||
emitter.textureCols = 1;
|
||||
emitter.enabled = true;
|
||||
REQUIRE(emitter.textureRows == 1);
|
||||
REQUIRE(emitter.textureCols == 1);
|
||||
REQUIRE(emitter.enabled);
|
||||
}
|
||||
|
||||
TEST_CASE("M2RibbonEmitter defaults", "[m2]") {
|
||||
M2RibbonEmitter ribbon{};
|
||||
REQUIRE(ribbon.edgesPerSecond == Catch::Approx(15.0f));
|
||||
REQUIRE(ribbon.edgeLifetime == Catch::Approx(0.5f));
|
||||
REQUIRE(ribbon.gravity == Catch::Approx(0.0f));
|
||||
}
|
||||
|
||||
TEST_CASE("M2Attachment position", "[m2]") {
|
||||
M2Attachment att{};
|
||||
att.id = 1; // Right hand
|
||||
att.bone = 42;
|
||||
att.position = glm::vec3(0.1f, 0.2f, 0.3f);
|
||||
|
||||
REQUIRE(att.id == 1);
|
||||
REQUIRE(att.bone == 42);
|
||||
REQUIRE(att.position.z == Catch::Approx(0.3f));
|
||||
}
|
||||
|
||||
TEST_CASE("M2Model collections", "[m2]") {
|
||||
M2Model model{};
|
||||
|
||||
// Bones
|
||||
model.bones.push_back({});
|
||||
model.bones[0].parentBone = -1;
|
||||
model.bones[0].pivot = glm::vec3(0, 0, 0);
|
||||
|
||||
// Sequences
|
||||
model.sequences.push_back({});
|
||||
model.sequences[0].id = 0; // Stand
|
||||
model.sequences[0].duration = 1000;
|
||||
|
||||
// Textures
|
||||
model.textures.push_back({});
|
||||
model.textures[0].type = 0;
|
||||
model.textures[0].filename = "test.blp";
|
||||
|
||||
REQUIRE(model.bones.size() == 1);
|
||||
REQUIRE(model.sequences.size() == 1);
|
||||
REQUIRE(model.textures.size() == 1);
|
||||
REQUIRE(model.sequences[0].duration == 1000);
|
||||
}
|
||||
118
tests/test_opcode_table.cpp
Normal file
118
tests/test_opcode_table.cpp
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// Phase 0 – OpcodeTable load from JSON, toWire/fromWire mapping
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "game/opcode_table.hpp"
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <cstdio>
|
||||
|
||||
using wowee::game::OpcodeTable;
|
||||
using wowee::game::LogicalOpcode;
|
||||
|
||||
// Helper: write a temporary JSON file and return its path.
|
||||
// Uses the executable's directory to avoid permission issues.
|
||||
static std::string writeTempJson(const std::string& content) {
|
||||
auto path = std::filesystem::temp_directory_path() / "wowee_test_opcodes.json";
|
||||
std::ofstream f(path);
|
||||
f << content;
|
||||
f.close();
|
||||
return path.string();
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable loadFromJson basic mapping", "[opcode_table]") {
|
||||
// CMSG_PING and SMSG_PONG are canonical opcodes present in the generated enum.
|
||||
std::string json = R"({
|
||||
"CMSG_PING": "0x1DC",
|
||||
"SMSG_PONG": "0x1DD"
|
||||
})";
|
||||
auto path = writeTempJson(json);
|
||||
|
||||
OpcodeTable table;
|
||||
REQUIRE(table.loadFromJson(path));
|
||||
|
||||
REQUIRE(table.size() == 2);
|
||||
REQUIRE(table.hasOpcode(LogicalOpcode::CMSG_PING));
|
||||
REQUIRE(table.toWire(LogicalOpcode::CMSG_PING) == 0x1DC);
|
||||
REQUIRE(table.toWire(LogicalOpcode::SMSG_PONG) == 0x1DD);
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable fromWire reverse lookup", "[opcode_table]") {
|
||||
std::string json = R"({ "CMSG_PING": "0x1DC" })";
|
||||
auto path = writeTempJson(json);
|
||||
|
||||
OpcodeTable table;
|
||||
table.loadFromJson(path);
|
||||
|
||||
auto result = table.fromWire(0x1DC);
|
||||
REQUIRE(result.has_value());
|
||||
REQUIRE(*result == LogicalOpcode::CMSG_PING);
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable unknown wire returns nullopt", "[opcode_table]") {
|
||||
std::string json = R"({ "CMSG_PING": "0x1DC" })";
|
||||
auto path = writeTempJson(json);
|
||||
|
||||
OpcodeTable table;
|
||||
table.loadFromJson(path);
|
||||
|
||||
auto result = table.fromWire(0x9999);
|
||||
REQUIRE_FALSE(result.has_value());
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable unknown logical returns 0xFFFF", "[opcode_table]") {
|
||||
std::string json = R"({ "CMSG_PING": "0x1DC" })";
|
||||
auto path = writeTempJson(json);
|
||||
|
||||
OpcodeTable table;
|
||||
table.loadFromJson(path);
|
||||
|
||||
// SMSG_AUTH_CHALLENGE should not be in this table
|
||||
REQUIRE(table.toWire(LogicalOpcode::SMSG_AUTH_CHALLENGE) == 0xFFFF);
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable loadFromJson nonexistent file", "[opcode_table]") {
|
||||
OpcodeTable table;
|
||||
REQUIRE_FALSE(table.loadFromJson("/nonexistent/path/opcodes.json"));
|
||||
REQUIRE(table.size() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable logicalToName returns enum name", "[opcode_table]") {
|
||||
const char* name = OpcodeTable::logicalToName(LogicalOpcode::CMSG_PING);
|
||||
REQUIRE(name != nullptr);
|
||||
REQUIRE(std::string(name) == "CMSG_PING");
|
||||
}
|
||||
|
||||
TEST_CASE("OpcodeTable decimal wire values", "[opcode_table]") {
|
||||
std::string json = R"({ "CMSG_PING": "476" })";
|
||||
auto path = writeTempJson(json);
|
||||
|
||||
OpcodeTable table;
|
||||
REQUIRE(table.loadFromJson(path));
|
||||
REQUIRE(table.toWire(LogicalOpcode::CMSG_PING) == 476);
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
|
||||
TEST_CASE("Global active opcode table", "[opcode_table]") {
|
||||
OpcodeTable table;
|
||||
std::string json = R"({ "CMSG_PING": "0x1DC" })";
|
||||
auto path = writeTempJson(json);
|
||||
table.loadFromJson(path);
|
||||
|
||||
wowee::game::setActiveOpcodeTable(&table);
|
||||
REQUIRE(wowee::game::getActiveOpcodeTable() == &table);
|
||||
REQUIRE(wowee::game::wireOpcode(LogicalOpcode::CMSG_PING) == 0x1DC);
|
||||
|
||||
// Reset
|
||||
wowee::game::setActiveOpcodeTable(nullptr);
|
||||
REQUIRE(wowee::game::wireOpcode(LogicalOpcode::CMSG_PING) == 0xFFFF);
|
||||
|
||||
std::remove(path.c_str());
|
||||
}
|
||||
192
tests/test_packet.cpp
Normal file
192
tests/test_packet.cpp
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Phase 0 – Packet read/write round-trip, packed GUID, bounds checks
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "network/packet.hpp"
|
||||
|
||||
using wowee::network::Packet;
|
||||
|
||||
TEST_CASE("Packet default constructor", "[packet]") {
|
||||
Packet p;
|
||||
REQUIRE(p.getOpcode() == 0);
|
||||
REQUIRE(p.getSize() == 0);
|
||||
REQUIRE(p.getReadPos() == 0);
|
||||
REQUIRE(p.getRemainingSize() == 0);
|
||||
REQUIRE_FALSE(p.hasData());
|
||||
}
|
||||
|
||||
TEST_CASE("Packet opcode constructor", "[packet]") {
|
||||
Packet p(0x1DC);
|
||||
REQUIRE(p.getOpcode() == 0x1DC);
|
||||
REQUIRE(p.getSize() == 0);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet write/read UInt8 round-trip", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt8(0);
|
||||
p.writeUInt8(127);
|
||||
p.writeUInt8(255);
|
||||
REQUIRE(p.getSize() == 3);
|
||||
|
||||
REQUIRE(p.readUInt8() == 0);
|
||||
REQUIRE(p.readUInt8() == 127);
|
||||
REQUIRE(p.readUInt8() == 255);
|
||||
REQUIRE_FALSE(p.hasData());
|
||||
}
|
||||
|
||||
TEST_CASE("Packet write/read UInt16 round-trip", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt16(0);
|
||||
p.writeUInt16(0xBEEF);
|
||||
p.writeUInt16(0xFFFF);
|
||||
REQUIRE(p.getSize() == 6);
|
||||
|
||||
REQUIRE(p.readUInt16() == 0);
|
||||
REQUIRE(p.readUInt16() == 0xBEEF);
|
||||
REQUIRE(p.readUInt16() == 0xFFFF);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet write/read UInt32 round-trip", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt32(0xDEADBEEF);
|
||||
REQUIRE(p.readUInt32() == 0xDEADBEEF);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet write/read UInt64 round-trip", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt64(0x0123456789ABCDEFULL);
|
||||
REQUIRE(p.readUInt64() == 0x0123456789ABCDEFULL);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet write/read float round-trip", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeFloat(3.14f);
|
||||
p.writeFloat(-0.0f);
|
||||
p.writeFloat(1e10f);
|
||||
REQUIRE(p.readFloat() == Catch::Approx(3.14f));
|
||||
REQUIRE(p.readFloat() == -0.0f);
|
||||
REQUIRE(p.readFloat() == Catch::Approx(1e10f));
|
||||
}
|
||||
|
||||
TEST_CASE("Packet write/read string round-trip", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeString("Hello WoW");
|
||||
p.writeString(""); // empty string
|
||||
REQUIRE(p.readString() == "Hello WoW");
|
||||
REQUIRE(p.readString() == "");
|
||||
}
|
||||
|
||||
TEST_CASE("Packet writeBytes / readUInt8 array", "[packet]") {
|
||||
Packet p(1);
|
||||
const uint8_t buf[] = {0xAA, 0xBB, 0xCC};
|
||||
p.writeBytes(buf, 3);
|
||||
REQUIRE(p.getSize() == 3);
|
||||
REQUIRE(p.readUInt8() == 0xAA);
|
||||
REQUIRE(p.readUInt8() == 0xBB);
|
||||
REQUIRE(p.readUInt8() == 0xCC);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet packed GUID round-trip", "[packet]") {
|
||||
SECTION("Zero GUID") {
|
||||
Packet p(1);
|
||||
p.writePackedGuid(0);
|
||||
REQUIRE(p.hasFullPackedGuid());
|
||||
REQUIRE(p.readPackedGuid() == 0);
|
||||
}
|
||||
|
||||
SECTION("Low GUID (single byte)") {
|
||||
Packet p(1);
|
||||
p.writePackedGuid(0x42);
|
||||
REQUIRE(p.readPackedGuid() == 0x42);
|
||||
}
|
||||
|
||||
SECTION("Full 64-bit GUID") {
|
||||
Packet p(1);
|
||||
uint64_t guid = 0x0102030405060708ULL;
|
||||
p.writePackedGuid(guid);
|
||||
REQUIRE(p.readPackedGuid() == guid);
|
||||
}
|
||||
|
||||
SECTION("Max GUID") {
|
||||
Packet p(1);
|
||||
uint64_t guid = 0xFFFFFFFFFFFFFFFFULL;
|
||||
p.writePackedGuid(guid);
|
||||
REQUIRE(p.readPackedGuid() == guid);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_CASE("Packet getRemainingSize and hasRemaining", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt32(100);
|
||||
p.writeUInt32(200);
|
||||
REQUIRE(p.getRemainingSize() == 8);
|
||||
REQUIRE(p.hasRemaining(8));
|
||||
REQUIRE_FALSE(p.hasRemaining(9));
|
||||
|
||||
p.readUInt32();
|
||||
REQUIRE(p.getRemainingSize() == 4);
|
||||
REQUIRE(p.hasRemaining(4));
|
||||
REQUIRE_FALSE(p.hasRemaining(5));
|
||||
|
||||
p.readUInt32();
|
||||
REQUIRE(p.getRemainingSize() == 0);
|
||||
REQUIRE(p.hasRemaining(0));
|
||||
REQUIRE_FALSE(p.hasRemaining(1));
|
||||
}
|
||||
|
||||
TEST_CASE("Packet setReadPos and skipAll", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt8(10);
|
||||
p.writeUInt8(20);
|
||||
p.writeUInt8(30);
|
||||
|
||||
p.readUInt8(); // pos = 1
|
||||
p.setReadPos(0);
|
||||
REQUIRE(p.readUInt8() == 10);
|
||||
|
||||
p.skipAll();
|
||||
REQUIRE(p.getRemainingSize() == 0);
|
||||
REQUIRE_FALSE(p.hasData());
|
||||
}
|
||||
|
||||
TEST_CASE("Packet constructed with data vector", "[packet]") {
|
||||
std::vector<uint8_t> raw = {0x01, 0x02, 0x03};
|
||||
Packet p(42, raw);
|
||||
REQUIRE(p.getOpcode() == 42);
|
||||
REQUIRE(p.getSize() == 3);
|
||||
REQUIRE(p.readUInt8() == 0x01);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet constructed with rvalue data", "[packet]") {
|
||||
Packet p(99, std::vector<uint8_t>{0xFF, 0xFE});
|
||||
REQUIRE(p.getSize() == 2);
|
||||
REQUIRE(p.readUInt8() == 0xFF);
|
||||
REQUIRE(p.readUInt8() == 0xFE);
|
||||
}
|
||||
|
||||
TEST_CASE("Packet mixed types interleaved", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt8(0xAA);
|
||||
p.writeUInt32(0xDEADBEEF);
|
||||
p.writeString("test");
|
||||
p.writeFloat(2.5f);
|
||||
p.writeUInt16(0x1234);
|
||||
|
||||
REQUIRE(p.readUInt8() == 0xAA);
|
||||
REQUIRE(p.readUInt32() == 0xDEADBEEF);
|
||||
REQUIRE(p.readString() == "test");
|
||||
REQUIRE(p.readFloat() == Catch::Approx(2.5f));
|
||||
REQUIRE(p.readUInt16() == 0x1234);
|
||||
REQUIRE_FALSE(p.hasData());
|
||||
}
|
||||
|
||||
TEST_CASE("Packet hasFullPackedGuid returns false on empty", "[packet]") {
|
||||
Packet p(1);
|
||||
REQUIRE_FALSE(p.hasFullPackedGuid());
|
||||
}
|
||||
|
||||
TEST_CASE("Packet getRemainingSize clamps after overshoot", "[packet]") {
|
||||
Packet p(1);
|
||||
p.writeUInt8(1);
|
||||
p.setReadPos(999);
|
||||
REQUIRE(p.getRemainingSize() == 0);
|
||||
REQUIRE_FALSE(p.hasRemaining(1));
|
||||
}
|
||||
127
tests/test_srp.cpp
Normal file
127
tests/test_srp.cpp
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
// Phase 0 – SRP6a challenge/proof smoke tests
|
||||
#include <catch_amalgamated.hpp>
|
||||
#include "auth/srp.hpp"
|
||||
#include "auth/crypto.hpp"
|
||||
|
||||
using wowee::auth::SRP;
|
||||
using wowee::auth::Crypto;
|
||||
|
||||
// WoW 3.3.5a uses well-known SRP6a parameters.
|
||||
// Generator g = 7, N = a large 32-byte safe prime.
|
||||
// We use the canonical WoW values for integration-level tests.
|
||||
|
||||
static const std::vector<uint8_t> kWoWGenerator = { 7 };
|
||||
|
||||
// WoW's 32-byte large safe prime (little-endian)
|
||||
static const std::vector<uint8_t> kWoWPrime = {
|
||||
0xB7, 0x9B, 0x3E, 0x2A, 0x87, 0x82, 0x3C, 0xAB,
|
||||
0x8F, 0x5E, 0xBF, 0xBF, 0x8E, 0xB1, 0x01, 0x08,
|
||||
0x53, 0x50, 0x06, 0x29, 0x8B, 0x5B, 0xAD, 0xBD,
|
||||
0x5B, 0x53, 0xE1, 0x89, 0x5E, 0x64, 0x4B, 0x89
|
||||
};
|
||||
|
||||
TEST_CASE("SRP initialize stores credentials", "[srp]") {
|
||||
SRP srp;
|
||||
// Should not throw
|
||||
REQUIRE_NOTHROW(srp.initialize("TEST", "PASSWORD"));
|
||||
}
|
||||
|
||||
TEST_CASE("SRP initializeWithHash accepts pre-computed hash", "[srp]") {
|
||||
// Pre-compute SHA1("TEST:PASSWORD")
|
||||
auto hash = Crypto::sha1(std::string("TEST:PASSWORD"));
|
||||
REQUIRE(hash.size() == 20);
|
||||
|
||||
SRP srp;
|
||||
REQUIRE_NOTHROW(srp.initializeWithHash("TEST", hash));
|
||||
}
|
||||
|
||||
TEST_CASE("SRP feed produces A and M1 of correct sizes", "[srp]") {
|
||||
SRP srp;
|
||||
srp.initialize("TEST", "PASSWORD");
|
||||
|
||||
// Fabricate a server B (32 bytes, non-zero to avoid SRP abort)
|
||||
std::vector<uint8_t> B(32, 0);
|
||||
B[0] = 0x42; // Non-zero
|
||||
|
||||
std::vector<uint8_t> salt(32, 0xAA);
|
||||
|
||||
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
|
||||
|
||||
auto A = srp.getA();
|
||||
auto M1 = srp.getM1();
|
||||
auto K = srp.getSessionKey();
|
||||
|
||||
// A should be 32 bytes (same size as N)
|
||||
REQUIRE(A.size() == 32);
|
||||
// M1 is SHA1 → 20 bytes
|
||||
REQUIRE(M1.size() == 20);
|
||||
// K is the interleaved session key → 40 bytes
|
||||
REQUIRE(K.size() == 40);
|
||||
}
|
||||
|
||||
TEST_CASE("SRP A is non-zero", "[srp]") {
|
||||
SRP srp;
|
||||
srp.initialize("PLAYER", "SECRET");
|
||||
|
||||
std::vector<uint8_t> B(32, 0);
|
||||
B[3] = 0x01;
|
||||
std::vector<uint8_t> salt(32, 0xBB);
|
||||
|
||||
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
|
||||
|
||||
auto A = srp.getA();
|
||||
bool allZero = true;
|
||||
for (auto b : A) {
|
||||
if (b != 0) { allZero = false; break; }
|
||||
}
|
||||
REQUIRE_FALSE(allZero);
|
||||
}
|
||||
|
||||
TEST_CASE("SRP different passwords produce different M1", "[srp]") {
|
||||
auto runSrp = [](const std::string& pass) {
|
||||
SRP srp;
|
||||
srp.initialize("TESTUSER", pass);
|
||||
std::vector<uint8_t> B(32, 0);
|
||||
B[0] = 0x11;
|
||||
std::vector<uint8_t> salt(32, 0xCC);
|
||||
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
|
||||
return srp.getM1();
|
||||
};
|
||||
|
||||
auto m1a = runSrp("PASSWORD1");
|
||||
auto m1b = runSrp("PASSWORD2");
|
||||
REQUIRE(m1a != m1b);
|
||||
}
|
||||
|
||||
TEST_CASE("SRP verifyServerProof rejects wrong proof", "[srp]") {
|
||||
SRP srp;
|
||||
srp.initialize("TEST", "PASSWORD");
|
||||
|
||||
std::vector<uint8_t> B(32, 0);
|
||||
B[0] = 0x55;
|
||||
std::vector<uint8_t> salt(32, 0xDD);
|
||||
|
||||
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
|
||||
|
||||
// Random 20 bytes should not match the expected M2
|
||||
std::vector<uint8_t> fakeM2(20, 0xFF);
|
||||
REQUIRE_FALSE(srp.verifyServerProof(fakeM2));
|
||||
}
|
||||
|
||||
TEST_CASE("SRP setUseHashedK changes behavior", "[srp]") {
|
||||
auto runWithHashedK = [](bool useHashed) {
|
||||
SRP srp;
|
||||
srp.setUseHashedK(useHashed);
|
||||
srp.initialize("TEST", "PASSWORD");
|
||||
std::vector<uint8_t> B(32, 0);
|
||||
B[0] = 0x22;
|
||||
std::vector<uint8_t> salt(32, 0xEE);
|
||||
srp.feed(B, kWoWGenerator, kWoWPrime, salt);
|
||||
return srp.getM1();
|
||||
};
|
||||
|
||||
auto m1_default = runWithHashedK(false);
|
||||
auto m1_hashed = runWithHashedK(true);
|
||||
// Different k derivation → different M1
|
||||
REQUIRE(m1_default != m1_hashed);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue