mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-17 09:33:51 +00:00
chore(testing): add unit tests and update core render/network pipelines
- add new tests: - test_blp_loader.cpp - test_dbc_loader.cpp - test_entity.cpp - test_frustum.cpp - test_m2_structs.cpp - test_opcode_table.cpp - test_packet.cpp - test_srp.cpp - CMakeLists.txt - add docs and progress tracking: - TESTING.md - perf_baseline.md - update project config/build: - .gitignore - CMakeLists.txt - test.sh - core engine updates: - application.cpp - game_handler.cpp - world_socket.cpp - adt_loader.cpp - asset_manager.cpp - m2_renderer.cpp - post_process_pipeline.cpp - renderer.cpp - terrain_manager.cpp - game_screen.cpp - add profiler header: - profiler.hpp
This commit is contained in:
parent
a2814ab082
commit
2cb47bf126
25 changed files with 2042 additions and 96 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,6 @@
|
||||||
# Build directories
|
# Build directories
|
||||||
build/
|
build/
|
||||||
|
build_asan/
|
||||||
build-debug/
|
build-debug/
|
||||||
build-sanitize/
|
build-sanitize/
|
||||||
bin/
|
bin/
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,9 @@ endif()
|
||||||
|
|
||||||
# Options
|
# Options
|
||||||
option(BUILD_SHARED_LIBS "Build shared libraries" OFF)
|
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_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_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_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)
|
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
|
# Create executable
|
||||||
add_executable(wowee ${WOWEE_SOURCES} ${WOWEE_HEADERS} ${WOWEE_PLATFORM_SOURCES})
|
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)
|
if(TARGET opcodes-generate)
|
||||||
add_dependencies(wowee opcodes-generate)
|
add_dependencies(wowee opcodes-generate)
|
||||||
endif()
|
endif()
|
||||||
|
|
@ -931,6 +941,12 @@ else()
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# ── Unit tests (Catch2) ──────────────────────────────────────
|
||||||
|
if(WOWEE_BUILD_TESTS)
|
||||||
|
enable_testing()
|
||||||
|
add_subdirectory(tests)
|
||||||
|
endif()
|
||||||
|
|
||||||
# AddressSanitizer — catch buffer overflows, use-after-free, etc.
|
# AddressSanitizer — catch buffer overflows, use-after-free, etc.
|
||||||
# Enable with: cmake ... -DWOWEE_ENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug
|
# Enable with: cmake ... -DWOWEE_ENABLE_ASAN=ON -DCMAKE_BUILD_TYPE=Debug
|
||||||
if(WOWEE_ENABLE_ASAN)
|
if(WOWEE_ENABLE_ASAN)
|
||||||
|
|
@ -942,10 +958,10 @@ if(WOWEE_ENABLE_ASAN)
|
||||||
$<$<CONFIG:Release>:/MD>
|
$<$<CONFIG:Release>:/MD>
|
||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
target_compile_options(wowee PRIVATE -fsanitize=address -fno-omit-frame-pointer)
|
target_compile_options(wowee PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer)
|
||||||
target_link_options(wowee PRIVATE -fsanitize=address)
|
target_link_options(wowee PRIVATE -fsanitize=address,undefined)
|
||||||
endif()
|
endif()
|
||||||
message(STATUS "AddressSanitizer: ENABLED")
|
message(STATUS "AddressSanitizer + UBSan: ENABLED")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# Release build optimizations
|
# 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/application.hpp"
|
||||||
#include "core/coordinates.hpp"
|
#include "core/coordinates.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
|
|
@ -569,6 +570,7 @@ bool Application::initialize() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Application::run() {
|
void Application::run() {
|
||||||
|
ZoneScopedN("Application::run");
|
||||||
LOG_INFO("Starting main loop");
|
LOG_INFO("Starting main loop");
|
||||||
|
|
||||||
// Pin main thread to a dedicated CPU core to reduce scheduling jitter
|
// Pin main thread to a dedicated CPU core to reduce scheduling jitter
|
||||||
|
|
@ -759,6 +761,7 @@ void Application::run() {
|
||||||
|
|
||||||
// Update application state
|
// Update application state
|
||||||
try {
|
try {
|
||||||
|
FrameMark;
|
||||||
update(deltaTime);
|
update(deltaTime);
|
||||||
} catch (const std::bad_alloc& e) {
|
} catch (const std::bad_alloc& e) {
|
||||||
LOG_ERROR("OOM during Application::update (state=", static_cast<int>(state),
|
LOG_ERROR("OOM during Application::update (state=", static_cast<int>(state),
|
||||||
|
|
@ -1110,6 +1113,7 @@ void Application::logoutToLogin() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Application::update(float deltaTime) {
|
void Application::update(float deltaTime) {
|
||||||
|
ZoneScopedN("Application::update");
|
||||||
const char* updateCheckpoint = "enter";
|
const char* updateCheckpoint = "enter";
|
||||||
try {
|
try {
|
||||||
// Update based on current state
|
// Update based on current state
|
||||||
|
|
|
||||||
|
|
@ -5304,40 +5304,9 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) {
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LFG / Dungeon Finder handlers (WotLK 3.3.5a)
|
// LFG / Dungeon Finder handlers (WotLK 3.3.5a)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
// NOTE: lfgJoinResultString() and lfgTeleportDeniedString() live in
|
||||||
static const char* lfgJoinResultString(uint8_t result) {
|
// social_handler.cpp where they are actually called.
|
||||||
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.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// LFG outgoing packets
|
// LFG outgoing packets
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "game/opcode_table.hpp"
|
#include "game/opcode_table.hpp"
|
||||||
#include "auth/crypto.hpp"
|
#include "auth/crypto.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include <iomanip>
|
#include <iomanip>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
@ -464,6 +465,7 @@ void WorldSocket::send(const Packet& packet) {
|
||||||
}
|
}
|
||||||
|
|
||||||
void WorldSocket::update() {
|
void WorldSocket::update() {
|
||||||
|
ZoneScopedN("WorldSocket::update");
|
||||||
if (!useAsyncPump_) {
|
if (!useAsyncPump_) {
|
||||||
pumpNetworkIO();
|
pumpNetworkIO();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "pipeline/adt_loader.hpp"
|
#include "pipeline/adt_loader.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
|
@ -28,6 +29,7 @@ float HeightMap::getHeight(int x, int y) const {
|
||||||
|
|
||||||
// ADTLoader implementation
|
// ADTLoader implementation
|
||||||
ADTTerrain ADTLoader::load(const std::vector<uint8_t>& adtData) {
|
ADTTerrain ADTLoader::load(const std::vector<uint8_t>& adtData) {
|
||||||
|
ZoneScopedN("ADTLoader::load");
|
||||||
ADTTerrain terrain;
|
ADTTerrain terrain;
|
||||||
|
|
||||||
if (adtData.empty()) {
|
if (adtData.empty()) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#include "pipeline/asset_manager.hpp"
|
#include "pipeline/asset_manager.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
#include "core/memory_monitor.hpp"
|
#include "core/memory_monitor.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
|
@ -166,6 +167,7 @@ void AssetManager::setBaseFallbackPath(const std::string& basePath) {
|
||||||
}
|
}
|
||||||
|
|
||||||
BLPImage AssetManager::loadTexture(const std::string& path) {
|
BLPImage AssetManager::loadTexture(const std::string& path) {
|
||||||
|
ZoneScopedN("AssetManager::loadTexture");
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
LOG_ERROR("AssetManager not initialized");
|
LOG_ERROR("AssetManager not initialized");
|
||||||
return BLPImage();
|
return BLPImage();
|
||||||
|
|
@ -265,6 +267,7 @@ void AssetManager::setExpansionDataPath(const std::string& path) {
|
||||||
}
|
}
|
||||||
|
|
||||||
std::shared_ptr<DBCFile> AssetManager::loadDBC(const std::string& name) {
|
std::shared_ptr<DBCFile> AssetManager::loadDBC(const std::string& name) {
|
||||||
|
ZoneScopedN("AssetManager::loadDBC");
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
LOG_ERROR("AssetManager not initialized");
|
LOG_ERROR("AssetManager not initialized");
|
||||||
return nullptr;
|
return nullptr;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
#include "pipeline/asset_manager.hpp"
|
#include "pipeline/asset_manager.hpp"
|
||||||
#include "pipeline/blp_loader.hpp"
|
#include "pipeline/blp_loader.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
#include <glm/gtc/matrix_transform.hpp>
|
#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) {
|
static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) {
|
||||||
|
ZoneScopedN("M2::computeBoneMatrices");
|
||||||
size_t numBones = std::min(model.bones.size(), size_t(128));
|
size_t numBones = std::min(model.bones.size(), size_t(128));
|
||||||
if (numBones == 0) return;
|
if (numBones == 0) return;
|
||||||
instance.boneMatrices.resize(numBones);
|
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) {
|
void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) {
|
||||||
|
ZoneScopedN("M2Renderer::update");
|
||||||
if (spatialIndexDirty_) {
|
if (spatialIndexDirty_) {
|
||||||
rebuildSpatialIndex();
|
rebuildSpatialIndex();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include "rendering/camera.hpp"
|
#include "rendering/camera.hpp"
|
||||||
#include "rendering/amd_fsr3_runtime.hpp"
|
#include "rendering/amd_fsr3_runtime.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <glm/gtc/matrix_inverse.hpp>
|
#include <glm/gtc/matrix_inverse.hpp>
|
||||||
|
|
@ -152,6 +153,7 @@ bool PostProcessPipeline::hasActivePostProcess() const {
|
||||||
|
|
||||||
bool PostProcessPipeline::executePostProcessing(VkCommandBuffer cmd, uint32_t imageIndex,
|
bool PostProcessPipeline::executePostProcessing(VkCommandBuffer cmd, uint32_t imageIndex,
|
||||||
Camera* camera, float deltaTime) {
|
Camera* camera, float deltaTime) {
|
||||||
|
ZoneScopedN("PostProcess::execute");
|
||||||
currentCmd_ = cmd;
|
currentCmd_ = cmd;
|
||||||
camera_ = camera;
|
camera_ = camera;
|
||||||
lastDeltaTime_ = deltaTime;
|
lastDeltaTime_ = deltaTime;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
#include "rendering/weather.hpp"
|
#include "rendering/weather.hpp"
|
||||||
#include "rendering/lightning.hpp"
|
#include "rendering/lightning.hpp"
|
||||||
#include "rendering/lighting_manager.hpp"
|
#include "rendering/lighting_manager.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include "rendering/sky_system.hpp"
|
#include "rendering/sky_system.hpp"
|
||||||
#include "rendering/swim_effects.hpp"
|
#include "rendering/swim_effects.hpp"
|
||||||
#include "rendering/mount_dust.hpp"
|
#include "rendering/mount_dust.hpp"
|
||||||
|
|
@ -797,6 +798,7 @@ void Renderer::applyMsaaChange() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::beginFrame() {
|
void Renderer::beginFrame() {
|
||||||
|
ZoneScopedN("Renderer::beginFrame");
|
||||||
if (!vkCtx) return;
|
if (!vkCtx) return;
|
||||||
if (vkCtx->isDeviceLost()) return;
|
if (vkCtx->isDeviceLost()) return;
|
||||||
|
|
||||||
|
|
@ -924,6 +926,7 @@ void Renderer::beginFrame() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::endFrame() {
|
void Renderer::endFrame() {
|
||||||
|
ZoneScopedN("Renderer::endFrame");
|
||||||
if (!vkCtx || currentCmd == VK_NULL_HANDLE) return;
|
if (!vkCtx || currentCmd == VK_NULL_HANDLE) return;
|
||||||
|
|
||||||
// Track whether a post-processing path switched to an INLINE render pass.
|
// 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) {
|
void Renderer::update(float deltaTime) {
|
||||||
|
ZoneScopedN("Renderer::update");
|
||||||
globalTime += deltaTime;
|
globalTime += deltaTime;
|
||||||
if (musicSwitchCooldown_ > 0.0f) {
|
if (musicSwitchCooldown_ > 0.0f) {
|
||||||
musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime);
|
musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime);
|
||||||
|
|
@ -1975,6 +1979,7 @@ float Renderer::getBrightness() const {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
|
||||||
|
ZoneScopedN("Renderer::renderWorld");
|
||||||
(void)world;
|
(void)world;
|
||||||
|
|
||||||
// Guard against null command buffer (e.g. after VK_ERROR_DEVICE_LOST)
|
// Guard against null command buffer (e.g. after VK_ERROR_DEVICE_LOST)
|
||||||
|
|
@ -3042,6 +3047,7 @@ void Renderer::renderReflectionPass() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void Renderer::renderShadowPass() {
|
void Renderer::renderShadowPass() {
|
||||||
|
ZoneScopedN("Renderer::renderShadowPass");
|
||||||
static const bool skipShadows = (std::getenv("WOWEE_SKIP_SHADOWS") != nullptr);
|
static const bool skipShadows = (std::getenv("WOWEE_SKIP_SHADOWS") != nullptr);
|
||||||
if (skipShadows) return;
|
if (skipShadows) return;
|
||||||
if (!shadowsEnabled || shadowDepthImage[0] == VK_NULL_HANDLE) return;
|
if (!shadowsEnabled || shadowDepthImage[0] == VK_NULL_HANDLE) return;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
#include "audio/ambient_sound_manager.hpp"
|
#include "audio/ambient_sound_manager.hpp"
|
||||||
#include "core/coordinates.hpp"
|
#include "core/coordinates.hpp"
|
||||||
#include "core/memory_monitor.hpp"
|
#include "core/memory_monitor.hpp"
|
||||||
|
#include "core/profiler.hpp"
|
||||||
#include "pipeline/asset_manager.hpp"
|
#include "pipeline/asset_manager.hpp"
|
||||||
#include "pipeline/adt_loader.hpp"
|
#include "pipeline/adt_loader.hpp"
|
||||||
#include "pipeline/m2_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) {
|
void TerrainManager::update(const Camera& camera, float deltaTime) {
|
||||||
|
ZoneScopedN("TerrainManager::update");
|
||||||
if (!streamingEnabled || !assetManager || !terrainRenderer) {
|
if (!streamingEnabled || !assetManager || !terrainRenderer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1236,6 +1238,7 @@ void TerrainManager::workerLoop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
void TerrainManager::processReadyTiles() {
|
void TerrainManager::processReadyTiles() {
|
||||||
|
ZoneScopedN("TerrainManager::processReadyTiles");
|
||||||
// Move newly ready tiles into the finalizing deque.
|
// Move newly ready tiles into the finalizing deque.
|
||||||
// Keep them in pendingTiles so streamTiles() won't re-enqueue them.
|
// Keep them in pendingTiles so streamTiles() won't re-enqueue them.
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -140,50 +140,8 @@ namespace {
|
||||||
return "Unknown";
|
return "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all non-comment, non-empty lines from a macro body.
|
// NOTE: allMacroCommands() and getMacroShowtooltipArg() live in
|
||||||
std::vector<std::string> allMacroCommands(const std::string& macroText) {
|
// action_bar_panel.cpp / chat_panel.cpp where they are actually used.
|
||||||
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 {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace wowee { namespace ui {
|
namespace wowee { namespace ui {
|
||||||
|
|
|
||||||
122
test.sh
122
test.sh
|
|
@ -1,11 +1,22 @@
|
||||||
#!/usr/bin/env bash
|
#!/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:
|
# Usage:
|
||||||
# ./test.sh # lint src/ and include/ using build/compile_commands.json
|
# ./test.sh # run both lint and tests (default)
|
||||||
# FIX=1 ./test.sh # apply suggested fixes automatically (use with care)
|
# ./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
|
set -euo pipefail
|
||||||
|
|
||||||
|
|
@ -13,8 +24,88 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$SCRIPT_DIR"
|
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=""
|
CLANG_TIDY=""
|
||||||
for candidate in clang-tidy clang-tidy-18 clang-tidy-17 clang-tidy-16 clang-tidy-15 clang-tidy-14; do
|
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
|
if command -v "$candidate" >/dev/null 2>&1; then
|
||||||
|
|
@ -29,6 +120,9 @@ if [[ -z "$CLANG_TIDY" ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
|
echo " Running clang-tidy lint"
|
||||||
|
echo "──────────────────────────────────────────────"
|
||||||
echo "Using: $($CLANG_TIDY --version | head -1)"
|
echo "Using: $($CLANG_TIDY --version | head -1)"
|
||||||
|
|
||||||
# run-clang-tidy runs checks in parallel; fall back to sequential if absent.
|
# 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
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Build database check
|
# Build database check
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
COMPILE_COMMANDS="$SCRIPT_DIR/build/compile_commands.json"
|
COMPILE_COMMANDS="$SCRIPT_DIR/build/compile_commands.json"
|
||||||
if [[ ! -f "$COMPILE_COMMANDS" ]]; then
|
if [[ ! -f "$COMPILE_COMMANDS" ]]; then
|
||||||
echo "compile_commands.json not found at $COMPILE_COMMANDS"
|
echo "compile_commands.json not found at $COMPILE_COMMANDS"
|
||||||
|
|
@ -108,7 +200,7 @@ if [[ "$FIX" == "1" ]]; then
|
||||||
echo "Fix mode enabled — applying suggested fixes."
|
echo "Fix mode enabled — applying suggested fixes."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
FAILED=0
|
LINT_FAILED=0
|
||||||
|
|
||||||
if [[ -n "$RUN_CLANG_TIDY" ]]; then
|
if [[ -n "$RUN_CLANG_TIDY" ]]; then
|
||||||
echo "Running via $RUN_CLANG_TIDY (parallel)..."
|
echo "Running via $RUN_CLANG_TIDY (parallel)..."
|
||||||
|
|
@ -119,7 +211,7 @@ if [[ -n "$RUN_CLANG_TIDY" ]]; then
|
||||||
-p "$SCRIPT_DIR/build" \
|
-p "$SCRIPT_DIR/build" \
|
||||||
$FIX_FLAG \
|
$FIX_FLAG \
|
||||||
"${EXTRA_RUN_ARGS[@]}" \
|
"${EXTRA_RUN_ARGS[@]}" \
|
||||||
"$SRC_REGEX" || FAILED=$?
|
"$SRC_REGEX" || LINT_FAILED=$?
|
||||||
else
|
else
|
||||||
echo "run-clang-tidy not found; running sequentially..."
|
echo "run-clang-tidy not found; running sequentially..."
|
||||||
for f in "${SOURCE_FILES[@]}"; do
|
for f in "${SOURCE_FILES[@]}"; do
|
||||||
|
|
@ -127,18 +219,20 @@ else
|
||||||
-p "$SCRIPT_DIR/build" \
|
-p "$SCRIPT_DIR/build" \
|
||||||
$FIX_FLAG \
|
$FIX_FLAG \
|
||||||
"${EXTRA_TIDY_ARGS[@]}" \
|
"${EXTRA_TIDY_ARGS[@]}" \
|
||||||
"$f" || FAILED=$?
|
"$f" || LINT_FAILED=$?
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Result
|
# Result
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if [[ $FAILED -ne 0 ]]; then
|
if [[ $LINT_FAILED -ne 0 ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "clang-tidy reported issues. Fix them or add suppressions in .clang-tidy."
|
echo "clang-tidy reported issues. Fix them or add suppressions in .clang-tidy."
|
||||||
exit 1
|
OVERALL_FAILED=1
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
echo "Lint passed."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
exit $OVERALL_FAILED
|
||||||
echo "Lint passed."
|
|
||||||
|
|
|
||||||
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