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:
Paul 2026-04-03 09:41:34 +03:00
parent a2814ab082
commit 2cb47bf126
25 changed files with 2042 additions and 96 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
# Build directories
build/
build_asan/
build-debug/
build-sanitize/
bin/

View file

@ -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)
$<$<CONFIG:Release>:/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

390
TESTING.md Normal file
View file

@ -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 1418, 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 `<vector>`, `<string>`, 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<uint8_t>(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_<name>.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_<name> ──────────────────────────────────────────────
add_executable(test_<name>
test_<name>.cpp
${TEST_COMMON_SOURCES}
${CMAKE_SOURCE_DIR}/src/<module>/<file>.cpp # source under test
)
target_include_directories(test_<name> PRIVATE ${TEST_INCLUDE_DIRS})
target_include_directories(test_<name> SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
target_link_libraries(test_<name> PRIVATE catch2_main)
add_test(NAME <name> COMMAND test_<name>)
register_test_target(test_<name>) # required — enables ASAN propagation
```
3. **Build** and verify:
```bash
cmake --build build --target test_<name>
./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.

79
docs/perf_baseline.md Normal file
View file

@ -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.

19
include/core/profiler.hpp Normal file
View file

@ -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 <tracy/Tracy.hpp>
#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

View file

@ -1,5 +1,6 @@
#include "core/application.hpp"
#include "core/coordinates.hpp"
#include "core/profiler.hpp"
#include <unordered_set>
#include <cmath>
#include <chrono>
@ -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<int>(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

View file

@ -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

View file

@ -4,6 +4,7 @@
#include "game/opcode_table.hpp"
#include "auth/crypto.hpp"
#include "core/logger.hpp"
#include "core/profiler.hpp"
#include <iomanip>
#include <sstream>
#include <cstdio>
@ -464,6 +465,7 @@ void WorldSocket::send(const Packet& packet) {
}
void WorldSocket::update() {
ZoneScopedN("WorldSocket::update");
if (!useAsyncPump_) {
pumpNetworkIO();
}

View file

@ -1,5 +1,6 @@
#include "pipeline/adt_loader.hpp"
#include "core/logger.hpp"
#include "core/profiler.hpp"
#include <cstring>
#include <cmath>
#include <algorithm>
@ -28,6 +29,7 @@ float HeightMap::getHeight(int x, int y) const {
// ADTLoader implementation
ADTTerrain ADTLoader::load(const std::vector<uint8_t>& adtData) {
ZoneScopedN("ADTLoader::load");
ADTTerrain terrain;
if (adtData.empty()) {

View file

@ -1,6 +1,7 @@
#include "pipeline/asset_manager.hpp"
#include "core/logger.hpp"
#include "core/memory_monitor.hpp"
#include "core/profiler.hpp"
#include <algorithm>
#include <cstdlib>
#include <filesystem>
@ -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<DBCFile> AssetManager::loadDBC(const std::string& name) {
ZoneScopedN("AssetManager::loadDBC");
if (!initialized) {
LOG_ERROR("AssetManager not initialized");
return nullptr;

View file

@ -12,6 +12,7 @@
#include "pipeline/asset_manager.hpp"
#include "pipeline/blp_loader.hpp"
#include "core/logger.hpp"
#include "core/profiler.hpp"
#include <chrono>
#include <cctype>
#include <glm/gtc/matrix_transform.hpp>
@ -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();
}

View file

@ -8,6 +8,7 @@
#include "rendering/camera.hpp"
#include "rendering/amd_fsr3_runtime.hpp"
#include "core/logger.hpp"
#include "core/profiler.hpp"
#include <cstdlib>
#include <algorithm>
#include <glm/gtc/matrix_inverse.hpp>
@ -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;

View file

@ -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;

View file

@ -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.
{

View file

@ -140,50 +140,8 @@ namespace {
return "Unknown";
}
// Collect all non-comment, non-empty lines from a macro body.
std::vector<std::string> allMacroCommands(const std::string& macroText) {
std::vector<std::string> 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 {

122
test.sh
View file

@ -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

142
tests/CMakeLists.txt Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}