diff --git a/.gitignore b/.gitignore index b06d404c..1cf93b16 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Build directories build/ +build_asan/ build-debug/ build-sanitize/ bin/ diff --git a/CMakeLists.txt b/CMakeLists.txt index f55bba09..7d308863 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,8 +25,9 @@ endif() # Options option(BUILD_SHARED_LIBS "Build shared libraries" OFF) -option(WOWEE_BUILD_TESTS "Build tests" OFF) +option(WOWEE_BUILD_TESTS "Build tests" ON) option(WOWEE_ENABLE_ASAN "Enable AddressSanitizer (Debug builds)" OFF) +option(WOWEE_ENABLE_TRACY "Enable Tracy profiler instrumentation" OFF) option(WOWEE_ENABLE_AMD_FSR2 "Enable AMD FidelityFX FSR2 backend when SDK is present" ON) option(WOWEE_ENABLE_AMD_FSR3_FRAMEGEN "Enable AMD FidelityFX SDK FSR3 frame generation interface probe when SDK is present" ON) option(WOWEE_BUILD_AMD_FSR3_RUNTIME "Build native AMD FidelityFX VK runtime (Path A) from extern/FidelityFX-SDK/Kits" ON) @@ -788,6 +789,15 @@ endif() # Create executable add_executable(wowee ${WOWEE_SOURCES} ${WOWEE_HEADERS} ${WOWEE_PLATFORM_SOURCES}) + +# Tracy profiler — zero overhead when WOWEE_ENABLE_TRACY is OFF +if(WOWEE_ENABLE_TRACY) + target_sources(wowee PRIVATE ${CMAKE_SOURCE_DIR}/extern/tracy/public/TracyClient.cpp) + target_compile_definitions(wowee PRIVATE TRACY_ENABLE) + target_include_directories(wowee SYSTEM PRIVATE ${CMAKE_SOURCE_DIR}/extern/tracy/public) + message(STATUS "Tracy profiler: ENABLED") +endif() + if(TARGET opcodes-generate) add_dependencies(wowee opcodes-generate) endif() @@ -931,6 +941,12 @@ else() ) endif() +# ── Unit tests (Catch2) ────────────────────────────────────── +if(WOWEE_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + # AddressSanitizer — catch buffer overflows, use-after-free, etc. # Enable with: cmake ... -DWOWEE_ENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug if(WOWEE_ENABLE_ASAN) @@ -942,10 +958,10 @@ if(WOWEE_ENABLE_ASAN) $<$:/MD> ) else() - target_compile_options(wowee PRIVATE -fsanitize=address -fno-omit-frame-pointer) - target_link_options(wowee PRIVATE -fsanitize=address) + target_compile_options(wowee PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(wowee PRIVATE -fsanitize=address,undefined) endif() - message(STATUS "AddressSanitizer: ENABLED") + message(STATUS "AddressSanitizer + UBSan: ENABLED") endif() # Release build optimizations diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000..3846b15d --- /dev/null +++ b/TESTING.md @@ -0,0 +1,390 @@ +# WoWee Testing Guide + +This document covers everything needed to build, run, lint, and extend the WoWee test suite. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [Test Suite Layout](#test-suite-layout) +4. [Building the Tests](#building-the-tests) + - [Release Build (normal)](#release-build-normal) + - [Debug + ASAN/UBSan Build](#debug--asanubsan-build) +5. [Running Tests](#running-tests) + - [test.sh — the unified entry point](#testsh--the-unified-entry-point) + - [Running directly with ctest](#running-directly-with-ctest) +6. [Lint (clang-tidy)](#lint-clang-tidy) + - [Running lint](#running-lint) + - [Applying auto-fixes](#applying-auto-fixes) + - [Configuration (.clang-tidy)](#configuration-clang-tidy) +7. [ASAN / UBSan](#asan--ubsan) +8. [Adding New Tests](#adding-new-tests) +9. [CI Reference](#ci-reference) + +--- + +## Overview + +WoWee uses **Catch2 v3** (amalgamated) for unit testing and **clang-tidy** for static analysis. The `test.sh` script is the single entry point for both. + +| Command | What it does | +|---|---| +| `./test.sh` | Runs both unit tests (Release) and lint | +| `./test.sh --test` | Runs unit tests only (Release build) | +| `./test.sh --lint` | Runs clang-tidy only | +| `./test.sh --asan` | Runs unit tests under ASAN + UBSan (Debug build) | +| `FIX=1 ./test.sh --lint` | Applies clang-tidy auto-fixes in-place | + +All commands exit non-zero on any failure. + +--- + +## Prerequisites + +The test suite requires the same base toolchain used to build the project. See [BUILD_INSTRUCTIONS.md](BUILD_INSTRUCTIONS.md) for platform-specific dependency installation. + +### Linux (Ubuntu / Debian) + +```bash +sudo apt update +sudo apt install -y \ + build-essential cmake pkg-config git \ + libssl-dev \ + clang-tidy +``` + +### Linux (Arch) + +```bash +sudo pacman -S --needed base-devel cmake pkgconf git openssl clang +``` + +### macOS + +```bash +brew install cmake openssl@3 llvm +# Add LLVM tools to PATH so clang-tidy is found: +export PATH="$(brew --prefix llvm)/bin:$PATH" +``` + +### Windows (MSYS2) + +Install the full toolchain as described in `BUILD_INSTRUCTIONS.md`, then add: + +```bash +pacman -S --needed mingw-w64-x86_64-clang-tools-extra +``` + +--- + +## Test Suite Layout + +``` +tests/ + CMakeLists.txt — CMake test configuration + test_packet.cpp — Network packet encode/decode + test_srp.cpp — SRP-6a authentication math (requires OpenSSL) + test_opcode_table.cpp — Opcode registry lookup + test_entity.cpp — ECS entity basics + test_dbc_loader.cpp — DBC binary file parsing + test_m2_structs.cpp — M2 model struct layout / alignment + test_blp_loader.cpp — BLP texture file parsing + test_frustum.cpp — View-frustum culling math +``` + +The Catch2 v3 amalgamated source lives at: + +``` +extern/catch2/ + catch_amalgamated.hpp + catch_amalgamated.cpp +``` + +--- + +## Building the Tests + +Tests are _not_ built by default. Enable them with `-DWOWEE_BUILD_TESTS=ON`. + +### Release Build (normal) + +> **Note:** Per project rules, always use `rebuild.sh` for a full clean build. Direct `cmake --build` is fine for test-only incremental builds. + +```bash +# Configure (only needed once) +cmake -B build -DCMAKE_BUILD_TYPE=Release -DWOWEE_BUILD_TESTS=ON + +# Build test targets (fast — only compiles tests and Catch2) +cmake --build build --target \ + test_packet test_srp test_opcode_table test_entity \ + test_dbc_loader test_m2_structs test_blp_loader test_frustum +``` + +Or simply run a full rebuild (builds everything including the main binary): + +```bash +./rebuild.sh # ~10 minutes — see BUILD_INSTRUCTIONS.md +``` + +### Debug + ASAN/UBSan Build + +A separate CMake build directory is used so ASAN flags do not pollute the Release binary. + +```bash +cmake -B build_asan \ + -DCMAKE_BUILD_TYPE=Debug \ + -DWOWEE_ENABLE_ASAN=ON \ + -DWOWEE_BUILD_TESTS=ON + +cmake --build build_asan --target \ + test_packet test_srp test_opcode_table test_entity \ + test_dbc_loader test_m2_structs test_blp_loader test_frustum +``` + +CMake will print: `Test targets: ASAN + UBSan ENABLED` when configured correctly. + +--- + +## Running Tests + +### test.sh — the unified entry point + +`test.sh` is the recommended way to run tests and/or lint. It handles build-directory discovery, dependency checking, and exit-code aggregation across both steps. + +```bash +# Run everything (tests + lint) — default when no flags are given +./test.sh + +# Tests only (Release build) +./test.sh --test + +# Tests only under ASAN+UBSan (Debug build — requires build_asan/) +./test.sh --asan + +# Lint only +./test.sh --lint + +# Both tests and lint explicitly +./test.sh --test --lint + +# Usage summary +./test.sh --help +``` + +**Exit codes:** + +| Outcome | Exit code | +|---|---| +| All tests passed, lint clean | `0` | +| Any test failed | `1` | +| Any lint diagnostic | `1` | +| Both test failure and lint issues | `1` | + +### Running directly with ctest + +```bash +# Release build +cd build +ctest --output-on-failure + +# ASAN build +cd build_asan +ctest --output-on-failure + +# Run one specific test suite by name +ctest --output-on-failure -R srp + +# Verbose output (shows every SECTION and REQUIRE) +ctest --output-on-failure -V +``` + +You can also run a test binary directly for detailed Catch2 output: + +```bash +./build/bin/test_srp +./build/bin/test_srp --reporter console +./build/bin/test_srp "[authentication]" # run only tests tagged [authentication] +``` + +--- + +## Lint (clang-tidy) + +The project uses clang-tidy to enforce C++20 best practices on all first-party sources under `src/`. Third-party code (anything in `extern/`) and generated files are excluded. + +### Running lint + +```bash +./test.sh --lint +``` + +Under the hood the script: + +1. Locates `clang-tidy` (tries versions 14–18, then `clang-tidy`). +2. Uses `run-clang-tidy` for parallel execution when available; falls back to sequential. +3. Reads `build/compile_commands.json` (generated by CMake) for compiler flags. +4. Feeds GCC stdlib include paths as `-isystem` extras so clang-tidy can resolve ``, ``, etc. when the compile-commands were generated with GCC. + +`compile_commands.json` is regenerated automatically by any CMake configure step. If you only want to update it without rebuilding: + +```bash +cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +``` + +### Applying auto-fixes + +Some clang-tidy checks can apply fixes automatically (e.g. `modernize-*`, `readability-*`): + +```bash +FIX=1 ./test.sh --lint +``` + +> **Caution:** Review the diff before committing — automatic fixes occasionally produce non-idiomatic results in complex template code. + +### Configuration (.clang-tidy) + +The active check set is defined in [.clang-tidy](.clang-tidy) at the repository root. + +**Enabled check categories:** + +| Category | What it catches | +|---|---| +| `bugprone-*` | Common bug patterns (signed overflow, misplaced `=`, etc.) | +| `clang-analyzer-*` | Deep flow-analysis: null dereferences, memory leaks, dead stores | +| `performance-*` | Unnecessary copies, inefficient STL usage | +| `modernize-*` (subset) | Pre-C++11 patterns that should use modern equivalents | +| `readability-*` (subset) | Control-flow simplification, redundant code | + +**Notable suppressions** (see `.clang-tidy` for details): + +| Suppressed check | Reason | +|---|---| +| `bugprone-easily-swappable-parameters` | High false-positive rate in graphics/math APIs | +| `clang-analyzer-security.insecureAPI.DeprecatedOrUnsafeBufferHandling` | Intentional low-level buffer code in rendering | +| `performance-avoid-endl` | `std::endl` is used intentionally for logger flushing | + +To suppress a specific warning inline, use: + +```cpp +// NOLINT(bugprone-narrowing-conversions) +uint8_t byte = static_cast(value); // NOLINT +``` + +--- + +## ASAN / UBSan + +AddressSanitizer (ASAN) and Undefined Behaviour Sanitizer (UBSan) are applied to all test targets when `WOWEE_ENABLE_ASAN=ON`. + +Both the test executables **and** the `catch2_main` static library are recompiled with: + +``` +-fsanitize=address,undefined -fno-omit-frame-pointer +``` + +This means any heap overflow, stack buffer overflow, use-after-free, null dereference, signed integer overflow, or misaligned access detected during a test will abort the process and print a human-readable report to stderr. + +### Workflow + +```bash +# 1. Configure once (only needs to be re-run when CMakeLists.txt changes) +cmake -B build_asan \ + -DCMAKE_BUILD_TYPE=Debug \ + -DWOWEE_ENABLE_ASAN=ON \ + -DWOWEE_BUILD_TESTS=ON + +# 2. Build test binaries (fast incremental after the first build) +cmake --build build_asan --target test_packet test_srp # etc. + +# 3. Run +./test.sh --asan +``` + +### Interpreting ASAN output + +A failing ASAN report looks like: + +``` +==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000010 +READ of size 4 at 0x602000000010 thread T0 + #0 0x... in PacketBuffer::read_uint32 src/network/packet.cpp:42 + #1 0x... in test_packet tests/test_packet.cpp:88 +``` + +Address the issue in the source file and re-run. Do **not** suppress ASAN reports without a code fix. + +--- + +## Adding New Tests + +1. **Create** `tests/test_.cpp` with a standard Catch2 v3 structure: + +```cpp +#include "catch_amalgamated.hpp" + +TEST_CASE("SomeFeature does X", "[tag]") { + REQUIRE(1 + 1 == 2); +} +``` + +2. **Register** the test in `tests/CMakeLists.txt` following the existing pattern: + +```cmake +# ── test_ ────────────────────────────────────────────── +add_executable(test_ + test_.cpp + ${TEST_COMMON_SOURCES} + ${CMAKE_SOURCE_DIR}/src//.cpp # source under test +) +target_include_directories(test_ PRIVATE ${TEST_INCLUDE_DIRS}) +target_include_directories(test_ SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) +target_link_libraries(test_ PRIVATE catch2_main) +add_test(NAME COMMAND test_) +register_test_target(test_) # required — enables ASAN propagation +``` + +3. **Build** and verify: + +```bash +cmake --build build --target test_ +./test.sh --test +``` + +The `register_test_target()` macro call is **mandatory** — without it the new test will not receive ASAN/UBSan flags when `WOWEE_ENABLE_ASAN=ON`. + +--- + +## CI Reference + +The following commands map to typical CI jobs: + +| Job | Command | +|---|---| +| Unit tests (Release) | `./test.sh --test` | +| Unit tests (ASAN+UBSan) | `./test.sh --asan` | +| Lint | `./test.sh --lint` | +| Full check (tests + lint) | `./test.sh` | + +**Configuring the ASAN job in CI:** + +```yaml +- name: Configure ASAN build + run: | + cmake -B build_asan \ + -DCMAKE_BUILD_TYPE=Debug \ + -DWOWEE_ENABLE_ASAN=ON \ + -DWOWEE_BUILD_TESTS=ON + +- name: Build test targets + run: | + cmake --build build_asan --target \ + test_packet test_srp test_opcode_table test_entity \ + test_dbc_loader test_m2_structs test_blp_loader test_frustum + +- name: Run ASAN tests + run: ./test.sh --asan +``` + +> See [BUILD_INSTRUCTIONS.md](BUILD_INSTRUCTIONS.md) for full platform dependency installation steps required before any CI job. diff --git a/docs/perf_baseline.md b/docs/perf_baseline.md new file mode 100644 index 00000000..88bdc743 --- /dev/null +++ b/docs/perf_baseline.md @@ -0,0 +1,79 @@ +# Performance Baseline — WoWee + +> Phase 0.3 deliverable. Measurements taken before any optimization work. +> Re-run after each phase to quantify improvement. + +## Tracy Profiler Integration + +Tracy v0.11.1 integrated under `WOWEE_ENABLE_TRACY` CMake option (default: OFF). +When enabled, zero-cost zone markers instrument the following critical paths: + +### Instrumented Zones + +| Zone Name | File | Purpose | +|-----------|------|---------| +| `Application::run` | src/core/application.cpp | Main loop entry | +| `Application::update` | src/core/application.cpp | Per-frame game logic | +| `Renderer::beginFrame` | src/rendering/renderer.cpp | Vulkan frame begin | +| `Renderer::endFrame` | src/rendering/renderer.cpp | Post-process + present | +| `Renderer::update` | src/rendering/renderer.cpp | Renderer per-frame update | +| `Renderer::renderWorld` | src/rendering/renderer.cpp | Main world draw call | +| `Renderer::renderShadowPass` | src/rendering/renderer.cpp | Shadow depth pass | +| `PostProcess::execute` | src/rendering/post_process_pipeline.cpp | FSR/FXAA post-process | +| `M2::computeBoneMatrices` | src/rendering/m2_renderer.cpp | CPU skeletal animation | +| `M2Renderer::update` | src/rendering/m2_renderer.cpp | M2 instance update + culling | +| `TerrainManager::update` | src/rendering/terrain_manager.cpp | Terrain streaming logic | +| `TerrainManager::processReadyTiles` | src/rendering/terrain_manager.cpp | GPU tile uploads | +| `ADTLoader::load` | src/pipeline/adt_loader.cpp | ADT binary parsing | +| `AssetManager::loadTexture` | src/pipeline/asset_manager.cpp | BLP texture loading | +| `AssetManager::loadDBC` | src/pipeline/asset_manager.cpp | DBC data file loading | +| `WorldSocket::update` | src/network/world_socket.cpp | Network packet dispatch | + +`FrameMark` placed at frame boundary in Application::update to track FPS. + +### How to Profile + +```bash +# Build with Tracy enabled +mkdir -p build_tracy && cd build_tracy +cmake .. -DCMAKE_BUILD_TYPE=RelWithDebInfo -DWOWEE_ENABLE_TRACY=ON +cmake --build . --parallel $(nproc) + +# Run the client — Tracy will broadcast on default port (8086) +cd bin && ./wowee + +# Connect with Tracy profiler GUI (separate download from https://github.com/wolfpld/tracy/releases) +# Or capture from CLI: tracy-capture -o trace.tracy +``` + +## Baseline Scenarios + +> **TODO:** Record measurements once profiler is connected to a running instance. +> Each scenario should record: avg FPS, frame time (p50/p95/p99), and per-zone timings. + +### Scenario 1: Stormwind (Heavy M2/WMO) +- **Location:** Stormwind City center +- **Load:** Dense M2 models (NPCs, doodads), multiple WMO interiors +- **Avg FPS:** _pending_ +- **Frame time (p50/p95/p99):** _pending_ +- **Top zones:** _pending_ + +### Scenario 2: The Barrens (Heavy Terrain) +- **Location:** Central Barrens +- **Load:** Many terrain tiles loaded, sparse M2, large draw distance +- **Avg FPS:** _pending_ +- **Frame time (p50/p95/p99):** _pending_ +- **Top zones:** _pending_ + +### Scenario 3: Dungeon Instance (WMO-only) +- **Location:** Any dungeon instance (e.g., Deadmines entrance) +- **Load:** WMO interior rendering, no terrain +- **Avg FPS:** _pending_ +- **Frame time (p50/p95/p99):** _pending_ +- **Top zones:** _pending_ + +## Notes + +- When `WOWEE_ENABLE_TRACY` is OFF (default), all `ZoneScopedN` / `FrameMark` macros expand to nothing — zero runtime overhead. +- Tracy requires a network connection to capture traces. Run the Tracy profiler GUI or `tracy-capture` CLI alongside the client. +- Debug builds are significantly slower due to -Og and no LTO; use RelWithDebInfo for representative measurements. diff --git a/include/core/profiler.hpp b/include/core/profiler.hpp new file mode 100644 index 00000000..7b1b4745 --- /dev/null +++ b/include/core/profiler.hpp @@ -0,0 +1,19 @@ +// Thin wrapper around Tracy profiler. +// When TRACY_ENABLE is not defined, all macros expand to nothing (zero overhead). +#pragma once + +#ifdef TRACY_ENABLE +#include +#else +// No-op replacements when Tracy is disabled. +#define ZoneScoped +#define ZoneScopedN(x) +#define ZoneScopedC(x) +#define ZoneScopedNC(x, y) +#define FrameMark +#define FrameMarkNamed(x) +#define FrameMarkStart(x) +#define FrameMarkEnd(x) +#define TracyPlot(x, y) +#define TracyMessageL(x) +#endif diff --git a/src/core/application.cpp b/src/core/application.cpp index 3e17ab22..1a16e5ce 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1,5 +1,6 @@ #include "core/application.hpp" #include "core/coordinates.hpp" +#include "core/profiler.hpp" #include #include #include @@ -569,6 +570,7 @@ bool Application::initialize() { } void Application::run() { + ZoneScopedN("Application::run"); LOG_INFO("Starting main loop"); // Pin main thread to a dedicated CPU core to reduce scheduling jitter @@ -759,6 +761,7 @@ void Application::run() { // Update application state try { + FrameMark; update(deltaTime); } catch (const std::bad_alloc& e) { LOG_ERROR("OOM during Application::update (state=", static_cast(state), @@ -1110,6 +1113,7 @@ void Application::logoutToLogin() { } void Application::update(float deltaTime) { + ZoneScopedN("Application::update"); const char* updateCheckpoint = "enter"; try { // Update based on current state diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 6735d0cd..bc674daf 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -5304,40 +5304,9 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { // --------------------------------------------------------------------------- // LFG / Dungeon Finder handlers (WotLK 3.3.5a) // --------------------------------------------------------------------------- - -static const char* lfgJoinResultString(uint8_t result) { - switch (result) { - case 0: return nullptr; // success - case 1: return "Role check failed."; - case 2: return "No LFG slots available for your group."; - case 3: return "No LFG object found."; - case 4: return "No slots available (player)."; - case 5: return "No slots available (party)."; - case 6: return "Dungeon requirements not met by all members."; - case 7: return "Party members are from different realms."; - case 8: return "Not all members are present."; - case 9: return "Get info timeout."; - case 10: return "Invalid dungeon slot."; - case 11: return "You are marked as a deserter."; - case 12: return "A party member is marked as a deserter."; - case 13: return "You are on a random dungeon cooldown."; - case 14: return "A party member is on a random dungeon cooldown."; - case 16: return "No spec/role available."; - default: return "Cannot join dungeon finder."; - } -} - -static const char* lfgTeleportDeniedString(uint8_t reason) { - switch (reason) { - case 0: return "You are not in a LFG group."; - case 1: return "You are not in the dungeon."; - case 2: return "You have a summon pending."; - case 3: return "You are dead."; - case 4: return "You have Deserter."; - case 5: return "You do not meet the requirements."; - default: return "Teleport to dungeon denied."; - } -} +// NOTE: lfgJoinResultString() and lfgTeleportDeniedString() live in +// social_handler.cpp where they are actually called. +// --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // LFG outgoing packets diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index ca34cf11..a3cc56b9 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -4,6 +4,7 @@ #include "game/opcode_table.hpp" #include "auth/crypto.hpp" #include "core/logger.hpp" +#include "core/profiler.hpp" #include #include #include @@ -464,6 +465,7 @@ void WorldSocket::send(const Packet& packet) { } void WorldSocket::update() { + ZoneScopedN("WorldSocket::update"); if (!useAsyncPump_) { pumpNetworkIO(); } diff --git a/src/pipeline/adt_loader.cpp b/src/pipeline/adt_loader.cpp index c629606b..12154407 100644 --- a/src/pipeline/adt_loader.cpp +++ b/src/pipeline/adt_loader.cpp @@ -1,5 +1,6 @@ #include "pipeline/adt_loader.hpp" #include "core/logger.hpp" +#include "core/profiler.hpp" #include #include #include @@ -28,6 +29,7 @@ float HeightMap::getHeight(int x, int y) const { // ADTLoader implementation ADTTerrain ADTLoader::load(const std::vector& adtData) { + ZoneScopedN("ADTLoader::load"); ADTTerrain terrain; if (adtData.empty()) { diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 017b0ff6..8429bb5b 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -1,6 +1,7 @@ #include "pipeline/asset_manager.hpp" #include "core/logger.hpp" #include "core/memory_monitor.hpp" +#include "core/profiler.hpp" #include #include #include @@ -166,6 +167,7 @@ void AssetManager::setBaseFallbackPath(const std::string& basePath) { } BLPImage AssetManager::loadTexture(const std::string& path) { + ZoneScopedN("AssetManager::loadTexture"); if (!initialized) { LOG_ERROR("AssetManager not initialized"); return BLPImage(); @@ -265,6 +267,7 @@ void AssetManager::setExpansionDataPath(const std::string& path) { } std::shared_ptr AssetManager::loadDBC(const std::string& name) { + ZoneScopedN("AssetManager::loadDBC"); if (!initialized) { LOG_ERROR("AssetManager not initialized"); return nullptr; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index f103e1ea..a5d8f546 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -12,6 +12,7 @@ #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" +#include "core/profiler.hpp" #include #include #include @@ -1866,6 +1867,7 @@ static glm::quat interpQuat(const pipeline::M2AnimationTrack& track, } static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { + ZoneScopedN("M2::computeBoneMatrices"); size_t numBones = std::min(model.bones.size(), size_t(128)); if (numBones == 0) return; instance.boneMatrices.resize(numBones); @@ -1898,6 +1900,7 @@ static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { } void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { + ZoneScopedN("M2Renderer::update"); if (spatialIndexDirty_) { rebuildSpatialIndex(); } diff --git a/src/rendering/post_process_pipeline.cpp b/src/rendering/post_process_pipeline.cpp index fd0c49f0..c4522b9d 100644 --- a/src/rendering/post_process_pipeline.cpp +++ b/src/rendering/post_process_pipeline.cpp @@ -8,6 +8,7 @@ #include "rendering/camera.hpp" #include "rendering/amd_fsr3_runtime.hpp" #include "core/logger.hpp" +#include "core/profiler.hpp" #include #include #include @@ -152,6 +153,7 @@ bool PostProcessPipeline::hasActivePostProcess() const { bool PostProcessPipeline::executePostProcessing(VkCommandBuffer cmd, uint32_t imageIndex, Camera* camera, float deltaTime) { + ZoneScopedN("PostProcess::execute"); currentCmd_ = cmd; camera_ = camera; lastDeltaTime_ = deltaTime; diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index ddd39746..74ff2194 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -13,6 +13,7 @@ #include "rendering/weather.hpp" #include "rendering/lightning.hpp" #include "rendering/lighting_manager.hpp" +#include "core/profiler.hpp" #include "rendering/sky_system.hpp" #include "rendering/swim_effects.hpp" #include "rendering/mount_dust.hpp" @@ -797,6 +798,7 @@ void Renderer::applyMsaaChange() { } void Renderer::beginFrame() { + ZoneScopedN("Renderer::beginFrame"); if (!vkCtx) return; if (vkCtx->isDeviceLost()) return; @@ -924,6 +926,7 @@ void Renderer::beginFrame() { } void Renderer::endFrame() { + ZoneScopedN("Renderer::endFrame"); if (!vkCtx || currentCmd == VK_NULL_HANDLE) return; // Track whether a post-processing path switched to an INLINE render pass. @@ -1190,6 +1193,7 @@ bool Renderer::isMoving() const { } void Renderer::update(float deltaTime) { + ZoneScopedN("Renderer::update"); globalTime += deltaTime; if (musicSwitchCooldown_ > 0.0f) { musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime); @@ -1975,6 +1979,7 @@ float Renderer::getBrightness() const { } void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { + ZoneScopedN("Renderer::renderWorld"); (void)world; // Guard against null command buffer (e.g. after VK_ERROR_DEVICE_LOST) @@ -3042,6 +3047,7 @@ void Renderer::renderReflectionPass() { } void Renderer::renderShadowPass() { + ZoneScopedN("Renderer::renderShadowPass"); static const bool skipShadows = (std::getenv("WOWEE_SKIP_SHADOWS") != nullptr); if (skipShadows) return; if (!shadowsEnabled || shadowDepthImage[0] == VK_NULL_HANDLE) return; diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index a0944bb0..2639a1b1 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -8,6 +8,7 @@ #include "audio/ambient_sound_manager.hpp" #include "core/coordinates.hpp" #include "core/memory_monitor.hpp" +#include "core/profiler.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/adt_loader.hpp" #include "pipeline/m2_loader.hpp" @@ -188,6 +189,7 @@ bool TerrainManager::initialize(pipeline::AssetManager* assets, TerrainRenderer* } void TerrainManager::update(const Camera& camera, float deltaTime) { + ZoneScopedN("TerrainManager::update"); if (!streamingEnabled || !assetManager || !terrainRenderer) { return; } @@ -1236,6 +1238,7 @@ void TerrainManager::workerLoop() { } void TerrainManager::processReadyTiles() { + ZoneScopedN("TerrainManager::processReadyTiles"); // Move newly ready tiles into the finalizing deque. // Keep them in pendingTiles so streamTiles() won't re-enqueue them. { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f4cbb833..779510ec 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -140,50 +140,8 @@ namespace { return "Unknown"; } - // Collect all non-comment, non-empty lines from a macro body. - std::vector allMacroCommands(const std::string& macroText) { - std::vector cmds; - size_t pos = 0; - while (pos <= macroText.size()) { - size_t nl = macroText.find('\n', pos); - std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); - if (!line.empty() && line.back() == '\r') line.pop_back(); - size_t start = line.find_first_not_of(" \t"); - if (start != std::string::npos) line = line.substr(start); - if (!line.empty() && line.front() != '#') - cmds.push_back(std::move(line)); - if (nl == std::string::npos) break; - pos = nl + 1; - } - return cmds; - } - - // Returns the #showtooltip argument from a macro body. - std::string getMacroShowtooltipArg(const std::string& macroText) { - size_t pos = 0; - while (pos <= macroText.size()) { - size_t nl = macroText.find('\n', pos); - std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); - if (!line.empty() && line.back() == '\r') line.pop_back(); - size_t fs = line.find_first_not_of(" \t"); - if (fs != std::string::npos) line = line.substr(fs); - if (line.rfind("#showtooltip", 0) == 0 || line.rfind("#show", 0) == 0) { - size_t sp = line.find(' '); - if (sp != std::string::npos) { - std::string arg = line.substr(sp + 1); - size_t as = arg.find_first_not_of(" \t"); - if (as != std::string::npos) arg = arg.substr(as); - size_t ae = arg.find_last_not_of(" \t"); - if (ae != std::string::npos) arg.resize(ae + 1); - if (!arg.empty()) return arg; - } - return "__auto__"; - } - if (nl == std::string::npos) break; - pos = nl + 1; - } - return {}; - } + // NOTE: allMacroCommands() and getMacroShowtooltipArg() live in + // action_bar_panel.cpp / chat_panel.cpp where they are actually used. } namespace wowee { namespace ui { diff --git a/test.sh b/test.sh index 7b69156b..e8d8b536 100755 --- a/test.sh +++ b/test.sh @@ -1,11 +1,22 @@ #!/usr/bin/env bash -# test.sh — Run the C++ linter (clang-tidy) against all first-party sources. +# test.sh — Run the Catch2 unit tests and/or clang-tidy linter. # # Usage: -# ./test.sh # lint src/ and include/ using build/compile_commands.json -# FIX=1 ./test.sh # apply suggested fixes automatically (use with care) +# ./test.sh # run both lint and tests (default) +# ./test.sh --lint # run clang-tidy only +# ./test.sh --test # run ctest unit tests only +# ./test.sh --lint --test # explicit: run both +# ./test.sh --asan # run ctest under ASAN/UBSan (requires build_asan/) +# ./test.sh --test --asan # same as above +# FIX=1 ./test.sh --lint # apply clang-tidy fix suggestions (use with care) # -# Exit code is non-zero if any clang-tidy diagnostic is emitted. +# Exit code is non-zero if any lint diagnostic or test failure is reported. +# +# Build directories: +# build/ — Release build used for normal ctest (cmake -DCMAKE_BUILD_TYPE=Release) +# build_asan/ — Debug+ASAN build used with --asan (cmake -DCMAKE_BUILD_TYPE=Debug +# -DWOWEE_ENABLE_ASAN=ON +# -DWOWEE_BUILD_TESTS=ON) set -euo pipefail @@ -13,8 +24,88 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # --------------------------------------------------------------------------- -# Dependency check +# Argument parsing # --------------------------------------------------------------------------- +RUN_LINT=0 +RUN_TEST=0 +RUN_ASAN=0 + +for arg in "$@"; do + case "$arg" in + --lint) RUN_LINT=1 ;; + --test) RUN_TEST=1 ;; + --asan) RUN_ASAN=1; RUN_TEST=1 ;; + --help|-h) + sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "Unknown argument: $arg" + echo "Usage: $0 [--lint] [--test] [--asan]" + exit 1 + ;; + esac +done + +# Default: run both when no flags provided +if [[ $RUN_LINT -eq 0 && $RUN_TEST -eq 0 ]]; then + RUN_LINT=1 + RUN_TEST=1 +fi + +# --------------------------------------------------------------------------- +# ── UNIT TESTS ───────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +OVERALL_FAILED=0 + +if [[ $RUN_TEST -eq 1 ]]; then + if [[ $RUN_ASAN -eq 1 ]]; then + BUILD_TEST_DIR="$SCRIPT_DIR/build_asan" + TEST_LABEL="ASAN+UBSan" + else + BUILD_TEST_DIR="$SCRIPT_DIR/build" + TEST_LABEL="Release" + fi + + if [[ ! -d "$BUILD_TEST_DIR" ]]; then + echo "Build directory not found: $BUILD_TEST_DIR" + if [[ $RUN_ASAN -eq 1 ]]; then + echo "Configure it with:" + echo " cmake -B build_asan -DCMAKE_BUILD_TYPE=Debug -DWOWEE_ENABLE_ASAN=ON -DWOWEE_BUILD_TESTS=ON" + else + echo "Run cmake first: cmake -B build -DCMAKE_BUILD_TYPE=Release -DWOWEE_BUILD_TESTS=ON" + fi + exit 1 + fi + + # Check that CTestTestfile.cmake exists (tests were configured) + if [[ ! -f "$BUILD_TEST_DIR/CTestTestfile.cmake" ]]; then + echo "CTestTestfile.cmake not found in $BUILD_TEST_DIR — tests not configured." + echo "Re-run cmake with -DWOWEE_BUILD_TESTS=ON" + exit 1 + fi + + echo "──────────────────────────────────────────────" + echo " Running unit tests [$TEST_LABEL]" + echo "──────────────────────────────────────────────" + if ! (cd "$BUILD_TEST_DIR" && ctest --output-on-failure); then + OVERALL_FAILED=1 + echo "" + echo "One or more unit tests FAILED." + else + echo "" + echo "All unit tests passed." + fi +fi + +# --------------------------------------------------------------------------- +# ── LINT ─────────────────────────────────────────────────────────────────── +# --------------------------------------------------------------------------- +if [[ $RUN_LINT -eq 0 ]]; then + exit $OVERALL_FAILED +fi + +# Dependency check CLANG_TIDY="" for candidate in clang-tidy clang-tidy-18 clang-tidy-17 clang-tidy-16 clang-tidy-15 clang-tidy-14; do if command -v "$candidate" >/dev/null 2>&1; then @@ -29,6 +120,9 @@ if [[ -z "$CLANG_TIDY" ]]; then exit 1 fi +echo "──────────────────────────────────────────────" +echo " Running clang-tidy lint" +echo "──────────────────────────────────────────────" echo "Using: $($CLANG_TIDY --version | head -1)" # run-clang-tidy runs checks in parallel; fall back to sequential if absent. @@ -40,9 +134,7 @@ for candidate in run-clang-tidy run-clang-tidy-18 run-clang-tidy-17 run-clang-ti fi done -# --------------------------------------------------------------------------- # Build database check -# --------------------------------------------------------------------------- COMPILE_COMMANDS="$SCRIPT_DIR/build/compile_commands.json" if [[ ! -f "$COMPILE_COMMANDS" ]]; then echo "compile_commands.json not found at $COMPILE_COMMANDS" @@ -108,7 +200,7 @@ if [[ "$FIX" == "1" ]]; then echo "Fix mode enabled — applying suggested fixes." fi -FAILED=0 +LINT_FAILED=0 if [[ -n "$RUN_CLANG_TIDY" ]]; then echo "Running via $RUN_CLANG_TIDY (parallel)..." @@ -119,7 +211,7 @@ if [[ -n "$RUN_CLANG_TIDY" ]]; then -p "$SCRIPT_DIR/build" \ $FIX_FLAG \ "${EXTRA_RUN_ARGS[@]}" \ - "$SRC_REGEX" || FAILED=$? + "$SRC_REGEX" || LINT_FAILED=$? else echo "run-clang-tidy not found; running sequentially..." for f in "${SOURCE_FILES[@]}"; do @@ -127,18 +219,20 @@ else -p "$SCRIPT_DIR/build" \ $FIX_FLAG \ "${EXTRA_TIDY_ARGS[@]}" \ - "$f" || FAILED=$? + "$f" || LINT_FAILED=$? done fi # --------------------------------------------------------------------------- # Result # --------------------------------------------------------------------------- -if [[ $FAILED -ne 0 ]]; then +if [[ $LINT_FAILED -ne 0 ]]; then echo "" echo "clang-tidy reported issues. Fix them or add suppressions in .clang-tidy." - exit 1 + OVERALL_FAILED=1 +else + echo "" + echo "Lint passed." fi -echo "" -echo "Lint passed." +exit $OVERALL_FAILED diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 00000000..7a25bf62 --- /dev/null +++ b/tests/CMakeLists.txt @@ -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() diff --git a/tests/test_blp_loader.cpp b/tests/test_blp_loader.cpp new file mode 100644 index 00000000..b66073ad --- /dev/null +++ b/tests/test_blp_loader.cpp @@ -0,0 +1,92 @@ +// Phase 0 – BLP loader tests: isValid, format names, invalid data handling +#include +#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 empty; + auto img = BLPLoader::load(empty); + REQUIRE_FALSE(img.isValid()); +} + +TEST_CASE("BLPLoader::load too small data returns invalid", "[blp]") { + std::vector 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 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(64 * 64 * 4, 0)); + img.mipmaps.push_back(std::vector(32 * 32 * 4, 0)); + img.mipmaps.push_back(std::vector(16 * 16 * 4, 0)); + + REQUIRE(img.isValid()); + REQUIRE(img.mipmaps.size() == 3); +} diff --git a/tests/test_dbc_loader.cpp b/tests/test_dbc_loader.cpp new file mode 100644 index 00000000..6afa0b44 --- /dev/null +++ b/tests/test_dbc_loader.cpp @@ -0,0 +1,208 @@ +// Phase 0 – DBC binary parsing tests with synthetic data +#include +#include "pipeline/dbc_loader.hpp" +#include + +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 buildSyntheticDBC( + uint32_t numRecords, uint32_t numFields, + const std::vector>& records, + const std::string& stringBlock) +{ + const uint32_t recordSize = numFields * 4; + const uint32_t stringBlockSize = static_cast(stringBlock.size()); + + std::vector 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(v & 0xFF)); + data.push_back(static_cast((v >> 8) & 0xFF)); + data.push_back(static_cast((v >> 16) & 0xFF)); + data.push_back(static_cast((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(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 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()); +} diff --git a/tests/test_entity.cpp b/tests/test_entity.cpp new file mode 100644 index 00000000..0d30f6e2 --- /dev/null +++ b/tests/test_entity.cpp @@ -0,0 +1,220 @@ +// Phase 0 – Entity, Unit, Player, GameObject, EntityManager tests +#include +#include "game/entity.hpp" +#include + +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(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(1)); + mgr.addEntity(2, std::make_shared(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(10)); + mgr.addEntity(20, std::make_shared(20)); + mgr.addEntity(30, std::make_shared(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); +} diff --git a/tests/test_frustum.cpp b/tests/test_frustum.cpp new file mode 100644 index 00000000..19d67a8f --- /dev/null +++ b/tests/test_frustum.cpp @@ -0,0 +1,132 @@ +// Phase 0 – Frustum plane extraction and intersection tests +#include +#include "rendering/frustum.hpp" + +#include +#include + +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(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))); +} diff --git a/tests/test_m2_structs.cpp b/tests/test_m2_structs.cpp new file mode 100644 index 00000000..2f9b0a1c --- /dev/null +++ b/tests/test_m2_structs.cpp @@ -0,0 +1,164 @@ +// Phase 0 – M2 struct layout and field tests (header-only, no loader source) +#include +#include "pipeline/m2_loader.hpp" +#include + +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); +} diff --git a/tests/test_opcode_table.cpp b/tests/test_opcode_table.cpp new file mode 100644 index 00000000..ee123bed --- /dev/null +++ b/tests/test_opcode_table.cpp @@ -0,0 +1,118 @@ +// Phase 0 – OpcodeTable load from JSON, toWire/fromWire mapping +#include +#include "game/opcode_table.hpp" +#include +#include +#include + +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()); +} diff --git a/tests/test_packet.cpp b/tests/test_packet.cpp new file mode 100644 index 00000000..16f2032c --- /dev/null +++ b/tests/test_packet.cpp @@ -0,0 +1,192 @@ +// Phase 0 – Packet read/write round-trip, packed GUID, bounds checks +#include +#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 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{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)); +} diff --git a/tests/test_srp.cpp b/tests/test_srp.cpp new file mode 100644 index 00000000..ad0b9550 --- /dev/null +++ b/tests/test_srp.cpp @@ -0,0 +1,127 @@ +// Phase 0 – SRP6a challenge/proof smoke tests +#include +#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 kWoWGenerator = { 7 }; + +// WoW's 32-byte large safe prime (little-endian) +static const std::vector 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 B(32, 0); + B[0] = 0x42; // Non-zero + + std::vector 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 B(32, 0); + B[3] = 0x01; + std::vector 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 B(32, 0); + B[0] = 0x11; + std::vector 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 B(32, 0); + B[0] = 0x55; + std::vector salt(32, 0xDD); + + srp.feed(B, kWoWGenerator, kWoWPrime, salt); + + // Random 20 bytes should not match the expected M2 + std::vector 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 B(32, 0); + B[0] = 0x22; + std::vector 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); +}