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