mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-05-03 08:03:50 +00:00
Merge pull request #60 from ldmonster/chore/transport-manager
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
[chore] refactor: extract spline math, decompose TransportManager
This commit is contained in:
commit
db3f65a87e
49 changed files with 3124 additions and 1552 deletions
|
|
@ -504,6 +504,9 @@ set(WOWEE_SOURCES
|
||||||
src/core/logger.cpp
|
src/core/logger.cpp
|
||||||
src/core/memory_monitor.cpp
|
src/core/memory_monitor.cpp
|
||||||
|
|
||||||
|
# Math
|
||||||
|
src/math/spline.cpp
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
src/network/socket.cpp
|
src/network/socket.cpp
|
||||||
src/network/packet.cpp
|
src/network/packet.cpp
|
||||||
|
|
@ -543,6 +546,9 @@ set(WOWEE_SOURCES
|
||||||
src/game/warden_emulator.cpp
|
src/game/warden_emulator.cpp
|
||||||
src/game/warden_memory.cpp
|
src/game/warden_memory.cpp
|
||||||
src/game/transport_manager.cpp
|
src/game/transport_manager.cpp
|
||||||
|
src/game/transport_path_repository.cpp
|
||||||
|
src/game/transport_clock_sync.cpp
|
||||||
|
src/game/transport_animator.cpp
|
||||||
src/game/world.cpp
|
src/game/world.cpp
|
||||||
src/game/player.cpp
|
src/game/player.cpp
|
||||||
src/game/entity.cpp
|
src/game/entity.cpp
|
||||||
|
|
@ -552,6 +558,7 @@ set(WOWEE_SOURCES
|
||||||
src/game/world_packets_entity.cpp
|
src/game/world_packets_entity.cpp
|
||||||
src/game/world_packets_world.cpp
|
src/game/world_packets_world.cpp
|
||||||
src/game/world_packets_economy.cpp
|
src/game/world_packets_economy.cpp
|
||||||
|
src/game/spline_packet.cpp
|
||||||
src/game/packet_parsers_tbc.cpp
|
src/game/packet_parsers_tbc.cpp
|
||||||
src/game/packet_parsers_classic.cpp
|
src/game/packet_parsers_classic.cpp
|
||||||
src/game/character.cpp
|
src/game/character.cpp
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,10 @@ public:
|
||||||
bool isWeaponsSheathed() const { return weaponsSheathed_; }
|
bool isWeaponsSheathed() const { return weaponsSheathed_; }
|
||||||
void toggleWeaponsSheathed() { weaponsSheathed_ = !weaponsSheathed_; }
|
void toggleWeaponsSheathed() { weaponsSheathed_ = !weaponsSheathed_; }
|
||||||
|
|
||||||
|
// Ranged weapon swap: temporarily show ranged weapon in right hand
|
||||||
|
void showRangedWeapon(bool show);
|
||||||
|
bool isShowingRanged() const { return showingRanged_; }
|
||||||
|
|
||||||
// Saved skin state accessors (used by game_screen.cpp for equipment re-compositing)
|
// Saved skin state accessors (used by game_screen.cpp for equipment re-compositing)
|
||||||
const std::string& getBodySkinPath() const { return bodySkinPath_; }
|
const std::string& getBodySkinPath() const { return bodySkinPath_; }
|
||||||
const std::vector<std::string>& getUnderwearPaths() const { return underwearPaths_; }
|
const std::vector<std::string>& getUnderwearPaths() const { return underwearPaths_; }
|
||||||
|
|
@ -96,6 +100,7 @@ private:
|
||||||
uint32_t cloakTextureSlotIndex_ = 0;
|
uint32_t cloakTextureSlotIndex_ = 0;
|
||||||
|
|
||||||
bool weaponsSheathed_ = false;
|
bool weaponsSheathed_ = false;
|
||||||
|
bool showingRanged_ = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace core
|
} // namespace core
|
||||||
|
|
|
||||||
|
|
@ -113,7 +113,7 @@ public:
|
||||||
// World loader access
|
// World loader access
|
||||||
WorldLoader* getWorldLoader() { return worldLoader_.get(); }
|
WorldLoader* getWorldLoader() { return worldLoader_.get(); }
|
||||||
|
|
||||||
// Audio coordinator access (Section 4.1: extracted audio subsystem)
|
// Audio coordinator access
|
||||||
audio::AudioCoordinator* getAudioCoordinator() { return audioCoordinator_.get(); }
|
audio::AudioCoordinator* getAudioCoordinator() { return audioCoordinator_.get(); }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
#include <map>
|
#include <map>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <optional>
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
namespace game {
|
namespace game {
|
||||||
|
|
@ -80,31 +82,41 @@ public:
|
||||||
orientation = o;
|
orientation = o;
|
||||||
isMoving_ = false; // Instant position set cancels interpolation
|
isMoving_ = false; // Instant position set cancels interpolation
|
||||||
usePathMode_ = false;
|
usePathMode_ = false;
|
||||||
|
activeSpline_.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-segment path movement
|
// Multi-segment path movement (Catmull-Rom spline interpolation)
|
||||||
void startMoveAlongPath(const std::vector<std::array<float, 3>>& path, float destO, float totalDuration) {
|
void startMoveAlongPath(const std::vector<std::array<float, 3>>& path, float destO, float totalDuration) {
|
||||||
if (path.empty()) return;
|
if (path.empty()) return;
|
||||||
if (path.size() == 1 || totalDuration <= 0.0f) {
|
if (path.size() == 1 || totalDuration <= 0.0f) {
|
||||||
startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration);
|
startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Compute cumulative distances for proportional segment timing
|
// Build cumulative distances for proportional time assignment
|
||||||
pathPoints_ = path;
|
std::vector<float> cumDist(path.size(), 0.0f);
|
||||||
pathSegDists_.resize(path.size());
|
|
||||||
pathSegDists_[0] = 0.0f;
|
|
||||||
float totalDist = 0.0f;
|
float totalDist = 0.0f;
|
||||||
for (size_t i = 1; i < path.size(); i++) {
|
for (size_t i = 1; i < path.size(); i++) {
|
||||||
float dx = path[i][0] - path[i - 1][0];
|
float dx = path[i][0] - path[i - 1][0];
|
||||||
float dy = path[i][1] - path[i - 1][1];
|
float dy = path[i][1] - path[i - 1][1];
|
||||||
float dz = path[i][2] - path[i - 1][2];
|
float dz = path[i][2] - path[i - 1][2];
|
||||||
totalDist += std::sqrt(dx * dx + dy * dy + dz * dz);
|
totalDist += std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
pathSegDists_[i] = totalDist;
|
cumDist[i] = totalDist;
|
||||||
}
|
}
|
||||||
if (totalDist < 0.001f) {
|
if (totalDist < 0.001f) {
|
||||||
startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration);
|
startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Build SplineKeys with distance-proportional time
|
||||||
|
uint32_t durationMs = static_cast<uint32_t>(totalDuration * 1000.0f);
|
||||||
|
std::vector<math::SplineKey> keys(path.size());
|
||||||
|
for (size_t i = 0; i < path.size(); i++) {
|
||||||
|
float fraction = cumDist[i] / totalDist;
|
||||||
|
keys[i].timeMs = static_cast<uint32_t>(fraction * durationMs);
|
||||||
|
keys[i].position = {path[i][0], path[i][1], path[i][2]};
|
||||||
|
}
|
||||||
|
activeSpline_.emplace(std::move(keys), /*timeClosed=*/false);
|
||||||
|
splineDurationMs_ = durationMs;
|
||||||
|
|
||||||
// Snap position if in overrun phase
|
// Snap position if in overrun phase
|
||||||
if (isMoving_ && moveElapsed_ >= moveDuration_) {
|
if (isMoving_ && moveElapsed_ >= moveDuration_) {
|
||||||
x = moveEndX_; y = moveEndY_; z = moveEndZ_;
|
x = moveEndX_; y = moveEndY_; z = moveEndZ_;
|
||||||
|
|
@ -130,6 +142,7 @@ public:
|
||||||
// Movement interpolation (syncs entity position with renderer during movement)
|
// Movement interpolation (syncs entity position with renderer during movement)
|
||||||
void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) {
|
void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) {
|
||||||
usePathMode_ = false;
|
usePathMode_ = false;
|
||||||
|
activeSpline_.reset();
|
||||||
if (durationSec <= 0.0f) {
|
if (durationSec <= 0.0f) {
|
||||||
setPosition(destX, destY, destZ, destO);
|
setPosition(destX, destY, destZ, destO);
|
||||||
return;
|
return;
|
||||||
|
|
@ -172,24 +185,14 @@ public:
|
||||||
if (!isMoving_) return;
|
if (!isMoving_) return;
|
||||||
moveElapsed_ += deltaTime;
|
moveElapsed_ += deltaTime;
|
||||||
if (moveElapsed_ < moveDuration_) {
|
if (moveElapsed_ < moveDuration_) {
|
||||||
if (usePathMode_ && pathPoints_.size() > 1) {
|
if (usePathMode_ && activeSpline_) {
|
||||||
// Multi-segment path interpolation
|
// Catmull-Rom spline interpolation
|
||||||
float totalDist = pathSegDists_.back();
|
uint32_t pathTimeMs = static_cast<uint32_t>(moveElapsed_ * 1000.0f);
|
||||||
float t = moveElapsed_ / moveDuration_;
|
if (pathTimeMs >= splineDurationMs_) pathTimeMs = splineDurationMs_ - 1;
|
||||||
float targetDist = t * totalDist;
|
glm::vec3 pos = activeSpline_->evaluatePosition(pathTimeMs);
|
||||||
// Find the segment containing targetDist
|
x = pos.x;
|
||||||
size_t seg = 1;
|
y = pos.y;
|
||||||
while (seg < pathSegDists_.size() - 1 && pathSegDists_[seg] < targetDist)
|
z = pos.z;
|
||||||
seg++;
|
|
||||||
float segStart = pathSegDists_[seg - 1];
|
|
||||||
float segEnd = pathSegDists_[seg];
|
|
||||||
float segLen = segEnd - segStart;
|
|
||||||
float segT = (segLen > 0.001f) ? (targetDist - segStart) / segLen : 0.0f;
|
|
||||||
const auto& p0 = pathPoints_[seg - 1];
|
|
||||||
const auto& p1 = pathPoints_[seg];
|
|
||||||
x = p0[0] + (p1[0] - p0[0]) * segT;
|
|
||||||
y = p0[1] + (p1[1] - p0[1]) * segT;
|
|
||||||
z = p0[2] + (p1[2] - p0[2]) * segT;
|
|
||||||
} else {
|
} else {
|
||||||
// Single-segment linear interpolation
|
// Single-segment linear interpolation
|
||||||
float t = moveElapsed_ / moveDuration_;
|
float t = moveElapsed_ / moveDuration_;
|
||||||
|
|
@ -277,9 +280,9 @@ protected:
|
||||||
float moveDuration_ = 0;
|
float moveDuration_ = 0;
|
||||||
float moveElapsed_ = 0;
|
float moveElapsed_ = 0;
|
||||||
float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning
|
float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning
|
||||||
// Multi-segment path data
|
// CatmullRom spline for multi-segment path movement (replaces linear pathPoints_/pathSegDists_)
|
||||||
std::vector<std::array<float, 3>> pathPoints_;
|
std::optional<math::CatmullRomSpline> activeSpline_;
|
||||||
std::vector<float> pathSegDists_; // Cumulative distances for each waypoint
|
uint32_t splineDurationMs_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -199,7 +199,7 @@ public:
|
||||||
using CharCreateCallback = std::function<void(bool success, const std::string& message)>;
|
using CharCreateCallback = std::function<void(bool success, const std::string& message)>;
|
||||||
void setCharCreateCallback(CharCreateCallback cb) { charCreateCallback_ = std::move(cb); }
|
void setCharCreateCallback(CharCreateCallback cb) { charCreateCallback_ = std::move(cb); }
|
||||||
|
|
||||||
using CharDeleteCallback = std::function<void(bool success)>;
|
using CharDeleteCallback = std::function<void(bool success, const std::string& message)>;
|
||||||
void setCharDeleteCallback(CharDeleteCallback cb) { charDeleteCallback_ = std::move(cb); }
|
void setCharDeleteCallback(CharDeleteCallback cb) { charDeleteCallback_ = std::move(cb); }
|
||||||
uint8_t getLastCharDeleteResult() const { return lastCharDeleteResult_; }
|
uint8_t getLastCharDeleteResult() const { return lastCharDeleteResult_; }
|
||||||
|
|
||||||
|
|
@ -943,6 +943,10 @@ public:
|
||||||
using MeleeSwingCallback = std::function<void(uint32_t spellId)>;
|
using MeleeSwingCallback = std::function<void(uint32_t spellId)>;
|
||||||
void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); }
|
void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); }
|
||||||
|
|
||||||
|
// Ranged weapon swap callback — show=true: swap to ranged weapon, false: back to melee
|
||||||
|
using RangedWeaponSwapCallback = std::function<void(bool show)>;
|
||||||
|
void setRangedWeaponSwapCallback(RangedWeaponSwapCallback cb) { rangedWeaponSwapCallback_ = std::move(cb); }
|
||||||
|
|
||||||
// Spell cast animation callbacks — true=start cast/channel, false=finish/cancel
|
// Spell cast animation callbacks — true=start cast/channel, false=finish/cancel
|
||||||
// guid: caster (may be player or another unit), isChannel: channel vs regular cast
|
// guid: caster (may be player or another unit), isChannel: channel vs regular cast
|
||||||
// castType: DIRECTED (unit target), OMNI (self/no target), AREA (ground AoE)
|
// castType: DIRECTED (unit target), OMNI (self/no target), AREA (ground AoE)
|
||||||
|
|
@ -2399,6 +2403,13 @@ public:
|
||||||
auto& knockBackCallbackRef() { return knockBackCallback_; }
|
auto& knockBackCallbackRef() { return knockBackCallback_; }
|
||||||
auto& lootWindowCallbackRef() { return lootWindowCallback_; }
|
auto& lootWindowCallbackRef() { return lootWindowCallback_; }
|
||||||
auto& meleeSwingCallbackRef() { return meleeSwingCallback_; }
|
auto& meleeSwingCallbackRef() { return meleeSwingCallback_; }
|
||||||
|
auto& rangedWeaponSwapCallbackRef() { return rangedWeaponSwapCallback_; }
|
||||||
|
void suppressNextMeleeSwingAnim() { suppressMeleeSwingAnim_ = true; }
|
||||||
|
bool consumeSuppressMeleeSwingAnim() {
|
||||||
|
bool v = suppressMeleeSwingAnim_;
|
||||||
|
suppressMeleeSwingAnim_ = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
auto& mountCallbackRef() { return mountCallback_; }
|
auto& mountCallbackRef() { return mountCallback_; }
|
||||||
auto& npcAggroCallbackRef() { return npcAggroCallback_; }
|
auto& npcAggroCallbackRef() { return npcAggroCallback_; }
|
||||||
auto& npcDeathCallbackRef() { return npcDeathCallback_; }
|
auto& npcDeathCallbackRef() { return npcDeathCallback_; }
|
||||||
|
|
@ -3336,6 +3347,10 @@ private:
|
||||||
CharDeleteCallback charDeleteCallback_;
|
CharDeleteCallback charDeleteCallback_;
|
||||||
CharLoginFailCallback charLoginFailCallback_;
|
CharLoginFailCallback charLoginFailCallback_;
|
||||||
uint8_t lastCharDeleteResult_ = 0xFF;
|
uint8_t lastCharDeleteResult_ = 0xFF;
|
||||||
|
bool pendingCharDeleteResponse_ = false;
|
||||||
|
uint64_t pendingDeleteGuid_ = 0;
|
||||||
|
float pendingDeleteTimer_ = 0.0f;
|
||||||
|
bool pendingDeleteFallbackEnum_ = false;
|
||||||
bool pendingCharCreateResult_ = false;
|
bool pendingCharCreateResult_ = false;
|
||||||
bool pendingCharCreateSuccess_ = false;
|
bool pendingCharCreateSuccess_ = false;
|
||||||
std::string pendingCharCreateMsg_;
|
std::string pendingCharCreateMsg_;
|
||||||
|
|
@ -3432,6 +3447,8 @@ private:
|
||||||
AppearanceChangedCallback appearanceChangedCallback_;
|
AppearanceChangedCallback appearanceChangedCallback_;
|
||||||
GhostStateCallback ghostStateCallback_;
|
GhostStateCallback ghostStateCallback_;
|
||||||
MeleeSwingCallback meleeSwingCallback_;
|
MeleeSwingCallback meleeSwingCallback_;
|
||||||
|
RangedWeaponSwapCallback rangedWeaponSwapCallback_;
|
||||||
|
bool suppressMeleeSwingAnim_ = false;
|
||||||
// lastMeleeSwingMs_ moved to CombatHandler
|
// lastMeleeSwingMs_ moved to CombatHandler
|
||||||
SpellCastAnimCallback spellCastAnimCallback_;
|
SpellCastAnimCallback spellCastAnimCallback_;
|
||||||
SpellCastFailedCallback spellCastFailedCallback_;
|
SpellCastFailedCallback spellCastFailedCallback_;
|
||||||
|
|
|
||||||
108
include/game/spline_packet.hpp
Normal file
108
include/game/spline_packet.hpp
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
// include/game/spline_packet.hpp
|
||||||
|
// Consolidated spline packet parsing — replaces 7 duplicated parsing locations.
|
||||||
|
#pragma once
|
||||||
|
#include "network/packet.hpp"
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
/// Decoded spline data from a movement or MonsterMove packet.
|
||||||
|
struct SplineBlockData {
|
||||||
|
uint32_t splineFlags = 0;
|
||||||
|
uint32_t duration = 0;
|
||||||
|
|
||||||
|
// Animation (splineFlag 0x00400000)
|
||||||
|
bool hasAnimation = false;
|
||||||
|
uint8_t animationType = 0;
|
||||||
|
uint32_t animationStartTime = 0;
|
||||||
|
|
||||||
|
// Parabolic (splineFlag 0x00000800 for MonsterMove, 0x00000008 for MoveUpdate)
|
||||||
|
bool hasParabolic = false;
|
||||||
|
float verticalAcceleration = 0.0f;
|
||||||
|
uint32_t parabolicStartTime = 0;
|
||||||
|
|
||||||
|
// FINAL_POINT / FINAL_TARGET / FINAL_ANGLE (movement update only)
|
||||||
|
bool hasFinalPoint = false;
|
||||||
|
glm::vec3 finalPoint{0};
|
||||||
|
bool hasFinalTarget = false;
|
||||||
|
uint64_t finalTarget = 0;
|
||||||
|
bool hasFinalAngle = false;
|
||||||
|
float finalAngle = 0.0f;
|
||||||
|
|
||||||
|
// Timing (movement update only)
|
||||||
|
uint32_t timePassed = 0;
|
||||||
|
uint32_t splineId = 0;
|
||||||
|
|
||||||
|
// Waypoints (server coordinates, decoded from packed-delta if compressed)
|
||||||
|
std::vector<glm::vec3> waypoints;
|
||||||
|
glm::vec3 destination{0};
|
||||||
|
bool hasDest = false;
|
||||||
|
|
||||||
|
// SplineMode (movement update WotLK only)
|
||||||
|
uint8_t splineMode = 0;
|
||||||
|
glm::vec3 endPoint{0};
|
||||||
|
bool hasEndPoint = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Spline flag constants ───────────────────────────────────────
|
||||||
|
namespace SplineFlag {
|
||||||
|
constexpr uint32_t FINAL_POINT = 0x00010000;
|
||||||
|
constexpr uint32_t FINAL_TARGET = 0x00020000;
|
||||||
|
constexpr uint32_t FINAL_ANGLE = 0x00040000;
|
||||||
|
constexpr uint32_t CATMULLROM = 0x00080000; // Uncompressed Catmull-Rom
|
||||||
|
constexpr uint32_t CYCLIC = 0x00100000; // Cyclic path
|
||||||
|
constexpr uint32_t ENTER_CYCLE = 0x00200000; // Entering cyclic path
|
||||||
|
constexpr uint32_t ANIMATION = 0x00400000; // Animation spline
|
||||||
|
constexpr uint32_t PARABOLIC_MM = 0x00000800; // Parabolic in MonsterMove
|
||||||
|
constexpr uint32_t PARABOLIC_MU = 0x00000008; // Parabolic in MoveUpdate
|
||||||
|
|
||||||
|
// Mask: if any of these are set, waypoints are uncompressed
|
||||||
|
constexpr uint32_t UNCOMPRESSED_MASK = CATMULLROM | CYCLIC | ENTER_CYCLE;
|
||||||
|
// TBC-era alternative for uncompressed check
|
||||||
|
constexpr uint32_t UNCOMPRESSED_MASK_TBC = CATMULLROM | 0x00002000;
|
||||||
|
} // namespace SplineFlag
|
||||||
|
|
||||||
|
/// Decode a single packed-delta waypoint.
|
||||||
|
/// Format: bits [0:10] = X (11-bit signed), [11:21] = Y (11-bit signed), [22:31] = Z (10-bit signed).
|
||||||
|
/// Each component is multiplied by 0.25 and subtracted from `midpoint`.
|
||||||
|
[[nodiscard]] glm::vec3 decodePackedDelta(uint32_t packed, const glm::vec3& midpoint);
|
||||||
|
|
||||||
|
/// Parse a MonsterMove spline body (after splineFlags has already been read).
|
||||||
|
/// Handles: Animation, duration, Parabolic, pointCount, compressed/uncompressed waypoints.
|
||||||
|
/// `startPos` is the creature's current position (needed for packed-delta midpoint calculation).
|
||||||
|
/// `splineFlags` is the already-read spline flags value.
|
||||||
|
/// `useTbcUncompressedMask`: if true, use 0x00080000|0x00002000 for uncompressed check (TBC format).
|
||||||
|
[[nodiscard]] bool parseMonsterMoveSplineBody(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out,
|
||||||
|
uint32_t splineFlags,
|
||||||
|
const glm::vec3& startPos,
|
||||||
|
bool useTbcUncompressedMask = false);
|
||||||
|
|
||||||
|
/// Parse a MonsterMove spline body where waypoints are always compressed (Vanilla format).
|
||||||
|
/// `startPos` is the creature's current position.
|
||||||
|
[[nodiscard]] bool parseMonsterMoveSplineBodyVanilla(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out,
|
||||||
|
uint32_t splineFlags,
|
||||||
|
const glm::vec3& startPos);
|
||||||
|
|
||||||
|
/// Parse a Classic/Turtle movement update spline block.
|
||||||
|
/// Format: splineFlags, FINAL_POINT/TARGET/ANGLE, timePassed, duration, splineId,
|
||||||
|
/// pointCount, uncompressed waypoints (12 bytes each), endPoint (no splineMode).
|
||||||
|
[[nodiscard]] bool parseClassicMoveUpdateSpline(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out);
|
||||||
|
|
||||||
|
/// Parse a WotLK movement update spline block.
|
||||||
|
/// Format: splineFlags, FINAL_POINT/TARGET/ANGLE, timePassed, duration, splineId,
|
||||||
|
/// then WotLK header (durationMod, durationModNext, [Animation], [Parabolic],
|
||||||
|
/// pointCount, splineMode, endPoint) with multi-strategy fallback.
|
||||||
|
[[nodiscard]] bool parseWotlkMoveUpdateSpline(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out,
|
||||||
|
const glm::vec3& entityPos = glm::vec3(0));
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
33
include/game/transport_animator.hpp
Normal file
33
include/game/transport_animator.hpp
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// include/game/transport_animator.hpp
|
||||||
|
// Path evaluation, Z clamping, and orientation for transports.
|
||||||
|
// Extracted from TransportManager (Phase 3b of spline refactoring).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
#include <glm/gtc/quaternion.hpp>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace wowee::math { class CatmullRomSpline; }
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
struct ActiveTransport;
|
||||||
|
struct PathEntry;
|
||||||
|
|
||||||
|
/// Evaluates a transport's position and orientation from a spline path and path time.
|
||||||
|
class TransportAnimator {
|
||||||
|
public:
|
||||||
|
/// Evaluate the spline at pathTimeMs, apply Z clamping, and update
|
||||||
|
/// transport.position and transport.rotation in-place.
|
||||||
|
void evaluateAndApply(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const PathEntry& pathEntry,
|
||||||
|
uint32_t pathTimeMs) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// Guard against bad fallback Z offsets on non-world-coordinate paths.
|
||||||
|
static float clampZOffset(float z, bool worldCoords, bool clientAnim,
|
||||||
|
int serverUpdateCount, bool hasServerClock);
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
49
include/game/transport_clock_sync.hpp
Normal file
49
include/game/transport_clock_sync.hpp
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
// include/game/transport_clock_sync.hpp
|
||||||
|
// Clock synchronization and yaw correction for transports.
|
||||||
|
// Extracted from TransportManager (Phase 3a of spline refactoring).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace wowee::math { class CatmullRomSpline; }
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
struct ActiveTransport;
|
||||||
|
struct PathEntry;
|
||||||
|
|
||||||
|
/// Manages clock sync, server-yaw correction, and velocity bootstrap for a single transport.
|
||||||
|
class TransportClockSync {
|
||||||
|
public:
|
||||||
|
/// Compute pathTimeMs for a transport given current elapsed time and deltaTime.
|
||||||
|
/// Returns false if the transport should use raw server position (no interpolation).
|
||||||
|
[[nodiscard]] bool computePathTime(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const math::CatmullRomSpline& spline,
|
||||||
|
double elapsedTime,
|
||||||
|
float deltaTime,
|
||||||
|
uint32_t& outPathTimeMs) const;
|
||||||
|
|
||||||
|
/// Process a server position update: update clock offset, detect yaw flips,
|
||||||
|
/// bootstrap velocity, and switch between client/server driven modes.
|
||||||
|
void processServerUpdate(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const PathEntry* pathEntry,
|
||||||
|
const glm::vec3& position,
|
||||||
|
float orientation,
|
||||||
|
double elapsedTime);
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// Detect and apply 180-degree yaw correction based on movement vs heading alignment.
|
||||||
|
void updateYawAlignment(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const glm::vec3& velocity) const;
|
||||||
|
|
||||||
|
/// Bootstrap velocity from nearest DBC path segment on first authoritative sample.
|
||||||
|
void bootstrapVelocityFromPath(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const PathEntry& pathEntry) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "game/transport_path_repository.hpp"
|
||||||
|
#include "game/transport_clock_sync.hpp"
|
||||||
|
#include "game/transport_animator.hpp"
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
|
@ -18,21 +21,6 @@ namespace wowee::pipeline {
|
||||||
|
|
||||||
namespace wowee::game {
|
namespace wowee::game {
|
||||||
|
|
||||||
struct TimedPoint {
|
|
||||||
uint32_t tMs; // Time in milliseconds from DBC
|
|
||||||
glm::vec3 pos; // Position at this time
|
|
||||||
};
|
|
||||||
|
|
||||||
struct TransportPath {
|
|
||||||
uint32_t pathId;
|
|
||||||
std::vector<TimedPoint> points; // Time-indexed waypoints (includes duplicate first point at end for wrap)
|
|
||||||
bool looping; // Set to false after adding explicit wrap point
|
|
||||||
uint32_t durationMs; // Total loop duration in ms (includes wrap segment if added)
|
|
||||||
bool zOnly; // True if path only has Z movement (elevator/bobbing), false if real XY travel
|
|
||||||
bool fromDBC; // True if loaded from TransportAnimation.dbc, false for runtime fallback/custom paths
|
|
||||||
bool worldCoords = false; // True if points are absolute world coords (TaxiPathNode), not local offsets
|
|
||||||
};
|
|
||||||
|
|
||||||
struct ActiveTransport {
|
struct ActiveTransport {
|
||||||
uint64_t guid; // Entity GUID
|
uint64_t guid; // Entity GUID
|
||||||
uint32_t wmoInstanceId; // WMO renderer instance ID
|
uint32_t wmoInstanceId; // WMO renderer instance ID
|
||||||
|
|
@ -137,13 +125,13 @@ public:
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void updateTransportMovement(ActiveTransport& transport, float deltaTime);
|
void updateTransportMovement(ActiveTransport& transport, float deltaTime);
|
||||||
glm::vec3 evalTimedCatmullRom(const TransportPath& path, uint32_t pathTimeMs);
|
|
||||||
glm::quat orientationFromTangent(const TransportPath& path, uint32_t pathTimeMs);
|
|
||||||
void updateTransformMatrices(ActiveTransport& transport);
|
void updateTransformMatrices(ActiveTransport& transport);
|
||||||
|
void pushTransform(ActiveTransport& transport);
|
||||||
|
|
||||||
|
TransportPathRepository pathRepo_;
|
||||||
|
TransportClockSync clockSync_;
|
||||||
|
TransportAnimator animator_;
|
||||||
std::unordered_map<uint64_t, ActiveTransport> transports_;
|
std::unordered_map<uint64_t, ActiveTransport> transports_;
|
||||||
std::unordered_map<uint32_t, TransportPath> paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc)
|
|
||||||
std::unordered_map<uint32_t, TransportPath> taxiPaths_; // Indexed by TaxiPath.dbc ID (world-coord paths for MO_TRANSPORT)
|
|
||||||
rendering::WMORenderer* wmoRenderer_ = nullptr;
|
rendering::WMORenderer* wmoRenderer_ = nullptr;
|
||||||
rendering::M2Renderer* m2Renderer_ = nullptr;
|
rendering::M2Renderer* m2Renderer_ = nullptr;
|
||||||
bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction
|
bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction
|
||||||
|
|
|
||||||
67
include/game/transport_path_repository.hpp
Normal file
67
include/game/transport_path_repository.hpp
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
// include/game/transport_path_repository.hpp
|
||||||
|
// Owns and manages transport path data — DBC, taxi, and custom paths.
|
||||||
|
// Uses CatmullRomSpline for spline evaluation (replaces duplicated evalTimedCatmullRom).
|
||||||
|
// Separated from TransportManager for SOLID-S (single responsibility).
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include <cstdint>
|
||||||
|
#include <vector>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
|
||||||
|
namespace wowee::pipeline {
|
||||||
|
class AssetManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
/// Metadata + CatmullRomSpline for a transport path.
|
||||||
|
struct PathEntry {
|
||||||
|
math::CatmullRomSpline spline;
|
||||||
|
uint32_t pathId = 0;
|
||||||
|
bool zOnly = false; // Elevator/bobbing — no meaningful XY travel
|
||||||
|
bool fromDBC = false; // Loaded from TransportAnimation.dbc
|
||||||
|
bool worldCoords = false; // TaxiPathNode absolute world positions (not local offsets)
|
||||||
|
|
||||||
|
PathEntry(math::CatmullRomSpline s, uint32_t id, bool zo, bool dbc, bool wc)
|
||||||
|
: spline(std::move(s)), pathId(id), zOnly(zo), fromDBC(dbc), worldCoords(wc) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Owns and manages transport path data.
|
||||||
|
class TransportPathRepository {
|
||||||
|
public:
|
||||||
|
TransportPathRepository() = default;
|
||||||
|
|
||||||
|
// ── DBC loading ─────────────────────────────────────────
|
||||||
|
bool loadTransportAnimationDBC(pipeline::AssetManager* assetMgr);
|
||||||
|
bool loadTaxiPathNodeDBC(pipeline::AssetManager* assetMgr);
|
||||||
|
|
||||||
|
// ── Path construction ───────────────────────────────────
|
||||||
|
void loadPathFromNodes(uint32_t pathId, const std::vector<glm::vec3>& waypoints,
|
||||||
|
bool looping = true, float speed = 18.0f);
|
||||||
|
|
||||||
|
// ── Lookup ──────────────────────────────────────────────
|
||||||
|
const PathEntry* findPath(uint32_t pathId) const;
|
||||||
|
const PathEntry* findTaxiPath(uint32_t taxiPathId) const;
|
||||||
|
bool hasPathForEntry(uint32_t entry) const;
|
||||||
|
bool hasTaxiPath(uint32_t taxiPathId) const;
|
||||||
|
|
||||||
|
// ── Query ───────────────────────────────────────────────
|
||||||
|
bool hasUsableMovingPathForEntry(uint32_t entry, float minXYRange = 1.0f) const;
|
||||||
|
uint32_t inferDbcPathForSpawn(const glm::vec3& spawnWorldPos, float maxDistance,
|
||||||
|
bool allowZOnly) const;
|
||||||
|
uint32_t inferMovingPathForSpawn(const glm::vec3& spawnWorldPos,
|
||||||
|
float maxDistance = 1200.0f) const;
|
||||||
|
uint32_t pickFallbackMovingPath(uint32_t entry, uint32_t displayId) const;
|
||||||
|
|
||||||
|
// ── Mutation ─────────────────────────────────────────────
|
||||||
|
/// Store or overwrite a path entry (used by assignTaxiPathToTransport).
|
||||||
|
void storePath(uint32_t pathId, PathEntry entry);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::unordered_map<uint32_t, PathEntry> paths_;
|
||||||
|
std::unordered_map<uint32_t, PathEntry> taxiPaths_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
77
include/math/spline.hpp
Normal file
77
include/math/spline.hpp
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// include/math/spline.hpp
|
||||||
|
// Standalone Catmull-Rom spline module with zero external dependencies beyond GLM.
|
||||||
|
// Immutable after construction — thread-safe for concurrent reads.
|
||||||
|
#pragma once
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
#include <glm/gtc/quaternion.hpp>
|
||||||
|
#include <vector>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace wowee::math {
|
||||||
|
|
||||||
|
/// A single time-indexed control point on a spline.
|
||||||
|
struct SplineKey {
|
||||||
|
uint32_t timeMs;
|
||||||
|
glm::vec3 position;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Result of evaluating a spline at a given time — position + tangent.
|
||||||
|
struct SplineEvalResult {
|
||||||
|
glm::vec3 position;
|
||||||
|
glm::vec3 tangent; // Unnormalized derivative
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Immutable spline path. Constructed once, evaluated many times.
|
||||||
|
/// Thread-safe for concurrent reads after construction.
|
||||||
|
class CatmullRomSpline {
|
||||||
|
public:
|
||||||
|
/// Construct from time-sorted keys.
|
||||||
|
/// If `timeClosed` is true, the path wraps: first and last keys share endpoints.
|
||||||
|
/// If false, uses clamped endpoints (no wrapping).
|
||||||
|
explicit CatmullRomSpline(std::vector<SplineKey> keys, bool timeClosed = false);
|
||||||
|
|
||||||
|
/// Evaluate position at given path time (clamped to [0, duration]).
|
||||||
|
[[nodiscard]] glm::vec3 evaluatePosition(uint32_t pathTimeMs) const;
|
||||||
|
|
||||||
|
/// Evaluate position and tangent at given path time.
|
||||||
|
[[nodiscard]] SplineEvalResult evaluate(uint32_t pathTimeMs) const;
|
||||||
|
|
||||||
|
/// Derive orientation quaternion from tangent (Z-up convention).
|
||||||
|
[[nodiscard]] static glm::quat orientationFromTangent(const glm::vec3& tangent);
|
||||||
|
|
||||||
|
/// Total duration of the spline in milliseconds.
|
||||||
|
[[nodiscard]] uint32_t durationMs() const { return durationMs_; }
|
||||||
|
|
||||||
|
/// Number of control points.
|
||||||
|
[[nodiscard]] size_t keyCount() const { return keys_.size(); }
|
||||||
|
|
||||||
|
/// Direct access to keys (for path inference, etc.).
|
||||||
|
[[nodiscard]] const std::vector<SplineKey>& keys() const { return keys_; }
|
||||||
|
|
||||||
|
/// Whether this spline has meaningful XY movement (not just Z-only like elevators).
|
||||||
|
[[nodiscard]] bool hasXYMovement(float minRange = 1.0f) const;
|
||||||
|
|
||||||
|
/// Find nearest key index to a world position (for phase estimation).
|
||||||
|
[[nodiscard]] size_t findNearestKey(const glm::vec3& position) const;
|
||||||
|
|
||||||
|
/// Whether the spline is time-closed (wrapping).
|
||||||
|
[[nodiscard]] bool isTimeClosed() const { return timeClosed_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// Binary search for segment containing pathTimeMs. O(log n).
|
||||||
|
[[nodiscard]] size_t findSegment(uint32_t pathTimeMs) const;
|
||||||
|
|
||||||
|
/// Get 4 control points {p0,p1,p2,p3} for the segment starting at `segIdx`.
|
||||||
|
struct ControlPoints { glm::vec3 p0, p1, p2, p3; };
|
||||||
|
[[nodiscard]] ControlPoints getControlPoints(size_t segIdx) const;
|
||||||
|
|
||||||
|
/// Evaluate position and tangent for a segment at parameter t in [0,1].
|
||||||
|
[[nodiscard]] SplineEvalResult evalSegment(
|
||||||
|
const ControlPoints& cp, float t) const;
|
||||||
|
|
||||||
|
std::vector<SplineKey> keys_;
|
||||||
|
bool timeClosed_;
|
||||||
|
uint32_t durationMs_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace wowee::math
|
||||||
|
|
@ -71,7 +71,7 @@ public:
|
||||||
std::unordered_map<uint32_t, uint32_t> macroPrimarySpellCache_;
|
std::unordered_map<uint32_t, uint32_t> macroPrimarySpellCache_;
|
||||||
size_t macroCacheSpellCount_ = 0;
|
size_t macroCacheSpellCount_ = 0;
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,14 @@ public:
|
||||||
/** Reset all chat settings to defaults. */
|
/** Reset all chat settings to defaults. */
|
||||||
void restoreDefaults();
|
void restoreDefaults();
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
/** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */
|
/** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */
|
||||||
std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler);
|
std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Section 3.5: Injected UI services (Phase B singleton breaking)
|
// Injected UI services (Phase B singleton breaking)
|
||||||
UIServices services_;
|
UIServices services_;
|
||||||
|
|
||||||
// ---- Chat input state ----
|
// ---- Chat input state ----
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ public:
|
||||||
void renderThreatWindow(game::GameHandler& gameHandler);
|
void renderThreatWindow(game::GameHandler& gameHandler);
|
||||||
void renderBgScoreboard(game::GameHandler& gameHandler);
|
void renderBgScoreboard(game::GameHandler& gameHandler);
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -35,11 +35,11 @@ public:
|
||||||
/// called in render() after reclaim corpse button
|
/// called in render() after reclaim corpse button
|
||||||
void renderLateDialogs(game::GameHandler& gameHandler);
|
void renderLateDialogs(game::GameHandler& gameHandler);
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Section 3.5: Injected UI services
|
// Injected UI services
|
||||||
UIServices services_;
|
UIServices services_;
|
||||||
// Common ImGui window flags for popup dialogs
|
// Common ImGui window flags for popup dialogs
|
||||||
static constexpr ImGuiWindowFlags kDialogFlags =
|
static constexpr ImGuiWindowFlags kDialogFlags =
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ public:
|
||||||
// Dependency injection for extracted classes (Phase A singleton breaking)
|
// Dependency injection for extracted classes (Phase A singleton breaking)
|
||||||
void setAppearanceComposer(core::AppearanceComposer* ac) { appearanceComposer_ = ac; }
|
void setAppearanceComposer(core::AppearanceComposer* ac) { appearanceComposer_ = ac; }
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services);
|
void setServices(const UIServices& services);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ public:
|
||||||
void renderInspectWindow(game::GameHandler& gameHandler,
|
void renderInspectWindow(game::GameHandler& gameHandler,
|
||||||
InventoryScreen& inventoryScreen);
|
InventoryScreen& inventoryScreen);
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ public:
|
||||||
/// Fire achievement earned toast + sound
|
/// Fire achievement earned toast + sound
|
||||||
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
|
void triggerAchievementToast(uint32_t achievementId, std::string name = {});
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
// --- public state consumed by GameScreen for the golden burst overlay ---
|
// --- public state consumed by GameScreen for the golden burst overlay ---
|
||||||
|
|
@ -49,7 +49,7 @@ public:
|
||||||
uint32_t levelUpDisplayLevel = 0;
|
uint32_t levelUpDisplayLevel = 0;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Section 3.5: Injected UI services
|
// Injected UI services
|
||||||
UIServices services_;
|
UIServices services_;
|
||||||
|
|
||||||
// ---- Ding effect (own level-up) ----
|
// ---- Ding effect (own level-up) ----
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ public:
|
||||||
if (gameScreen) gameScreen->setAppearanceComposer(ac);
|
if (gameScreen) gameScreen->setAppearanceComposer(ac);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) {
|
void setServices(const UIServices& services) {
|
||||||
services_ = services;
|
services_ = services;
|
||||||
if (gameScreen) gameScreen->setServices(services);
|
if (gameScreen) gameScreen->setServices(services);
|
||||||
|
|
@ -86,7 +86,7 @@ public:
|
||||||
|
|
||||||
private:
|
private:
|
||||||
core::Window* window = nullptr;
|
core::Window* window = nullptr;
|
||||||
UIServices services_; // Section 3.5: Injected services
|
UIServices services_; // Injected services
|
||||||
|
|
||||||
// UI Screens
|
// UI Screens
|
||||||
std::unique_ptr<AuthScreen> authScreen;
|
std::unique_ptr<AuthScreen> authScreen;
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ namespace ui {
|
||||||
/**
|
/**
|
||||||
* UI Services - Dependency injection container for UI components.
|
* UI Services - Dependency injection container for UI components.
|
||||||
*
|
*
|
||||||
* Section 3.5: Break the singleton Phase B
|
* Break the singleton Phase B
|
||||||
*
|
*
|
||||||
* Replaces Application::getInstance() calls throughout UI code.
|
* Replaces Application::getInstance() calls throughout UI code.
|
||||||
* Application creates this struct and injects it into UIManager,
|
* Application creates this struct and injects it into UIManager,
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ public:
|
||||||
std::unordered_map<uint32_t, ExtendedCostEntry> extendedCostCache_;
|
std::unordered_map<uint32_t, ExtendedCostEntry> extendedCostCache_;
|
||||||
bool extendedCostDbLoaded_ = false;
|
bool extendedCostDbLoaded_ = false;
|
||||||
|
|
||||||
// Section 3.5: UIServices injection (Phase B singleton breaking)
|
// UIServices injection (Phase B singleton breaking)
|
||||||
void setServices(const UIServices& services) { services_ = services; }
|
void setServices(const UIServices& services) { services_ = services; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|
|
||||||
|
|
@ -343,9 +343,9 @@ void AnimationCallbackHandler::setupCallbacks() {
|
||||||
|
|
||||||
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
|
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
|
||||||
// WoW-accurate 3-phase spell animation sequence:
|
// WoW-accurate 3-phase spell animation sequence:
|
||||||
// Phase 1: SPELL_PRECAST (31) — one-shot wind-up
|
// SPELL_PRECAST (31) — one-shot wind-up
|
||||||
// Phase 2: READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills
|
// READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills
|
||||||
// Phase 3: SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion
|
// SPELL_CAST_DIRECTED/OMNI/AREA (53/54/33) — one-shot release at completion
|
||||||
// Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201).
|
// Channels use CHANNEL_CAST_DIRECTED/OMNI (124/125) or SPELL_CHANNEL_DIRECTED_OMNI (201).
|
||||||
// castType comes from the spell packet's targetGuid:
|
// castType comes from the spell packet's targetGuid:
|
||||||
// DIRECTED — spell targets a specific unit (Frostbolt, Heal)
|
// DIRECTED — spell targets a specific unit (Frostbolt, Heal)
|
||||||
|
|
@ -398,13 +398,13 @@ void AnimationCallbackHandler::setupCallbacks() {
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Phase 1: Precast wind-up (one-shot, non-channels only)
|
// Precast wind-up (one-shot, non-channels only)
|
||||||
uint32_t precastAnim = 0;
|
uint32_t precastAnim = 0;
|
||||||
if (!isChannel) {
|
if (!isChannel) {
|
||||||
precastAnim = pickFirst({rendering::anim::SPELL_PRECAST});
|
precastAnim = pickFirst({rendering::anim::SPELL_PRECAST});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Cast hold (looping while cast bar fills / channel active)
|
// Cast hold (looping while cast bar fills / channel active)
|
||||||
uint32_t castAnim = 0;
|
uint32_t castAnim = 0;
|
||||||
if (isChannel) {
|
if (isChannel) {
|
||||||
// Channel hold: prefer DIRECTED/OMNI based on spell target classification
|
// Channel hold: prefer DIRECTED/OMNI based on spell target classification
|
||||||
|
|
@ -449,7 +449,7 @@ void AnimationCallbackHandler::setupCallbacks() {
|
||||||
}
|
}
|
||||||
if (castAnim == 0) castAnim = rendering::anim::SPELL;
|
if (castAnim == 0) castAnim = rendering::anim::SPELL;
|
||||||
|
|
||||||
// Phase 3: Finalization release (one-shot after cast completes)
|
// Finalization release (one-shot after cast completes)
|
||||||
// Animation chosen by spell target type: AREA → SPELL_CAST_AREA,
|
// Animation chosen by spell target type: AREA → SPELL_CAST_AREA,
|
||||||
// DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI
|
// DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI
|
||||||
uint32_t finalizeAnim = 0;
|
uint32_t finalizeAnim = 0;
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,7 @@ bool AppearanceComposer::loadWeaponM2(const std::string& m2Path, pipeline::M2Mod
|
||||||
}
|
}
|
||||||
|
|
||||||
void AppearanceComposer::loadEquippedWeapons() {
|
void AppearanceComposer::loadEquippedWeapons() {
|
||||||
|
showingRanged_ = false;
|
||||||
if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized())
|
if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized())
|
||||||
return;
|
return;
|
||||||
if (!gameHandler_) return;
|
if (!gameHandler_) return;
|
||||||
|
|
@ -354,9 +355,12 @@ void AppearanceComposer::loadEquippedWeapons() {
|
||||||
for (const auto& ws : weaponSlots) {
|
for (const auto& ws : weaponSlots) {
|
||||||
charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
|
charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
|
||||||
}
|
}
|
||||||
|
charRenderer->detachWeapon(charInstanceId, 1); // ranged may also use right hand
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool rightHandFilled = false;
|
||||||
|
|
||||||
for (const auto& ws : weaponSlots) {
|
for (const auto& ws : weaponSlots) {
|
||||||
const auto& equipSlot = inventory.getEquipSlot(ws.slot);
|
const auto& equipSlot = inventory.getEquipSlot(ws.slot);
|
||||||
|
|
||||||
|
|
@ -421,8 +425,123 @@ void AppearanceComposer::loadEquippedWeapons() {
|
||||||
weaponModel, weaponModelId, texturePath);
|
weaponModel, weaponModelId, texturePath);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId);
|
LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId);
|
||||||
|
if (ws.attachmentId == 1) rightHandFilled = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- RANGED slot (bow, gun, crossbow, thrown) ---
|
||||||
|
// Show ranged weapon in right hand when main hand is empty.
|
||||||
|
const auto& rangedSlot = inventory.getEquipSlot(game::EquipSlot::RANGED);
|
||||||
|
if (!rightHandFilled && !rangedSlot.empty() && rangedSlot.item.displayInfoId != 0) {
|
||||||
|
uint32_t displayInfoId = rangedSlot.item.displayInfoId;
|
||||||
|
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
|
||||||
|
if (recIdx >= 0) {
|
||||||
|
const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
||||||
|
std::string modelName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), idiL ? (*idiL)["LeftModel"] : 1);
|
||||||
|
std::string textureName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3);
|
||||||
|
|
||||||
|
if (!modelName.empty()) {
|
||||||
|
std::string modelFile = modelName;
|
||||||
|
{
|
||||||
|
size_t dotPos = modelFile.rfind('.');
|
||||||
|
if (dotPos != std::string::npos) {
|
||||||
|
modelFile = modelFile.substr(0, dotPos) + ".m2";
|
||||||
|
} else {
|
||||||
|
modelFile += ".m2";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile;
|
||||||
|
pipeline::M2Model weaponModel;
|
||||||
|
if (!loadWeaponM2(m2Path, weaponModel)) {
|
||||||
|
m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile;
|
||||||
|
loadWeaponM2(m2Path, weaponModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weaponModel.vertices.size() > 0) {
|
||||||
|
std::string texturePath;
|
||||||
|
if (!textureName.empty()) {
|
||||||
|
texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp";
|
||||||
|
if (!assetManager_->fileExists(texturePath)) {
|
||||||
|
texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId();
|
||||||
|
bool ok = charRenderer->attachWeapon(charInstanceId, 1,
|
||||||
|
weaponModel, weaponModelId, texturePath);
|
||||||
|
if (ok) {
|
||||||
|
LOG_INFO("Equipped ranged weapon: ", m2Path, " at attachment 1 (right hand)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppearanceComposer::showRangedWeapon(bool show) {
|
||||||
|
if (show == showingRanged_) return;
|
||||||
|
showingRanged_ = show;
|
||||||
|
|
||||||
|
if (!renderer_ || !renderer_->getCharacterRenderer() || !gameHandler_ || !assetManager_ || !assetManager_->isInitialized())
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto* charRenderer = renderer_->getCharacterRenderer();
|
||||||
|
uint32_t charInstanceId = renderer_->getCharacterInstanceId();
|
||||||
|
if (charInstanceId == 0) return;
|
||||||
|
|
||||||
|
if (!show) {
|
||||||
|
// Swap back to normal melee weapons
|
||||||
|
loadEquippedWeapons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto& inventory = gameHandler_->getInventory();
|
||||||
|
const auto& rangedSlot = inventory.getEquipSlot(game::EquipSlot::RANGED);
|
||||||
|
if (rangedSlot.empty() || rangedSlot.item.displayInfoId == 0) return;
|
||||||
|
|
||||||
|
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
|
||||||
|
if (!displayInfoDbc) return;
|
||||||
|
|
||||||
|
uint32_t displayInfoId = rangedSlot.item.displayInfoId;
|
||||||
|
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
|
||||||
|
if (recIdx < 0) return;
|
||||||
|
|
||||||
|
const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
||||||
|
std::string modelName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), idiL ? (*idiL)["LeftModel"] : 1);
|
||||||
|
std::string textureName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3);
|
||||||
|
if (modelName.empty()) return;
|
||||||
|
|
||||||
|
std::string modelFile = modelName;
|
||||||
|
{
|
||||||
|
size_t dotPos = modelFile.rfind('.');
|
||||||
|
if (dotPos != std::string::npos)
|
||||||
|
modelFile = modelFile.substr(0, dotPos) + ".m2";
|
||||||
|
else
|
||||||
|
modelFile += ".m2";
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile;
|
||||||
|
pipeline::M2Model weaponModel;
|
||||||
|
if (!loadWeaponM2(m2Path, weaponModel)) {
|
||||||
|
m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile;
|
||||||
|
if (!loadWeaponM2(m2Path, weaponModel)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string texturePath;
|
||||||
|
if (!textureName.empty()) {
|
||||||
|
texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp";
|
||||||
|
if (!assetManager_->fileExists(texturePath))
|
||||||
|
texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach current right-hand weapon and attach ranged weapon
|
||||||
|
charRenderer->detachWeapon(charInstanceId, 1);
|
||||||
|
uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId();
|
||||||
|
bool ok = charRenderer->attachWeapon(charInstanceId, 1, weaponModel, weaponModelId, texturePath);
|
||||||
|
if (ok) {
|
||||||
|
LOG_INFO("Swapped to ranged weapon: ", m2Path, " at attachment 1 (right hand)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace core
|
} // namespace core
|
||||||
|
|
|
||||||
|
|
@ -980,14 +980,23 @@ void Application::setState(AppState newState) {
|
||||||
if (renderer) {
|
if (renderer) {
|
||||||
// Ranged auto-attack spells: Auto Shot (75), Shoot (5019), Throw (2764)
|
// Ranged auto-attack spells: Auto Shot (75), Shoot (5019), Throw (2764)
|
||||||
if (spellId == 75 || spellId == 5019 || spellId == 2764) {
|
if (spellId == 75 || spellId == 5019 || spellId == 2764) {
|
||||||
|
if (appearanceComposer_ && !appearanceComposer_->isShowingRanged())
|
||||||
|
appearanceComposer_->showRangedWeapon(true);
|
||||||
if (auto* ac = renderer->getAnimationController()) ac->triggerRangedShot();
|
if (auto* ac = renderer->getAnimationController()) ac->triggerRangedShot();
|
||||||
} else if (spellId != 0) {
|
} else if (spellId != 0) {
|
||||||
|
if (appearanceComposer_ && appearanceComposer_->isShowingRanged())
|
||||||
|
appearanceComposer_->showRangedWeapon(false);
|
||||||
if (auto* ac = renderer->getAnimationController()) ac->triggerSpecialAttack(spellId);
|
if (auto* ac = renderer->getAnimationController()) ac->triggerSpecialAttack(spellId);
|
||||||
} else {
|
} else {
|
||||||
|
if (appearanceComposer_ && appearanceComposer_->isShowingRanged())
|
||||||
|
appearanceComposer_->showRangedWeapon(false);
|
||||||
if (auto* ac = renderer->getAnimationController()) ac->triggerMeleeSwing();
|
if (auto* ac = renderer->getAnimationController()) ac->triggerMeleeSwing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
gameHandler->setRangedWeaponSwapCallback([this](bool show) {
|
||||||
|
if (appearanceComposer_) appearanceComposer_->showRangedWeapon(show);
|
||||||
|
});
|
||||||
gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) {
|
gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) {
|
||||||
if (renderer && renderer->getCameraController()) {
|
if (renderer && renderer->getCameraController()) {
|
||||||
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
|
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
|
||||||
|
|
@ -1216,6 +1225,10 @@ void Application::update(float deltaTime) {
|
||||||
appearanceComposer_->setWeaponsSheathed(false);
|
appearanceComposer_->setWeaponsSheathed(false);
|
||||||
appearanceComposer_->loadEquippedWeapons();
|
appearanceComposer_->loadEquippedWeapons();
|
||||||
}
|
}
|
||||||
|
// Swap back to melee weapon when auto-attack stops
|
||||||
|
if (!autoAttacking && wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isShowingRanged()) {
|
||||||
|
appearanceComposer_->showRangedWeapon(false);
|
||||||
|
}
|
||||||
wasAutoAttacking_ = autoAttacking;
|
wasAutoAttacking_ = autoAttacking;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ void EntitySpawner::resetAllState() {
|
||||||
|
|
||||||
// Clear display/spawn caches
|
// Clear display/spawn caches
|
||||||
nonRenderableCreatureDisplayIds_.clear();
|
nonRenderableCreatureDisplayIds_.clear();
|
||||||
|
displayIdModelCache_.clear();
|
||||||
displayIdTexturesApplied_.clear();
|
displayIdTexturesApplied_.clear();
|
||||||
charSectionsCache_.clear();
|
charSectionsCache_.clear();
|
||||||
charSectionsCacheBuilt_ = false;
|
charSectionsCacheBuilt_ = false;
|
||||||
|
|
|
||||||
|
|
@ -166,15 +166,10 @@ void UIScreenCallbackHandler::setupCallbacks() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Character delete result callback
|
// Character delete result callback
|
||||||
gameHandler_.setCharDeleteCallback([this](bool success) {
|
gameHandler_.setCharDeleteCallback([this](bool success, const std::string& message) {
|
||||||
|
uiManager_.getCharacterScreen().setStatus(message, !success);
|
||||||
if (success) {
|
if (success) {
|
||||||
uiManager_.getCharacterScreen().setStatus("Character deleted.");
|
|
||||||
// Refresh character list
|
|
||||||
gameHandler_.requestCharacterList();
|
gameHandler_.requestCharacterList();
|
||||||
} else {
|
|
||||||
uint8_t code = gameHandler_.getLastCharDeleteResult();
|
|
||||||
uiManager_.getCharacterScreen().setStatus(
|
|
||||||
"Delete failed (code " + std::to_string(static_cast<int>(code)) + ").", true);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,14 +206,21 @@ void CombatHandler::startAutoAttack(uint64_t targetGuid) {
|
||||||
owner_.dismount();
|
owner_.dismount();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Client-side melee range gate to avoid starting "swing forever" loops when
|
// Client-side range gate to avoid starting "swing forever" loops when
|
||||||
// target is already clearly out of range.
|
// target is already clearly out of range.
|
||||||
if (auto target = owner_.getEntityManager().getEntity(targetGuid)) {
|
if (auto target = owner_.getEntityManager().getEntity(targetGuid)) {
|
||||||
float dx = owner_.movementInfoRef().x - target->getLatestX();
|
float dx = owner_.movementInfoRef().x - target->getLatestX();
|
||||||
float dy = owner_.movementInfoRef().y - target->getLatestY();
|
float dy = owner_.movementInfoRef().y - target->getLatestY();
|
||||||
float dz = owner_.movementInfoRef().z - target->getLatestZ();
|
float dz = owner_.movementInfoRef().z - target->getLatestZ();
|
||||||
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
|
float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
if (dist3d > 8.0f) {
|
// Use longer range limit when a ranged weapon is equipped
|
||||||
|
const auto& rangedSlot = owner_.getInventory().getEquipSlot(game::EquipSlot::RANGED);
|
||||||
|
bool hasRangedWeapon = !rangedSlot.empty() &&
|
||||||
|
(rangedSlot.item.inventoryType == game::InvType::RANGED_BOW ||
|
||||||
|
rangedSlot.item.inventoryType == game::InvType::RANGED_GUN ||
|
||||||
|
rangedSlot.item.inventoryType == game::InvType::THROWN);
|
||||||
|
float maxRange = hasRangedWeapon ? 40.0f : 8.0f;
|
||||||
|
if (dist3d > maxRange) {
|
||||||
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
|
||||||
owner_.addSystemChatMessage("Target is too far away.");
|
owner_.addSystemChatMessage("Target is too far away.");
|
||||||
autoAttackRangeWarnCooldown_ = 1.25f;
|
autoAttackRangeWarnCooldown_ = 1.25f;
|
||||||
|
|
@ -443,8 +450,15 @@ void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) {
|
||||||
lastMeleeSwingMs_ = static_cast<uint64_t>(
|
lastMeleeSwingMs_ = static_cast<uint64_t>(
|
||||||
std::chrono::duration_cast<std::chrono::milliseconds>(
|
std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||||
std::chrono::system_clock::now().time_since_epoch()).count());
|
std::chrono::system_clock::now().time_since_epoch()).count());
|
||||||
|
// Skip melee animation if a ranged shot was just triggered from
|
||||||
|
// SMSG_SPELL_GO (Auto Shot / Shoot / Throw). The ranged animation
|
||||||
|
// is already playing; firing the melee callback here would override it.
|
||||||
|
if (owner_.consumeSuppressMeleeSwingAnim()) {
|
||||||
|
LOG_DEBUG("Suppressed melee swing anim — ranged shot already triggered");
|
||||||
|
} else {
|
||||||
if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(0);
|
if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!isPlayerAttacker && owner_.npcSwingCallbackRef()) {
|
if (!isPlayerAttacker && owner_.npcSwingCallbackRef()) {
|
||||||
owner_.npcSwingCallbackRef()(data.attackerGuid);
|
owner_.npcSwingCallbackRef()(data.attackerGuid);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,17 +73,24 @@ EntityController::EntityController(GameHandler& owner)
|
||||||
: owner_(owner) { initTypeHandlers(); }
|
: owner_(owner) { initTypeHandlers(); }
|
||||||
|
|
||||||
void EntityController::registerOpcodes(DispatchTable& table) {
|
void EntityController::registerOpcodes(DispatchTable& table) {
|
||||||
// World object updates
|
// World object updates — accept during ENTERING_WORLD too so that entity
|
||||||
table[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) {
|
// packets arriving before SMSG_LOGIN_VERIFY_WORLD are parsed and queued
|
||||||
|
// rather than silently dropped (the budget system processes them later once
|
||||||
|
// the state transitions to IN_WORLD).
|
||||||
|
auto inWorldOrEntering = [this]() {
|
||||||
|
auto s = owner_.getState();
|
||||||
|
return s == WorldState::IN_WORLD || s == WorldState::ENTERING_WORLD;
|
||||||
|
};
|
||||||
|
table[Opcode::SMSG_UPDATE_OBJECT] = [this, inWorldOrEntering](network::Packet& packet) {
|
||||||
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(owner_.getState()), " size=", packet.getSize());
|
LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast<int>(owner_.getState()), " size=", packet.getSize());
|
||||||
if (owner_.getState() == WorldState::IN_WORLD) handleUpdateObject(packet);
|
if (inWorldOrEntering()) handleUpdateObject(packet);
|
||||||
};
|
};
|
||||||
table[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) {
|
table[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this, inWorldOrEntering](network::Packet& packet) {
|
||||||
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(owner_.getState()), " size=", packet.getSize());
|
LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast<int>(owner_.getState()), " size=", packet.getSize());
|
||||||
if (owner_.getState() == WorldState::IN_WORLD) handleCompressedUpdateObject(packet);
|
if (inWorldOrEntering()) handleCompressedUpdateObject(packet);
|
||||||
};
|
};
|
||||||
table[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) {
|
table[Opcode::SMSG_DESTROY_OBJECT] = [this, inWorldOrEntering](network::Packet& packet) {
|
||||||
if (owner_.getState() == WorldState::IN_WORLD) handleDestroyObject(packet);
|
if (inWorldOrEntering()) handleDestroyObject(packet);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Entity queries
|
// Entity queries
|
||||||
|
|
|
||||||
|
|
@ -756,6 +756,43 @@ void GameHandler::update(float deltaTime) {
|
||||||
updateNetworking(deltaTime);
|
updateNetworking(deltaTime);
|
||||||
if (!socket) return; // disconnect() may have been called
|
if (!socket) return; // disconnect() may have been called
|
||||||
|
|
||||||
|
// Fallback for CMSG_CHAR_DELETE with no server response: if the server
|
||||||
|
// doesn't send SMSG_CHAR_DELETE within 3 seconds, re-request the character
|
||||||
|
// list. Some server cores silently process the delete without responding.
|
||||||
|
if (pendingCharDeleteResponse_) {
|
||||||
|
pendingDeleteTimer_ += deltaTime;
|
||||||
|
if (pendingDeleteTimer_ >= 3.0f) {
|
||||||
|
LOG_WARNING("No SMSG_CHAR_DELETE response after 3s — requesting character list to verify");
|
||||||
|
pendingCharDeleteResponse_ = false;
|
||||||
|
pendingDeleteFallbackEnum_ = true;
|
||||||
|
requestCharacterList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the fallback SMSG_CHAR_ENUM has been processed, check if the
|
||||||
|
// character was actually removed and fire the delete callback.
|
||||||
|
if (pendingDeleteFallbackEnum_ && state == WorldState::CHAR_LIST_RECEIVED) {
|
||||||
|
pendingDeleteFallbackEnum_ = false;
|
||||||
|
uint64_t deletedGuid = pendingDeleteGuid_;
|
||||||
|
pendingDeleteGuid_ = 0;
|
||||||
|
bool found = false;
|
||||||
|
for (const auto& ch : characters) {
|
||||||
|
if (ch.guid == deletedGuid) { found = true; break; }
|
||||||
|
}
|
||||||
|
bool deleted = !found;
|
||||||
|
LOG_INFO("Char delete fallback: GUID 0x", std::hex, deletedGuid, std::dec,
|
||||||
|
deleted ? " was deleted" : " still exists");
|
||||||
|
std::string msg;
|
||||||
|
if (deleted) {
|
||||||
|
msg = "Character deleted.";
|
||||||
|
} else {
|
||||||
|
msg = "Delete failed: the server did not respond. "
|
||||||
|
"This usually happens if you recently logged out — "
|
||||||
|
"wait 20-30 seconds and try again.";
|
||||||
|
}
|
||||||
|
if (charDeleteCallback_) charDeleteCallback_(deleted, msg);
|
||||||
|
}
|
||||||
|
|
||||||
// Validate target still exists
|
// Validate target still exists
|
||||||
if (targetGuid != 0 && !entityController_->getEntityManager().hasEntity(targetGuid)) {
|
if (targetGuid != 0 && !entityController_->getEntityManager().hasEntity(targetGuid)) {
|
||||||
clearTarget();
|
clearTarget();
|
||||||
|
|
|
||||||
|
|
@ -342,13 +342,16 @@ void GameHandler::handleCharCreateResponse(network::Packet& packet) {
|
||||||
|
|
||||||
void GameHandler::deleteCharacter(uint64_t characterGuid) {
|
void GameHandler::deleteCharacter(uint64_t characterGuid) {
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
if (charDeleteCallback_) charDeleteCallback_(false);
|
if (charDeleteCallback_) charDeleteCallback_(false, "Delete failed: not connected to server.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE));
|
network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE));
|
||||||
packet.writeUInt64(characterGuid);
|
packet.writeUInt64(characterGuid);
|
||||||
socket->send(packet);
|
socket->send(packet);
|
||||||
|
pendingCharDeleteResponse_ = true;
|
||||||
|
pendingDeleteGuid_ = characterGuid;
|
||||||
|
pendingDeleteTimer_ = 0.0f;
|
||||||
LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec);
|
LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,10 +151,23 @@ void GameHandler::registerOpcodeHandlers() {
|
||||||
dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) {
|
dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) {
|
||||||
uint8_t result = packet.readUInt8();
|
uint8_t result = packet.readUInt8();
|
||||||
lastCharDeleteResult_ = result;
|
lastCharDeleteResult_ = result;
|
||||||
|
pendingCharDeleteResponse_ = false;
|
||||||
bool success = (result == 0x00 || result == 0x47);
|
bool success = (result == 0x00 || result == 0x47);
|
||||||
LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast<int>(result), success ? " (success)" : " (failed)");
|
LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast<int>(result), success ? " (success)" : " (failed)");
|
||||||
requestCharacterList();
|
requestCharacterList();
|
||||||
if (charDeleteCallback_) charDeleteCallback_(success);
|
std::string msg;
|
||||||
|
if (success) {
|
||||||
|
msg = "Character deleted.";
|
||||||
|
} else {
|
||||||
|
// Map known CHAR_DELETE_* result codes to user-friendly messages
|
||||||
|
switch (result) {
|
||||||
|
case 0x31: msg = "Delete failed: character is a guild leader. Transfer leadership first."; break;
|
||||||
|
case 0x32: msg = "Delete failed: character is in an arena team."; break;
|
||||||
|
case 0x3A: msg = "Delete failed: character has mail. Check mailbox first."; break;
|
||||||
|
default: msg = "Delete failed (server error code " + std::to_string(static_cast<int>(result)) + ")."; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (charDeleteCallback_) charDeleteCallback_(success, msg);
|
||||||
};
|
};
|
||||||
dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) {
|
dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) {
|
||||||
if (state == WorldState::CHAR_LIST_REQUESTED)
|
if (state == WorldState::CHAR_LIST_REQUESTED)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
#include "game/game_handler.hpp"
|
#include "game/game_handler.hpp"
|
||||||
#include "game/game_utils.hpp"
|
#include "game/game_utils.hpp"
|
||||||
#include "game/packet_parsers.hpp"
|
#include "game/packet_parsers.hpp"
|
||||||
|
#include "game/spline_packet.hpp"
|
||||||
#include "game/transport_manager.hpp"
|
#include "game/transport_manager.hpp"
|
||||||
#include "game/entity.hpp"
|
#include "game/entity.hpp"
|
||||||
#include "network/world_socket.hpp"
|
#include "network/world_socket.hpp"
|
||||||
|
|
@ -1572,52 +1573,17 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) {
|
||||||
if (packet.getReadPos() + 4 > packet.getSize()) return;
|
if (packet.getReadPos() + 4 > packet.getSize()) return;
|
||||||
uint32_t splineFlags = packet.readUInt32();
|
uint32_t splineFlags = packet.readUInt32();
|
||||||
|
|
||||||
if (splineFlags & 0x00400000) {
|
// Consolidated spline body parser
|
||||||
if (packet.getReadPos() + 5 > packet.getSize()) return;
|
SplineBlockData spline;
|
||||||
packet.readUInt8(); packet.readUInt32();
|
if (!parseMonsterMoveSplineBody(packet, spline, splineFlags,
|
||||||
}
|
glm::vec3(localX, localY, localZ))) {
|
||||||
|
return;
|
||||||
if (packet.getReadPos() + 4 > packet.getSize()) return;
|
|
||||||
uint32_t duration = packet.readUInt32();
|
|
||||||
|
|
||||||
if (splineFlags & 0x00000800) {
|
|
||||||
if (packet.getReadPos() + 8 > packet.getSize()) return;
|
|
||||||
packet.readFloat(); packet.readUInt32();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (packet.getReadPos() + 4 > packet.getSize()) return;
|
|
||||||
uint32_t pointCount = packet.readUInt32();
|
|
||||||
constexpr uint32_t kMaxTransportSplinePoints = 1000;
|
|
||||||
if (pointCount > kMaxTransportSplinePoints) {
|
|
||||||
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount,
|
|
||||||
" clamped to ", kMaxTransportSplinePoints);
|
|
||||||
pointCount = kMaxTransportSplinePoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
float destLocalX = localX, destLocalY = localY, destLocalZ = localZ;
|
|
||||||
bool hasDest = false;
|
|
||||||
if (pointCount > 0) {
|
|
||||||
const bool uncompressed = (splineFlags & (0x00080000 | 0x00002000)) != 0;
|
|
||||||
if (uncompressed) {
|
|
||||||
for (uint32_t i = 0; i < pointCount - 1; ++i) {
|
|
||||||
if (packet.getReadPos() + 12 > packet.getSize()) break;
|
|
||||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
|
||||||
}
|
|
||||||
if (packet.getReadPos() + 12 <= packet.getSize()) {
|
|
||||||
destLocalX = packet.readFloat();
|
|
||||||
destLocalY = packet.readFloat();
|
|
||||||
destLocalZ = packet.readFloat();
|
|
||||||
hasDest = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (packet.getReadPos() + 12 <= packet.getSize()) {
|
|
||||||
destLocalX = packet.readFloat();
|
|
||||||
destLocalY = packet.readFloat();
|
|
||||||
destLocalZ = packet.readFloat();
|
|
||||||
hasDest = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
uint32_t duration = spline.duration;
|
||||||
|
float destLocalX = spline.hasDest ? spline.destination.x : localX;
|
||||||
|
float destLocalY = spline.hasDest ? spline.destination.y : localY;
|
||||||
|
float destLocalZ = spline.hasDest ? spline.destination.z : localZ;
|
||||||
|
bool hasDest = spline.hasDest;
|
||||||
|
|
||||||
if (!owner_.getTransportManager()) {
|
if (!owner_.getTransportManager()) {
|
||||||
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x",
|
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "game/packet_parsers.hpp"
|
#include "game/packet_parsers.hpp"
|
||||||
|
#include "game/spline_packet.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
|
@ -258,45 +259,8 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
|
||||||
|
|
||||||
// Spline data (Classic: SPLINE_ENABLED=0x00400000)
|
// Spline data (Classic: SPLINE_ENABLED=0x00400000)
|
||||||
if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) {
|
if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) {
|
||||||
if (rem() < 4) return false;
|
SplineBlockData splineData;
|
||||||
uint32_t splineFlags = packet.readUInt32();
|
if (!parseClassicMoveUpdateSpline(packet, splineData)) return false;
|
||||||
LOG_DEBUG(" [Classic] Spline: flags=0x", std::hex, splineFlags, std::dec);
|
|
||||||
|
|
||||||
if (splineFlags & 0x00010000) { // FINAL_POINT
|
|
||||||
if (rem() < 12) return false;
|
|
||||||
/*float finalX =*/ packet.readFloat();
|
|
||||||
/*float finalY =*/ packet.readFloat();
|
|
||||||
/*float finalZ =*/ packet.readFloat();
|
|
||||||
} else if (splineFlags & 0x00020000) { // FINAL_TARGET
|
|
||||||
if (rem() < 8) return false;
|
|
||||||
/*uint64_t finalTarget =*/ packet.readUInt64();
|
|
||||||
} else if (splineFlags & 0x00040000) { // FINAL_ANGLE
|
|
||||||
if (rem() < 4) return false;
|
|
||||||
/*float finalAngle =*/ packet.readFloat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classic spline: timePassed, duration, id, pointCount
|
|
||||||
if (rem() < 16) return false;
|
|
||||||
/*uint32_t timePassed =*/ packet.readUInt32();
|
|
||||||
/*uint32_t duration =*/ packet.readUInt32();
|
|
||||||
/*uint32_t splineId =*/ packet.readUInt32();
|
|
||||||
|
|
||||||
uint32_t pointCount = packet.readUInt32();
|
|
||||||
// Cap waypoints to prevent DoS from malformed packets allocating huge arrays
|
|
||||||
if (pointCount > 256) return false;
|
|
||||||
|
|
||||||
// points + endPoint (no splineMode in Classic)
|
|
||||||
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
|
|
||||||
for (uint32_t i = 0; i < pointCount; i++) {
|
|
||||||
/*float px =*/ packet.readFloat();
|
|
||||||
/*float py =*/ packet.readFloat();
|
|
||||||
/*float pz =*/ packet.readFloat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classic: NO splineMode byte
|
|
||||||
/*float endPointX =*/ packet.readFloat();
|
|
||||||
/*float endPointY =*/ packet.readFloat();
|
|
||||||
/*float endPointZ =*/ packet.readFloat();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
|
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
|
||||||
|
|
@ -2004,45 +1968,8 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
|
||||||
bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) ||
|
bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) ||
|
||||||
(moveFlags & TurtleMoveFlags::SPLINE_TBC);
|
(moveFlags & TurtleMoveFlags::SPLINE_TBC);
|
||||||
if (hasSpline) {
|
if (hasSpline) {
|
||||||
if (rem() < 4) return false;
|
SplineBlockData splineData;
|
||||||
uint32_t splineFlags = packet.readUInt32();
|
if (!parseClassicMoveUpdateSpline(packet, splineData)) return false;
|
||||||
LOG_DEBUG(" [Turtle] Spline: flags=0x", std::hex, splineFlags, std::dec);
|
|
||||||
|
|
||||||
if (splineFlags & 0x00010000) {
|
|
||||||
if (rem() < 12) return false;
|
|
||||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
|
||||||
} else if (splineFlags & 0x00020000) {
|
|
||||||
if (rem() < 8) return false;
|
|
||||||
packet.readUInt64();
|
|
||||||
} else if (splineFlags & 0x00040000) {
|
|
||||||
if (rem() < 4) return false;
|
|
||||||
packet.readFloat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// timePassed + duration + splineId + pointCount = 16 bytes
|
|
||||||
if (rem() < 16) return false;
|
|
||||||
/*uint32_t timePassed =*/ packet.readUInt32();
|
|
||||||
/*uint32_t duration =*/ packet.readUInt32();
|
|
||||||
/*uint32_t splineId =*/ packet.readUInt32();
|
|
||||||
|
|
||||||
uint32_t pointCount = packet.readUInt32();
|
|
||||||
if (pointCount > 256) {
|
|
||||||
static uint32_t badTurtleSplineCount = 0;
|
|
||||||
++badTurtleSplineCount;
|
|
||||||
if (badTurtleSplineCount <= 5 || (badTurtleSplineCount % 100) == 0) {
|
|
||||||
LOG_WARNING(" [Turtle] Spline pointCount=", pointCount,
|
|
||||||
" exceeds max (occurrence=", badTurtleSplineCount, ")");
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// points + endPoint
|
|
||||||
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
|
|
||||||
for (uint32_t i = 0; i < pointCount; i++) {
|
|
||||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// End point
|
|
||||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_DEBUG(" [Turtle] LIVING block consumed ", packet.getReadPos() - livingStart,
|
LOG_DEBUG(" [Turtle] LIVING block consumed ", packet.getReadPos() - livingStart,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
#include "game/packet_parsers.hpp"
|
#include "game/packet_parsers.hpp"
|
||||||
|
#include "game/spline_packet.hpp"
|
||||||
#include "core/logger.hpp"
|
#include "core/logger.hpp"
|
||||||
|
|
||||||
namespace wowee {
|
namespace wowee {
|
||||||
|
|
@ -670,47 +671,21 @@ bool TbcPacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData
|
||||||
if (!packet.hasRemaining(4)) return false;
|
if (!packet.hasRemaining(4)) return false;
|
||||||
data.splineFlags = packet.readUInt32();
|
data.splineFlags = packet.readUInt32();
|
||||||
|
|
||||||
// TBC 2.4.3 SplineFlags animation bit is same as WotLK: 0x00400000
|
// Consolidated spline body parser (TBC uses different uncompressed mask)
|
||||||
if (data.splineFlags & 0x00400000) {
|
{
|
||||||
if (!packet.hasRemaining(5)) return false;
|
SplineBlockData spline;
|
||||||
packet.readUInt8(); // animationType
|
if (!parseMonsterMoveSplineBody(packet, spline, data.splineFlags,
|
||||||
packet.readUInt32(); // effectStartTime
|
glm::vec3(data.x, data.y, data.z), true)) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
data.duration = spline.duration;
|
||||||
if (!packet.hasRemaining(4)) return false;
|
if (spline.hasDest) {
|
||||||
data.duration = packet.readUInt32();
|
data.destX = spline.destination.x;
|
||||||
|
data.destY = spline.destination.y;
|
||||||
if (data.splineFlags & 0x00000800) {
|
data.destZ = spline.destination.z;
|
||||||
if (!packet.hasRemaining(8)) return false;
|
|
||||||
packet.readFloat(); // verticalAcceleration
|
|
||||||
packet.readUInt32(); // effectStartTime
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!packet.hasRemaining(4)) return false;
|
|
||||||
uint32_t pointCount = packet.readUInt32();
|
|
||||||
if (pointCount == 0) return true;
|
|
||||||
if (pointCount > 16384) return false;
|
|
||||||
|
|
||||||
// Spline points are stored uncompressed when Catmull-Rom interpolation (0x80000)
|
|
||||||
// or linear movement (0x2000) flags are set; otherwise they use packed delta format
|
|
||||||
bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0;
|
|
||||||
if (uncompressed) {
|
|
||||||
for (uint32_t i = 0; i < pointCount - 1; i++) {
|
|
||||||
if (!packet.hasRemaining(12)) return true;
|
|
||||||
packet.readFloat(); packet.readFloat(); packet.readFloat();
|
|
||||||
}
|
|
||||||
if (!packet.hasRemaining(12)) return true;
|
|
||||||
data.destX = packet.readFloat();
|
|
||||||
data.destY = packet.readFloat();
|
|
||||||
data.destZ = packet.readFloat();
|
|
||||||
data.hasDest = true;
|
|
||||||
} else {
|
|
||||||
if (!packet.hasRemaining(12)) return true;
|
|
||||||
data.destX = packet.readFloat();
|
|
||||||
data.destY = packet.readFloat();
|
|
||||||
data.destZ = packet.readFloat();
|
|
||||||
data.hasDest = true;
|
data.hasDest = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LOG_DEBUG("[TBC] MonsterMove: guid=0x", std::hex, data.guid, std::dec,
|
LOG_DEBUG("[TBC] MonsterMove: guid=0x", std::hex, data.guid, std::dec,
|
||||||
" type=", static_cast<int>(data.moveType), " dur=", data.duration, "ms",
|
" type=", static_cast<int>(data.moveType), " dur=", data.duration, "ms",
|
||||||
|
|
|
||||||
|
|
@ -1025,8 +1025,16 @@ void SpellHandler::handleSpellGo(network::Packet& packet) {
|
||||||
if (!owner_.isProfessionSpell(data.spellId))
|
if (!owner_.isProfessionSpell(data.spellId))
|
||||||
playSpellCastSound(data.spellId);
|
playSpellCastSound(data.spellId);
|
||||||
|
|
||||||
// Instant melee abilities → trigger attack animation
|
// Ranged auto-attack spells (Auto Shot, Shoot, Throw) complete as timed
|
||||||
|
// casts and are NOT classified as instant melee abilities, so trigger the
|
||||||
|
// ranged shot animation explicitly here.
|
||||||
uint32_t sid = data.spellId;
|
uint32_t sid = data.spellId;
|
||||||
|
if (sid == 75 || sid == 5019 || sid == 2764) {
|
||||||
|
if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(sid);
|
||||||
|
owner_.suppressNextMeleeSwingAnim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instant melee abilities → trigger attack animation
|
||||||
bool isMeleeAbility = false;
|
bool isMeleeAbility = false;
|
||||||
if (!owner_.isProfessionSpell(sid)) {
|
if (!owner_.isProfessionSpell(sid)) {
|
||||||
owner_.loadSpellNameCache();
|
owner_.loadSpellNameCache();
|
||||||
|
|
|
||||||
450
src/game/spline_packet.cpp
Normal file
450
src/game/spline_packet.cpp
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
// src/game/spline_packet.cpp
|
||||||
|
// Consolidated spline packet parsing — replaces 7 duplicated parsing locations.
|
||||||
|
// Ported from: world_packets.cpp, world_packets_entity.cpp, packet_parsers_classic.cpp,
|
||||||
|
// packet_parsers_tbc.cpp, movement_handler.cpp.
|
||||||
|
#include "game/spline_packet.hpp"
|
||||||
|
#include "core/logger.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
// ── Packed-delta decoding ───────────────────────────────────────
|
||||||
|
|
||||||
|
glm::vec3 decodePackedDelta(uint32_t packed, const glm::vec3& midpoint) {
|
||||||
|
// 11-bit signed X, 11-bit signed Y, 10-bit signed Z
|
||||||
|
// Scaled by 0.25, subtracted from midpoint
|
||||||
|
int32_t sx = static_cast<int32_t>(packed & 0x7FF);
|
||||||
|
if (sx & 0x400) sx |= static_cast<int32_t>(0xFFFFF800); // sign-extend 11-bit
|
||||||
|
|
||||||
|
int32_t sy = static_cast<int32_t>((packed >> 11) & 0x7FF);
|
||||||
|
if (sy & 0x400) sy |= static_cast<int32_t>(0xFFFFF800); // sign-extend 11-bit
|
||||||
|
|
||||||
|
int32_t sz = static_cast<int32_t>((packed >> 22) & 0x3FF);
|
||||||
|
if (sz & 0x200) sz |= static_cast<int32_t>(0xFFFFFC00); // sign-extend 10-bit
|
||||||
|
|
||||||
|
return glm::vec3(
|
||||||
|
midpoint.x - static_cast<float>(sx) * 0.25f,
|
||||||
|
midpoint.y - static_cast<float>(sy) * 0.25f,
|
||||||
|
midpoint.z - static_cast<float>(sz) * 0.25f
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MonsterMove spline body (post-splineFlags) ─────────────────
|
||||||
|
|
||||||
|
bool parseMonsterMoveSplineBody(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out,
|
||||||
|
uint32_t splineFlags,
|
||||||
|
const glm::vec3& startPos,
|
||||||
|
bool useTbcUncompressedMask)
|
||||||
|
{
|
||||||
|
out.splineFlags = splineFlags;
|
||||||
|
|
||||||
|
// Animation (0x00400000): uint8 animType + uint32 animStartTime
|
||||||
|
if (splineFlags & SplineFlag::ANIMATION) {
|
||||||
|
if (!packet.hasRemaining(5)) return false;
|
||||||
|
out.hasAnimation = true;
|
||||||
|
out.animationType = packet.readUInt8();
|
||||||
|
out.animationStartTime = packet.readUInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
if (!packet.hasRemaining(4)) return false;
|
||||||
|
out.duration = packet.readUInt32();
|
||||||
|
|
||||||
|
// Parabolic (0x00000800 in MonsterMove): float vertAccel + uint32 startTime
|
||||||
|
if (splineFlags & SplineFlag::PARABOLIC_MM) {
|
||||||
|
if (!packet.hasRemaining(8)) return false;
|
||||||
|
out.hasParabolic = true;
|
||||||
|
out.verticalAcceleration = packet.readFloat();
|
||||||
|
out.parabolicStartTime = packet.readUInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point count
|
||||||
|
if (!packet.hasRemaining(4)) return false;
|
||||||
|
uint32_t pointCount = packet.readUInt32();
|
||||||
|
if (pointCount == 0) return true;
|
||||||
|
if (pointCount > 1000) return false;
|
||||||
|
|
||||||
|
// Determine compressed vs uncompressed
|
||||||
|
uint32_t uncompMask = useTbcUncompressedMask
|
||||||
|
? SplineFlag::UNCOMPRESSED_MASK_TBC
|
||||||
|
: SplineFlag::UNCOMPRESSED_MASK;
|
||||||
|
bool uncompressed = (splineFlags & uncompMask) != 0;
|
||||||
|
|
||||||
|
if (uncompressed) {
|
||||||
|
// All waypoints as absolute float3, last one is destination
|
||||||
|
for (uint32_t i = 0; i + 1 < pointCount; ++i) {
|
||||||
|
if (!packet.hasRemaining(12)) return true; // Partial parse OK
|
||||||
|
float wx = packet.readFloat();
|
||||||
|
float wy = packet.readFloat();
|
||||||
|
float wz = packet.readFloat();
|
||||||
|
out.waypoints.push_back(glm::vec3(wx, wy, wz));
|
||||||
|
}
|
||||||
|
if (!packet.hasRemaining(12)) return true;
|
||||||
|
out.destination.x = packet.readFloat();
|
||||||
|
out.destination.y = packet.readFloat();
|
||||||
|
out.destination.z = packet.readFloat();
|
||||||
|
out.hasDest = true;
|
||||||
|
} else {
|
||||||
|
// Compressed: first float3 is destination, rest are packed deltas from midpoint
|
||||||
|
if (!packet.hasRemaining(12)) return true;
|
||||||
|
out.destination.x = packet.readFloat();
|
||||||
|
out.destination.y = packet.readFloat();
|
||||||
|
out.destination.z = packet.readFloat();
|
||||||
|
out.hasDest = true;
|
||||||
|
|
||||||
|
if (pointCount > 1) {
|
||||||
|
glm::vec3 mid = (startPos + out.destination) * 0.5f;
|
||||||
|
for (uint32_t i = 0; i + 1 < pointCount; ++i) {
|
||||||
|
if (!packet.hasRemaining(4)) break;
|
||||||
|
uint32_t packed = packet.readUInt32();
|
||||||
|
out.waypoints.push_back(decodePackedDelta(packed, mid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vanilla MonsterMove spline body (always compressed) ─────────
|
||||||
|
|
||||||
|
bool parseMonsterMoveSplineBodyVanilla(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out,
|
||||||
|
uint32_t splineFlags,
|
||||||
|
const glm::vec3& startPos)
|
||||||
|
{
|
||||||
|
out.splineFlags = splineFlags;
|
||||||
|
|
||||||
|
// Animation (0x00400000): uint8 animType + uint32 animStartTime
|
||||||
|
if (splineFlags & SplineFlag::ANIMATION) {
|
||||||
|
if (!packet.hasRemaining(5)) return false;
|
||||||
|
out.hasAnimation = true;
|
||||||
|
out.animationType = packet.readUInt8();
|
||||||
|
out.animationStartTime = packet.readUInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duration
|
||||||
|
if (!packet.hasRemaining(4)) return false;
|
||||||
|
out.duration = packet.readUInt32();
|
||||||
|
|
||||||
|
// Parabolic (0x00000800)
|
||||||
|
if (splineFlags & SplineFlag::PARABOLIC_MM) {
|
||||||
|
if (!packet.hasRemaining(8)) return false;
|
||||||
|
out.hasParabolic = true;
|
||||||
|
out.verticalAcceleration = packet.readFloat();
|
||||||
|
out.parabolicStartTime = packet.readUInt32();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Point count
|
||||||
|
if (!packet.hasRemaining(4)) return false;
|
||||||
|
uint32_t pointCount = packet.readUInt32();
|
||||||
|
if (pointCount == 0) return true;
|
||||||
|
if (pointCount > 1000) return false;
|
||||||
|
|
||||||
|
// Always compressed in Vanilla: dest (12 bytes) + packed deltas (4 bytes each)
|
||||||
|
size_t requiredBytes = 12;
|
||||||
|
if (pointCount > 1) requiredBytes += static_cast<size_t>(pointCount - 1) * 4ull;
|
||||||
|
if (!packet.hasRemaining(requiredBytes)) return false;
|
||||||
|
|
||||||
|
out.destination.x = packet.readFloat();
|
||||||
|
out.destination.y = packet.readFloat();
|
||||||
|
out.destination.z = packet.readFloat();
|
||||||
|
out.hasDest = true;
|
||||||
|
|
||||||
|
if (pointCount > 1) {
|
||||||
|
glm::vec3 mid = (startPos + out.destination) * 0.5f;
|
||||||
|
for (uint32_t i = 0; i + 1 < pointCount; ++i) {
|
||||||
|
uint32_t packed = packet.readUInt32();
|
||||||
|
out.waypoints.push_back(decodePackedDelta(packed, mid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Classic/Turtle movement update spline block ─────────────────
|
||||||
|
|
||||||
|
bool parseClassicMoveUpdateSpline(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out)
|
||||||
|
{
|
||||||
|
// splineFlags
|
||||||
|
if (!packet.hasRemaining(4)) return false;
|
||||||
|
out.splineFlags = packet.readUInt32();
|
||||||
|
LOG_DEBUG(" [Classic] Spline: flags=0x", std::hex, out.splineFlags, std::dec);
|
||||||
|
|
||||||
|
// FINAL_POINT / FINAL_TARGET / FINAL_ANGLE
|
||||||
|
if (out.splineFlags & SplineFlag::FINAL_POINT) {
|
||||||
|
if (!packet.hasRemaining(12)) return false;
|
||||||
|
out.hasFinalPoint = true;
|
||||||
|
out.finalPoint.x = packet.readFloat();
|
||||||
|
out.finalPoint.y = packet.readFloat();
|
||||||
|
out.finalPoint.z = packet.readFloat();
|
||||||
|
} else if (out.splineFlags & SplineFlag::FINAL_TARGET) {
|
||||||
|
if (!packet.hasRemaining(8)) return false;
|
||||||
|
out.hasFinalTarget = true;
|
||||||
|
out.finalTarget = packet.readUInt64();
|
||||||
|
} else if (out.splineFlags & SplineFlag::FINAL_ANGLE) {
|
||||||
|
if (!packet.hasRemaining(4)) return false;
|
||||||
|
out.hasFinalAngle = true;
|
||||||
|
out.finalAngle = packet.readFloat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// timePassed + duration + splineId + pointCount = 16 bytes
|
||||||
|
if (!packet.hasRemaining(16)) return false;
|
||||||
|
out.timePassed = packet.readUInt32();
|
||||||
|
out.duration = packet.readUInt32();
|
||||||
|
out.splineId = packet.readUInt32();
|
||||||
|
|
||||||
|
uint32_t pointCount = packet.readUInt32();
|
||||||
|
if (pointCount > 256) return false;
|
||||||
|
|
||||||
|
// All points uncompressed (12 bytes each) + endPoint (12 bytes)
|
||||||
|
// Classic: NO splineMode byte
|
||||||
|
if (!packet.hasRemaining(static_cast<size_t>(pointCount) * 12 + 12)) return false;
|
||||||
|
for (uint32_t i = 0; i < pointCount; ++i) {
|
||||||
|
float px = packet.readFloat();
|
||||||
|
float py = packet.readFloat();
|
||||||
|
float pz = packet.readFloat();
|
||||||
|
out.waypoints.push_back(glm::vec3(px, py, pz));
|
||||||
|
}
|
||||||
|
|
||||||
|
out.endPoint.x = packet.readFloat();
|
||||||
|
out.endPoint.y = packet.readFloat();
|
||||||
|
out.endPoint.z = packet.readFloat();
|
||||||
|
out.hasEndPoint = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WotLK movement update spline block ──────────────────────────
|
||||||
|
// Complex multi-try parser for different server variations.
|
||||||
|
|
||||||
|
bool parseWotlkMoveUpdateSpline(
|
||||||
|
network::Packet& packet,
|
||||||
|
SplineBlockData& out,
|
||||||
|
const glm::vec3& entityPos)
|
||||||
|
{
|
||||||
|
auto bytesAvailable = [&](size_t n) -> bool { return packet.hasRemaining(n); };
|
||||||
|
|
||||||
|
// splineFlags
|
||||||
|
if (!bytesAvailable(4)) return false;
|
||||||
|
out.splineFlags = packet.readUInt32();
|
||||||
|
LOG_DEBUG(" Spline: flags=0x", std::hex, out.splineFlags, std::dec);
|
||||||
|
|
||||||
|
// FINAL_POINT / FINAL_TARGET / FINAL_ANGLE
|
||||||
|
if (out.splineFlags & SplineFlag::FINAL_POINT) {
|
||||||
|
if (!bytesAvailable(12)) return false;
|
||||||
|
out.hasFinalPoint = true;
|
||||||
|
out.finalPoint.x = packet.readFloat();
|
||||||
|
out.finalPoint.y = packet.readFloat();
|
||||||
|
out.finalPoint.z = packet.readFloat();
|
||||||
|
} else if (out.splineFlags & SplineFlag::FINAL_TARGET) {
|
||||||
|
if (!bytesAvailable(8)) return false;
|
||||||
|
out.hasFinalTarget = true;
|
||||||
|
out.finalTarget = packet.readUInt64();
|
||||||
|
} else if (out.splineFlags & SplineFlag::FINAL_ANGLE) {
|
||||||
|
if (!bytesAvailable(4)) return false;
|
||||||
|
out.hasFinalAngle = true;
|
||||||
|
out.finalAngle = packet.readFloat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// timePassed + duration + splineId
|
||||||
|
if (!bytesAvailable(12)) return false;
|
||||||
|
out.timePassed = packet.readUInt32();
|
||||||
|
out.duration = packet.readUInt32();
|
||||||
|
out.splineId = packet.readUInt32();
|
||||||
|
|
||||||
|
// ── Helper: try to parse spline points + splineMode + endPoint ──
|
||||||
|
// WotLK uses compressed points by default (first=12 bytes, rest=4 bytes packed).
|
||||||
|
auto tryParseSplinePoints = [&](bool compressed, const char* tag) -> bool {
|
||||||
|
if (!bytesAvailable(4)) return false;
|
||||||
|
size_t prePointCount = packet.getReadPos();
|
||||||
|
uint32_t pc = packet.readUInt32();
|
||||||
|
if (pc > 256) return false;
|
||||||
|
// Zero-point splines (e.g. FINAL_TARGET "follow" splines) have no
|
||||||
|
// splineMode or endPoint written — return immediately.
|
||||||
|
if (pc == 0) {
|
||||||
|
LOG_DEBUG(" Spline pointCount=0 (", tag, ")");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
size_t pointsBytes;
|
||||||
|
if (compressed && pc > 0) {
|
||||||
|
// First point = 3 floats (12 bytes), rest = packed uint32 (4 bytes each)
|
||||||
|
pointsBytes = 12ull + (pc > 1 ? static_cast<size_t>(pc - 1) * 4ull : 0ull);
|
||||||
|
} else {
|
||||||
|
// All uncompressed: 3 floats each
|
||||||
|
pointsBytes = static_cast<size_t>(pc) * 12ull;
|
||||||
|
}
|
||||||
|
size_t needed = pointsBytes + 13ull; // + splineMode(1) + endPoint(12)
|
||||||
|
if (!bytesAvailable(needed)) {
|
||||||
|
packet.setReadPos(prePointCount);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
packet.setReadPos(packet.getReadPos() + pointsBytes);
|
||||||
|
uint8_t mode = packet.readUInt8();
|
||||||
|
if (mode > 3) {
|
||||||
|
packet.setReadPos(prePointCount);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
float epX = packet.readFloat();
|
||||||
|
float epY = packet.readFloat();
|
||||||
|
float epZ = packet.readFloat();
|
||||||
|
// Validate endPoint: garbage bytes rarely produce finite world coords
|
||||||
|
if (!std::isfinite(epX) || !std::isfinite(epY) || !std::isfinite(epZ) ||
|
||||||
|
std::fabs(epX) > 65000.0f || std::fabs(epY) > 65000.0f ||
|
||||||
|
std::fabs(epZ) > 65000.0f) {
|
||||||
|
packet.setReadPos(prePointCount);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Proximity check: if entity position is known, reject endpoints that
|
||||||
|
// are implausibly far from it (catches misinterpreted compressed data).
|
||||||
|
if (entityPos.x != 0.0f || entityPos.y != 0.0f || entityPos.z != 0.0f) {
|
||||||
|
float dx = epX - entityPos.x;
|
||||||
|
float dy = epY - entityPos.y;
|
||||||
|
float dz = epZ - entityPos.z;
|
||||||
|
float distSq = dx * dx + dy * dy + dz * dz;
|
||||||
|
if (distSq > 5000.0f * 5000.0f) {
|
||||||
|
packet.setReadPos(prePointCount);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.splineMode = mode;
|
||||||
|
out.endPoint = glm::vec3(epX, epY, epZ);
|
||||||
|
out.hasEndPoint = true;
|
||||||
|
LOG_DEBUG(" Spline pointCount=", pc, " compressed=", compressed,
|
||||||
|
" endPt=(", epX, ",", epY, ",", epZ, ") (", tag, ")");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save position before WotLK spline header for fallback
|
||||||
|
size_t beforeSplineHeader = packet.getReadPos();
|
||||||
|
|
||||||
|
// Try 1: WotLK format (durationMod+durationModNext+[ANIMATION]+vertAccel+effectStart+points)
|
||||||
|
// Some servers (ChromieCraft) always write vertAccel+effectStart unconditionally.
|
||||||
|
bool splineParsed = false;
|
||||||
|
if (bytesAvailable(8)) {
|
||||||
|
/*float durationMod =*/ packet.readFloat();
|
||||||
|
/*float durationModNext =*/ packet.readFloat();
|
||||||
|
bool wotlkOk = true;
|
||||||
|
if (out.splineFlags & SplineFlag::ANIMATION) {
|
||||||
|
if (!bytesAvailable(5)) { wotlkOk = false; }
|
||||||
|
else {
|
||||||
|
out.hasAnimation = true;
|
||||||
|
out.animationType = packet.readUInt8();
|
||||||
|
out.animationStartTime = packet.readUInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unconditional vertAccel+effectStart (ChromieCraft/some AzerothCore builds)
|
||||||
|
if (wotlkOk) {
|
||||||
|
if (!bytesAvailable(8)) { wotlkOk = false; }
|
||||||
|
else {
|
||||||
|
/*float vertAccel =*/ packet.readFloat();
|
||||||
|
/*uint32_t effectStart =*/ packet.readUInt32();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wotlkOk) {
|
||||||
|
bool useCompressed = (out.splineFlags & SplineFlag::UNCOMPRESSED_MASK) == 0;
|
||||||
|
splineParsed = tryParseSplinePoints(useCompressed, "wotlk-compressed");
|
||||||
|
if (!splineParsed) {
|
||||||
|
splineParsed = tryParseSplinePoints(false, "wotlk-uncompressed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 2: ANIMATION present but vertAccel+effectStart gated by PARABOLIC
|
||||||
|
if (!splineParsed && (out.splineFlags & SplineFlag::ANIMATION)) {
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
out.hasAnimation = false; // Reset from failed try
|
||||||
|
if (bytesAvailable(8)) {
|
||||||
|
packet.readFloat(); // durationMod
|
||||||
|
packet.readFloat(); // durationModNext
|
||||||
|
bool ok = true;
|
||||||
|
if (!bytesAvailable(5)) { ok = false; }
|
||||||
|
else {
|
||||||
|
out.hasAnimation = true;
|
||||||
|
out.animationType = packet.readUInt8();
|
||||||
|
out.animationStartTime = packet.readUInt32();
|
||||||
|
}
|
||||||
|
if (ok && (out.splineFlags & SplineFlag::PARABOLIC_MU)) {
|
||||||
|
if (!bytesAvailable(8)) { ok = false; }
|
||||||
|
else { packet.readFloat(); packet.readUInt32(); }
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
bool useCompressed = (out.splineFlags & SplineFlag::UNCOMPRESSED_MASK) == 0;
|
||||||
|
splineParsed = tryParseSplinePoints(useCompressed, "wotlk-anim-conditional");
|
||||||
|
if (!splineParsed) {
|
||||||
|
splineParsed = tryParseSplinePoints(false, "wotlk-anim-conditional-uncomp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 3: No ANIMATION — vertAccel+effectStart only when PARABOLIC set
|
||||||
|
if (!splineParsed) {
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
out.hasAnimation = false;
|
||||||
|
if (bytesAvailable(8)) {
|
||||||
|
packet.readFloat(); // durationMod
|
||||||
|
packet.readFloat(); // durationModNext
|
||||||
|
bool ok = true;
|
||||||
|
if (out.splineFlags & SplineFlag::PARABOLIC_MU) {
|
||||||
|
if (!bytesAvailable(8)) { ok = false; }
|
||||||
|
else { packet.readFloat(); packet.readUInt32(); }
|
||||||
|
}
|
||||||
|
if (ok) {
|
||||||
|
bool useCompressed = (out.splineFlags & SplineFlag::UNCOMPRESSED_MASK) == 0;
|
||||||
|
splineParsed = tryParseSplinePoints(useCompressed, "wotlk-parabolic-gated");
|
||||||
|
if (!splineParsed) {
|
||||||
|
splineParsed = tryParseSplinePoints(false, "wotlk-parabolic-gated-uncomp");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 4: No header at all — just durationMod+durationModNext then points
|
||||||
|
if (!splineParsed) {
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
if (bytesAvailable(8)) {
|
||||||
|
packet.readFloat(); // durationMod
|
||||||
|
packet.readFloat(); // durationModNext
|
||||||
|
splineParsed = tryParseSplinePoints(false, "wotlk-no-parabolic");
|
||||||
|
if (!splineParsed) {
|
||||||
|
bool useComp = (out.splineFlags & SplineFlag::UNCOMPRESSED_MASK) == 0;
|
||||||
|
splineParsed = tryParseSplinePoints(useComp, "wotlk-no-parabolic-compressed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 5: bare points (no WotLK header at all — some spline types skip everything)
|
||||||
|
if (!splineParsed) {
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
splineParsed = tryParseSplinePoints(false, "bare-uncompressed");
|
||||||
|
if (!splineParsed) {
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
bool useComp = (out.splineFlags & SplineFlag::UNCOMPRESSED_MASK) == 0;
|
||||||
|
splineParsed = tryParseSplinePoints(useComp, "bare-compressed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!splineParsed) {
|
||||||
|
// Dump first 5 uint32s at beforeSplineHeader for format diagnosis
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
uint32_t d[5] = {};
|
||||||
|
for (int di = 0; di < 5 && packet.hasRemaining(4); ++di)
|
||||||
|
d[di] = packet.readUInt32();
|
||||||
|
packet.setReadPos(beforeSplineHeader);
|
||||||
|
LOG_WARNING("WotLK spline parse failed"
|
||||||
|
" splineFlags=0x", std::hex, out.splineFlags, std::dec,
|
||||||
|
" remaining=", packet.getRemainingSize(),
|
||||||
|
" header=[0x", std::hex, d[0], " 0x", d[1], " 0x", d[2],
|
||||||
|
" 0x", d[3], " 0x", d[4], "]", std::dec);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
64
src/game/transport_animator.cpp
Normal file
64
src/game/transport_animator.cpp
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// src/game/transport_animator.cpp
|
||||||
|
// Path evaluation, Z clamping, and orientation for transports.
|
||||||
|
// Extracted from TransportManager::updateTransportMovement (Phase 3b of spline refactoring).
|
||||||
|
#include "game/transport_animator.hpp"
|
||||||
|
#include "game/transport_manager.hpp"
|
||||||
|
#include "game/transport_path_repository.hpp"
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include <glm/gtc/constants.hpp>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
void TransportAnimator::evaluateAndApply(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const PathEntry& pathEntry,
|
||||||
|
uint32_t pathTimeMs) const
|
||||||
|
{
|
||||||
|
const auto& spline = pathEntry.spline;
|
||||||
|
|
||||||
|
// Evaluate position from time via CatmullRomSpline (path is local offsets, add base position)
|
||||||
|
glm::vec3 pathOffset = spline.evaluatePosition(pathTimeMs);
|
||||||
|
|
||||||
|
pathOffset.z = clampZOffset(
|
||||||
|
pathOffset.z,
|
||||||
|
pathEntry.worldCoords,
|
||||||
|
transport.useClientAnimation,
|
||||||
|
transport.serverUpdateCount,
|
||||||
|
transport.hasServerClock);
|
||||||
|
|
||||||
|
transport.position = transport.basePosition + pathOffset;
|
||||||
|
|
||||||
|
// Use server yaw if available (authoritative), otherwise compute from spline tangent
|
||||||
|
if (transport.hasServerYaw) {
|
||||||
|
float effectiveYaw = transport.serverYaw +
|
||||||
|
(transport.serverYawFlipped180 ? glm::pi<float>() : 0.0f);
|
||||||
|
transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f));
|
||||||
|
} else {
|
||||||
|
auto result = spline.evaluate(pathTimeMs);
|
||||||
|
transport.rotation = math::CatmullRomSpline::orientationFromTangent(result.tangent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
float TransportAnimator::clampZOffset(float z, bool worldCoords, bool clientAnim,
|
||||||
|
int serverUpdateCount, bool hasServerClock)
|
||||||
|
{
|
||||||
|
// Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions.
|
||||||
|
if (worldCoords) return z;
|
||||||
|
|
||||||
|
constexpr float kMinFallbackZOffset = -2.0f;
|
||||||
|
constexpr float kMaxFallbackZOffset = 8.0f;
|
||||||
|
|
||||||
|
// Clamp fallback Z offsets for non-world-coordinate paths to prevent transport
|
||||||
|
// models from sinking below sea level on paths derived only from spawn-time data
|
||||||
|
// (notably icebreaker routes where the DBC path has steep vertical curves).
|
||||||
|
if (clientAnim && serverUpdateCount <= 1) {
|
||||||
|
z = std::max(z, kMinFallbackZOffset);
|
||||||
|
}
|
||||||
|
if (!clientAnim && !hasServerClock) {
|
||||||
|
z = std::clamp(z, kMinFallbackZOffset, kMaxFallbackZOffset);
|
||||||
|
}
|
||||||
|
return z;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
272
src/game/transport_clock_sync.cpp
Normal file
272
src/game/transport_clock_sync.cpp
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
// src/game/transport_clock_sync.cpp
|
||||||
|
// Clock synchronization and yaw correction for transports.
|
||||||
|
// Extracted from TransportManager (Phase 3a of spline refactoring).
|
||||||
|
#include "game/transport_clock_sync.hpp"
|
||||||
|
#include "game/transport_manager.hpp"
|
||||||
|
#include "game/transport_path_repository.hpp"
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include "core/logger.hpp"
|
||||||
|
#include <glm/gtc/constants.hpp>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
bool TransportClockSync::computePathTime(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const math::CatmullRomSpline& spline,
|
||||||
|
double elapsedTime,
|
||||||
|
float deltaTime,
|
||||||
|
uint32_t& outPathTimeMs) const
|
||||||
|
{
|
||||||
|
uint32_t nowMs = static_cast<uint32_t>(elapsedTime * 1000.0);
|
||||||
|
uint32_t durationMs = spline.durationMs();
|
||||||
|
|
||||||
|
if (transport.hasServerClock) {
|
||||||
|
// Predict server time using clock offset (works for both client and server-driven modes)
|
||||||
|
int64_t serverTimeMs = static_cast<int64_t>(nowMs) + transport.serverClockOffsetMs;
|
||||||
|
int64_t mod = static_cast<int64_t>(durationMs);
|
||||||
|
int64_t wrapped = serverTimeMs % mod;
|
||||||
|
if (wrapped < 0) wrapped += mod;
|
||||||
|
outPathTimeMs = static_cast<uint32_t>(wrapped);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transport.useClientAnimation) {
|
||||||
|
// Pure local clock (no server sync yet, client-driven)
|
||||||
|
uint32_t dtMs = static_cast<uint32_t>(deltaTime * 1000.0f);
|
||||||
|
if (!transport.clientAnimationReverse) {
|
||||||
|
transport.localClockMs += dtMs;
|
||||||
|
} else {
|
||||||
|
if (dtMs > durationMs) {
|
||||||
|
dtMs %= durationMs;
|
||||||
|
}
|
||||||
|
if (transport.localClockMs >= dtMs) {
|
||||||
|
transport.localClockMs -= dtMs;
|
||||||
|
} else {
|
||||||
|
transport.localClockMs = durationMs - (dtMs - transport.localClockMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outPathTimeMs = transport.localClockMs % durationMs;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strict server-authoritative mode: do not guess movement between server snapshots.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransportClockSync::processServerUpdate(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const PathEntry* pathEntry,
|
||||||
|
const glm::vec3& position,
|
||||||
|
float orientation,
|
||||||
|
double elapsedTime)
|
||||||
|
{
|
||||||
|
const bool hadPrevUpdate = (transport.serverUpdateCount > 0);
|
||||||
|
const double prevUpdateTime = transport.lastServerUpdate;
|
||||||
|
const glm::vec3 prevPos = transport.position;
|
||||||
|
|
||||||
|
const bool hasPath = (pathEntry != nullptr);
|
||||||
|
const bool isZOnlyPath = (hasPath && pathEntry->fromDBC && pathEntry->zOnly && pathEntry->spline.durationMs() > 0);
|
||||||
|
const bool isWorldCoordPath = (hasPath && pathEntry->worldCoords && pathEntry->spline.durationMs() > 0);
|
||||||
|
|
||||||
|
// Don't let (0,0,0) server updates override a TaxiPathNode world-coordinate path
|
||||||
|
if (isWorldCoordPath && glm::dot(position, position) < 1.0f) {
|
||||||
|
transport.serverUpdateCount++;
|
||||||
|
transport.lastServerUpdate = elapsedTime;
|
||||||
|
transport.serverYaw = orientation;
|
||||||
|
transport.hasServerYaw = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track server updates
|
||||||
|
transport.serverUpdateCount++;
|
||||||
|
transport.lastServerUpdate = elapsedTime;
|
||||||
|
// Z-only elevators and world-coordinate paths (TaxiPathNode) always stay client-driven.
|
||||||
|
// For other DBC paths (trams, ships): only switch to server-driven mode when the server
|
||||||
|
// sends a position that actually differs from the current position, indicating it's
|
||||||
|
// actively streaming movement data (not just echoing the spawn position).
|
||||||
|
if (isZOnlyPath || isWorldCoordPath) {
|
||||||
|
transport.useClientAnimation = true;
|
||||||
|
} else if (transport.useClientAnimation && hasPath && pathEntry->fromDBC) {
|
||||||
|
glm::vec3 pd = position - transport.position;
|
||||||
|
float posDeltaSq = glm::dot(pd, pd);
|
||||||
|
if (posDeltaSq > 1.0f) {
|
||||||
|
// Server sent a meaningfully different position — it's actively driving this transport
|
||||||
|
transport.useClientAnimation = false;
|
||||||
|
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" switching to server-driven (posDeltaSq=", posDeltaSq, ")");
|
||||||
|
}
|
||||||
|
// Otherwise keep client animation (server just echoed spawn pos or sent small jitter)
|
||||||
|
} else if (!hasPath || !pathEntry->fromDBC) {
|
||||||
|
// No DBC path — purely server-driven
|
||||||
|
transport.useClientAnimation = false;
|
||||||
|
}
|
||||||
|
transport.clientAnimationReverse = false;
|
||||||
|
|
||||||
|
// Server-authoritative transport mode:
|
||||||
|
// Trust explicit server world position/orientation directly for all moving transports.
|
||||||
|
transport.hasServerClock = false;
|
||||||
|
if (transport.serverUpdateCount == 1) {
|
||||||
|
// Seed once from first authoritative update; keep stable base for fallback phase estimation.
|
||||||
|
// For z-only elevator paths, keep the spawn-derived basePosition (the DBC path is local offsets).
|
||||||
|
if (!isZOnlyPath) {
|
||||||
|
transport.basePosition = position;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transport.position = position;
|
||||||
|
transport.serverYaw = orientation;
|
||||||
|
transport.hasServerYaw = true;
|
||||||
|
float effectiveYaw = transport.serverYaw + (transport.serverYawFlipped180 ? glm::pi<float>() : 0.0f);
|
||||||
|
transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f));
|
||||||
|
|
||||||
|
if (hadPrevUpdate) {
|
||||||
|
const double dt = elapsedTime - prevUpdateTime;
|
||||||
|
if (dt > 0.001) {
|
||||||
|
glm::vec3 v = (position - prevPos) / static_cast<float>(dt);
|
||||||
|
float speedSq = glm::dot(v, v);
|
||||||
|
constexpr float kMinAuthoritativeSpeed = 0.15f;
|
||||||
|
constexpr float kMaxSpeed = 60.0f;
|
||||||
|
if (speedSq >= kMinAuthoritativeSpeed * kMinAuthoritativeSpeed) {
|
||||||
|
updateYawAlignment(transport, v);
|
||||||
|
|
||||||
|
if (speedSq > kMaxSpeed * kMaxSpeed) {
|
||||||
|
v *= (kMaxSpeed * glm::inversesqrt(speedSq));
|
||||||
|
}
|
||||||
|
|
||||||
|
transport.serverLinearVelocity = v;
|
||||||
|
transport.serverAngularVelocity = 0.0f;
|
||||||
|
transport.hasServerVelocity = true;
|
||||||
|
|
||||||
|
// Re-apply potentially corrected yaw this frame after alignment check.
|
||||||
|
effectiveYaw = transport.serverYaw + (transport.serverYawFlipped180 ? glm::pi<float>() : 0.0f);
|
||||||
|
transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Seed fallback path phase from nearest waypoint to the first authoritative sample.
|
||||||
|
if (pathEntry && pathEntry->spline.keyCount() > 0 && pathEntry->spline.durationMs() > 0) {
|
||||||
|
glm::vec3 local = position - transport.basePosition;
|
||||||
|
size_t bestIdx = pathEntry->spline.findNearestKey(local);
|
||||||
|
transport.localClockMs = pathEntry->spline.keys()[bestIdx].timeMs % pathEntry->spline.durationMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bootstrap velocity from mapped DBC path on first authoritative sample.
|
||||||
|
if (transport.allowBootstrapVelocity && pathEntry && pathEntry->spline.keyCount() >= 2 && pathEntry->spline.durationMs() > 0) {
|
||||||
|
bootstrapVelocityFromPath(transport, *pathEntry);
|
||||||
|
} else if (!transport.allowBootstrapVelocity) {
|
||||||
|
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" DBC bootstrap velocity disabled for this transport");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransportClockSync::updateYawAlignment(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const glm::vec3& velocity) const
|
||||||
|
{
|
||||||
|
// Auto-detect 180-degree yaw mismatch by comparing heading to movement direction.
|
||||||
|
// Some transports appear to report yaw opposite their actual travel direction.
|
||||||
|
glm::vec2 horizontalV(velocity.x, velocity.y);
|
||||||
|
float hLenSq = glm::dot(horizontalV, horizontalV);
|
||||||
|
if (hLenSq > 0.04f) {
|
||||||
|
horizontalV *= glm::inversesqrt(hLenSq);
|
||||||
|
glm::vec2 heading(std::cos(transport.serverYaw), std::sin(transport.serverYaw));
|
||||||
|
float alignDot = glm::dot(heading, horizontalV);
|
||||||
|
|
||||||
|
if (alignDot < -0.35f) {
|
||||||
|
transport.serverYawAlignmentScore = std::max(transport.serverYawAlignmentScore - 1, -12);
|
||||||
|
} else if (alignDot > 0.35f) {
|
||||||
|
transport.serverYawAlignmentScore = std::min(transport.serverYawAlignmentScore + 1, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!transport.serverYawFlipped180 && transport.serverYawAlignmentScore <= -4) {
|
||||||
|
transport.serverYawFlipped180 = true;
|
||||||
|
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" enabled 180-degree yaw correction (alignScore=",
|
||||||
|
transport.serverYawAlignmentScore, ")");
|
||||||
|
} else if (transport.serverYawFlipped180 &&
|
||||||
|
transport.serverYawAlignmentScore >= 4) {
|
||||||
|
transport.serverYawFlipped180 = false;
|
||||||
|
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" disabled 180-degree yaw correction (alignScore=",
|
||||||
|
transport.serverYawAlignmentScore, ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransportClockSync::bootstrapVelocityFromPath(
|
||||||
|
ActiveTransport& transport,
|
||||||
|
const PathEntry& pathEntry) const
|
||||||
|
{
|
||||||
|
// Bootstrap velocity from nearest DBC path segment on first authoritative sample.
|
||||||
|
// This avoids "stalled at dock" when server sends sparse transport snapshots.
|
||||||
|
const auto& keys = pathEntry.spline.keys();
|
||||||
|
glm::vec3 local = transport.position - transport.basePosition;
|
||||||
|
size_t bestIdx = pathEntry.spline.findNearestKey(local);
|
||||||
|
|
||||||
|
float bestDistSq = 0.0f;
|
||||||
|
{
|
||||||
|
glm::vec3 d = keys[bestIdx].position - local;
|
||||||
|
bestDistSq = glm::dot(d, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
constexpr float kMaxBootstrapNearestDist = 80.0f;
|
||||||
|
if (bestDistSq > (kMaxBootstrapNearestDist * kMaxBootstrapNearestDist)) {
|
||||||
|
LOG_WARNING("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" skipping DBC bootstrap velocity: nearest path point too far (dist=",
|
||||||
|
std::sqrt(bestDistSq), ", path=", transport.pathId, ")");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t n = keys.size();
|
||||||
|
uint32_t durMs = pathEntry.spline.durationMs();
|
||||||
|
constexpr float kMinBootstrapSpeed = 0.25f;
|
||||||
|
constexpr float kMaxSpeed = 60.0f;
|
||||||
|
|
||||||
|
auto tryApplySegment = [&](size_t a, size_t b) {
|
||||||
|
uint32_t t0 = keys[a].timeMs;
|
||||||
|
uint32_t t1 = keys[b].timeMs;
|
||||||
|
if (b == 0 && t1 <= t0 && durMs > 0) {
|
||||||
|
t1 = durMs;
|
||||||
|
}
|
||||||
|
if (t1 <= t0) return;
|
||||||
|
glm::vec3 seg = keys[b].position - keys[a].position;
|
||||||
|
float dtSeg = static_cast<float>(t1 - t0) / 1000.0f;
|
||||||
|
if (dtSeg <= 0.001f) return;
|
||||||
|
glm::vec3 v = seg / dtSeg;
|
||||||
|
float speedSq = glm::dot(v, v);
|
||||||
|
if (speedSq < kMinBootstrapSpeed * kMinBootstrapSpeed) return;
|
||||||
|
if (speedSq > kMaxSpeed * kMaxSpeed) {
|
||||||
|
v *= (kMaxSpeed * glm::inversesqrt(speedSq));
|
||||||
|
}
|
||||||
|
transport.serverLinearVelocity = v;
|
||||||
|
transport.serverAngularVelocity = 0.0f;
|
||||||
|
transport.hasServerVelocity = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prefer nearest forward meaningful segment from bestIdx.
|
||||||
|
for (size_t step = 1; step < n && !transport.hasServerVelocity; ++step) {
|
||||||
|
size_t a = (bestIdx + step - 1) % n;
|
||||||
|
size_t b = (bestIdx + step) % n;
|
||||||
|
tryApplySegment(a, b);
|
||||||
|
}
|
||||||
|
// Fallback: nearest backward meaningful segment.
|
||||||
|
for (size_t step = 1; step < n && !transport.hasServerVelocity; ++step) {
|
||||||
|
size_t b = (bestIdx + n - step + 1) % n;
|
||||||
|
size_t a = (bestIdx + n - step) % n;
|
||||||
|
tryApplySegment(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transport.hasServerVelocity) {
|
||||||
|
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" bootstrapped velocity from DBC path ", transport.pathId,
|
||||||
|
" v=(", transport.serverLinearVelocity.x, ", ",
|
||||||
|
transport.serverLinearVelocity.y, ", ",
|
||||||
|
transport.serverLinearVelocity.z, ")");
|
||||||
|
} else {
|
||||||
|
LOG_INFO("Transport 0x", std::hex, transport.guid, std::dec,
|
||||||
|
" skipped DBC bootstrap velocity (segment too short/static)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
File diff suppressed because it is too large
Load diff
514
src/game/transport_path_repository.cpp
Normal file
514
src/game/transport_path_repository.cpp
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
// src/game/transport_path_repository.cpp
|
||||||
|
// Owns and manages transport path data — DBC, taxi, and custom paths.
|
||||||
|
// Ported from TransportManager (path management subset).
|
||||||
|
#include "game/transport_path_repository.hpp"
|
||||||
|
#include "core/coordinates.hpp"
|
||||||
|
#include "core/logger.hpp"
|
||||||
|
#include "pipeline/dbc_loader.hpp"
|
||||||
|
#include "pipeline/asset_manager.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <map>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace wowee::game {
|
||||||
|
|
||||||
|
// ── Simple lookup methods ──────────────────────────────────────
|
||||||
|
|
||||||
|
const PathEntry* TransportPathRepository::findPath(uint32_t pathId) const {
|
||||||
|
auto it = paths_.find(pathId);
|
||||||
|
return it != paths_.end() ? &it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PathEntry* TransportPathRepository::findTaxiPath(uint32_t taxiPathId) const {
|
||||||
|
auto it = taxiPaths_.find(taxiPathId);
|
||||||
|
return it != taxiPaths_.end() ? &it->second : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TransportPathRepository::hasPathForEntry(uint32_t entry) const {
|
||||||
|
auto* e = findPath(entry);
|
||||||
|
return e != nullptr && e->fromDBC;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TransportPathRepository::hasTaxiPath(uint32_t taxiPathId) const {
|
||||||
|
return taxiPaths_.find(taxiPathId) != taxiPaths_.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TransportPathRepository::storePath(uint32_t pathId, PathEntry entry) {
|
||||||
|
auto it = paths_.find(pathId);
|
||||||
|
if (it != paths_.end()) {
|
||||||
|
it->second = std::move(entry);
|
||||||
|
} else {
|
||||||
|
paths_.emplace(pathId, std::move(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Query methods ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
bool TransportPathRepository::hasUsableMovingPathForEntry(uint32_t entry, float minXYRange) const {
|
||||||
|
auto* e = findPath(entry);
|
||||||
|
if (!e) return false;
|
||||||
|
if (!e->fromDBC || e->spline.keyCount() < 2 || e->spline.durationMs() == 0 || e->zOnly) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return e->spline.hasXYMovement(minXYRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t TransportPathRepository::inferDbcPathForSpawn(const glm::vec3& spawnWorldPos,
|
||||||
|
float maxDistance,
|
||||||
|
bool allowZOnly) const {
|
||||||
|
float bestD2 = maxDistance * maxDistance;
|
||||||
|
uint32_t bestPathId = 0;
|
||||||
|
|
||||||
|
for (const auto& [pathId, entry] : paths_) {
|
||||||
|
if (!entry.fromDBC || entry.spline.durationMs() == 0 || entry.spline.keyCount() == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!allowZOnly && entry.zOnly) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find nearest waypoint on this path to spawn
|
||||||
|
size_t nearIdx = entry.spline.findNearestKey(spawnWorldPos);
|
||||||
|
glm::vec3 diff = entry.spline.keys()[nearIdx].position - spawnWorldPos;
|
||||||
|
float d2 = glm::dot(diff, diff);
|
||||||
|
if (d2 < bestD2) {
|
||||||
|
bestD2 = d2;
|
||||||
|
bestPathId = pathId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestPathId != 0) {
|
||||||
|
LOG_INFO("TransportPathRepository: Inferred DBC path ", bestPathId,
|
||||||
|
" (allowZOnly=", allowZOnly ? "yes" : "no",
|
||||||
|
") for spawn at (", spawnWorldPos.x, ", ", spawnWorldPos.y, ", ", spawnWorldPos.z,
|
||||||
|
"), dist=", std::sqrt(bestD2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPathId;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t TransportPathRepository::inferMovingPathForSpawn(const glm::vec3& spawnWorldPos, float maxDistance) const {
|
||||||
|
return inferDbcPathForSpawn(spawnWorldPos, maxDistance, /*allowZOnly=*/false);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t TransportPathRepository::pickFallbackMovingPath(uint32_t entry, uint32_t displayId) const {
|
||||||
|
auto isUsableMovingPath = [this](uint32_t pathId) -> bool {
|
||||||
|
auto* e = findPath(pathId);
|
||||||
|
if (!e) return false;
|
||||||
|
return e->fromDBC && !e->zOnly && e->spline.durationMs() > 0 && e->spline.keyCount() > 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Known AzerothCore transport entry remaps (WotLK): server entry -> moving DBC path id.
|
||||||
|
// These entries commonly do not match TransportAnimation.dbc ids 1:1.
|
||||||
|
static const std::unordered_map<uint32_t, uint32_t> kEntryRemap = {
|
||||||
|
{176231u, 176080u}, // The Maiden's Fancy
|
||||||
|
{176310u, 176081u}, // The Bravery
|
||||||
|
{20808u, 176082u}, // The Black Princess
|
||||||
|
{164871u, 193182u}, // The Thundercaller
|
||||||
|
{176495u, 193183u}, // The Purple Princess
|
||||||
|
{175080u, 193182u}, // The Iron Eagle
|
||||||
|
{181689u, 193183u}, // Cloudkisser
|
||||||
|
{186238u, 193182u}, // The Mighty Wind
|
||||||
|
{181688u, 176083u}, // Northspear (icebreaker)
|
||||||
|
{190536u, 176084u}, // Stormwind's Pride (icebreaker)
|
||||||
|
};
|
||||||
|
|
||||||
|
auto itMapped = kEntryRemap.find(entry);
|
||||||
|
if (itMapped != kEntryRemap.end() && isUsableMovingPath(itMapped->second)) {
|
||||||
|
return itMapped->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback by display model family.
|
||||||
|
const bool looksLikeShip =
|
||||||
|
(displayId == 3015u || displayId == 2454u || displayId == 7446u);
|
||||||
|
const bool looksLikeZeppelin =
|
||||||
|
(displayId == 3031u || displayId == 7546u || displayId == 1587u || displayId == 807u || displayId == 808u);
|
||||||
|
|
||||||
|
if (looksLikeShip) {
|
||||||
|
static constexpr uint32_t kShipCandidates[] = {176080u, 176081u, 176082u, 176083u, 176084u, 176085u, 194675u};
|
||||||
|
for (uint32_t id : kShipCandidates) {
|
||||||
|
if (isUsableMovingPath(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (looksLikeZeppelin) {
|
||||||
|
static constexpr uint32_t kZeppelinCandidates[] = {193182u, 193183u, 188360u, 190587u};
|
||||||
|
for (uint32_t id : kZeppelinCandidates) {
|
||||||
|
if (isUsableMovingPath(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last-resort: pick any moving DBC path so transport does not remain stationary.
|
||||||
|
for (const auto& [pathId, e] : paths_) {
|
||||||
|
if (e.fromDBC && !e.zOnly && e.spline.durationMs() > 0 && e.spline.keyCount() > 1) {
|
||||||
|
return pathId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Path construction from waypoints ───────────────────────────
|
||||||
|
|
||||||
|
void TransportPathRepository::loadPathFromNodes(uint32_t pathId, const std::vector<glm::vec3>& waypoints, bool looping, float speed) {
|
||||||
|
if (waypoints.empty()) {
|
||||||
|
LOG_ERROR("TransportPathRepository: Cannot load empty path ", pathId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isZOnly = false; // Manually loaded paths are assumed to have XY movement
|
||||||
|
|
||||||
|
// Helper: compute segment duration from distance and speed
|
||||||
|
auto segMsFromDist = [&](float dist) -> uint32_t {
|
||||||
|
if (speed <= 0.0f) return 1000;
|
||||||
|
return static_cast<uint32_t>((dist / speed) * 1000.0f);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Single point = stationary (durationMs = 0)
|
||||||
|
if (waypoints.size() == 1) {
|
||||||
|
std::vector<math::SplineKey> keys;
|
||||||
|
keys.push_back({0, waypoints[0]});
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
paths_.emplace(pathId, PathEntry(std::move(spline), pathId, isZOnly, false, false));
|
||||||
|
LOG_INFO("TransportPathRepository: Loaded stationary path ", pathId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple points: calculate cumulative time based on distance and speed
|
||||||
|
std::vector<math::SplineKey> keys;
|
||||||
|
keys.reserve(waypoints.size() + (looping ? 1 : 0));
|
||||||
|
uint32_t cumulativeMs = 0;
|
||||||
|
keys.push_back({0, waypoints[0]});
|
||||||
|
|
||||||
|
for (size_t i = 1; i < waypoints.size(); i++) {
|
||||||
|
float dist = glm::distance(waypoints[i-1], waypoints[i]);
|
||||||
|
cumulativeMs += glm::max(1u, segMsFromDist(dist));
|
||||||
|
keys.push_back({cumulativeMs, waypoints[i]});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add explicit wrap segment (last → first) for looping paths.
|
||||||
|
// By duplicating the first point at the end with cumulative time, the path
|
||||||
|
// becomes time-closed and CatmullRomSpline handles wrap via modular time
|
||||||
|
// without requiring special-case index wrapping during evaluation.
|
||||||
|
if (looping && waypoints.size() >= 2) {
|
||||||
|
float wrapDist = glm::distance(waypoints.back(), waypoints.front());
|
||||||
|
cumulativeMs += glm::max(1u, segMsFromDist(wrapDist));
|
||||||
|
keys.push_back({cumulativeMs, waypoints[0]});
|
||||||
|
}
|
||||||
|
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
paths_.emplace(pathId, PathEntry(std::move(spline), pathId, isZOnly, false, false));
|
||||||
|
|
||||||
|
auto* stored = findPath(pathId);
|
||||||
|
LOG_INFO("TransportPathRepository: Loaded path ", pathId,
|
||||||
|
" with ", waypoints.size(), " waypoints",
|
||||||
|
(looping ? " + wrap segment" : ""),
|
||||||
|
", duration=", stored ? stored->spline.durationMs() : 0, "ms, speed=", speed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DBC: TransportAnimation ────────────────────────────────────
|
||||||
|
|
||||||
|
bool TransportPathRepository::loadTransportAnimationDBC(pipeline::AssetManager* assetMgr) {
|
||||||
|
LOG_INFO("Loading TransportAnimation.dbc...");
|
||||||
|
|
||||||
|
if (!assetMgr) {
|
||||||
|
LOG_ERROR("AssetManager is null");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load DBC file
|
||||||
|
auto dbcData = assetMgr->readFile("DBFilesClient\\TransportAnimation.dbc");
|
||||||
|
if (dbcData.empty()) {
|
||||||
|
LOG_WARNING("TransportAnimation.dbc not found - transports will use fallback paths");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline::DBCFile dbc;
|
||||||
|
if (!dbc.load(dbcData)) {
|
||||||
|
LOG_ERROR("Failed to parse TransportAnimation.dbc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("TransportAnimation.dbc: ", dbc.getRecordCount(), " records, ",
|
||||||
|
dbc.getFieldCount(), " fields per record");
|
||||||
|
|
||||||
|
// Debug: dump first 3 records to see all field values
|
||||||
|
for (uint32_t i = 0; i < std::min(3u, dbc.getRecordCount()); i++) {
|
||||||
|
LOG_INFO(" DEBUG Record ", i, ": ",
|
||||||
|
" [0]=", dbc.getUInt32(i, 0),
|
||||||
|
" [1]=", dbc.getUInt32(i, 1),
|
||||||
|
" [2]=", dbc.getUInt32(i, 2),
|
||||||
|
" [3]=", dbc.getFloat(i, 3),
|
||||||
|
" [4]=", dbc.getFloat(i, 4),
|
||||||
|
" [5]=", dbc.getFloat(i, 5),
|
||||||
|
" [6]=", dbc.getUInt32(i, 6));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group waypoints by transportEntry
|
||||||
|
std::map<uint32_t, std::vector<std::pair<uint32_t, glm::vec3>>> waypointsByTransport;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < dbc.getRecordCount(); i++) {
|
||||||
|
// uint32_t id = dbc.getUInt32(i, 0); // Not needed
|
||||||
|
uint32_t transportEntry = dbc.getUInt32(i, 1);
|
||||||
|
uint32_t timeIndex = dbc.getUInt32(i, 2);
|
||||||
|
float posX = dbc.getFloat(i, 3);
|
||||||
|
float posY = dbc.getFloat(i, 4);
|
||||||
|
float posZ = dbc.getFloat(i, 5);
|
||||||
|
// uint32_t sequenceId = dbc.getUInt32(i, 6); // Not needed for basic paths
|
||||||
|
|
||||||
|
// RAW FLOAT SANITY CHECK: Log first 10 records to see if DBC has real data
|
||||||
|
if (i < 10) {
|
||||||
|
uint32_t ux = dbc.getUInt32(i, 3);
|
||||||
|
uint32_t uy = dbc.getUInt32(i, 4);
|
||||||
|
uint32_t uz = dbc.getUInt32(i, 5);
|
||||||
|
LOG_INFO("TA raw rec ", i,
|
||||||
|
" entry=", transportEntry,
|
||||||
|
" t=", timeIndex,
|
||||||
|
" raw=(", posX, ",", posY, ",", posZ, ")",
|
||||||
|
" u32=(", ux, ",", uy, ",", uz, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DIAGNOSTIC: Log ALL records for problematic ferries (20655, 20657, 149046)
|
||||||
|
// AND first few records for known-good transports to verify DBC reading
|
||||||
|
if (i < 5 || transportEntry == 2074 ||
|
||||||
|
transportEntry == 20655 || transportEntry == 20657 || transportEntry == 149046) {
|
||||||
|
LOG_INFO("RAW DBC [", i, "] entry=", transportEntry, " t=", timeIndex,
|
||||||
|
" raw=(", posX, ",", posY, ",", posZ, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
waypointsByTransport[transportEntry].push_back({timeIndex, glm::vec3(posX, posY, posZ)});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create time-indexed paths from waypoints
|
||||||
|
int pathsLoaded = 0;
|
||||||
|
for (const auto& [transportEntry, waypoints] : waypointsByTransport) {
|
||||||
|
if (waypoints.empty()) continue;
|
||||||
|
|
||||||
|
// Sort by timeIndex
|
||||||
|
auto sortedWaypoints = waypoints;
|
||||||
|
std::sort(sortedWaypoints.begin(), sortedWaypoints.end(),
|
||||||
|
[](const auto& a, const auto& b) { return a.first < b.first; });
|
||||||
|
|
||||||
|
// CRITICAL: Normalize timeIndex to start at 0 (DBC records don't start at 0!)
|
||||||
|
// This makes evaluatePosition(0) valid and stabilizes basePosition seeding
|
||||||
|
uint32_t t0 = sortedWaypoints.front().first;
|
||||||
|
|
||||||
|
// Build SplineKey array with normalized time indices
|
||||||
|
std::vector<math::SplineKey> keys;
|
||||||
|
keys.reserve(sortedWaypoints.size() + 1); // +1 for wrap point
|
||||||
|
|
||||||
|
// Log DBC waypoints for tram entries
|
||||||
|
if (transportEntry >= 176080 && transportEntry <= 176085) {
|
||||||
|
size_t mid = sortedWaypoints.size() / 4; // ~quarter through
|
||||||
|
size_t mid2 = sortedWaypoints.size() / 2; // ~halfway
|
||||||
|
LOG_DEBUG("DBC path entry=", transportEntry, " nPts=", sortedWaypoints.size(),
|
||||||
|
" [0] t=", sortedWaypoints[0].first, " raw=(", sortedWaypoints[0].second.x, ",", sortedWaypoints[0].second.y, ",", sortedWaypoints[0].second.z, ")",
|
||||||
|
" [", mid, "] t=", sortedWaypoints[mid].first, " raw=(", sortedWaypoints[mid].second.x, ",", sortedWaypoints[mid].second.y, ",", sortedWaypoints[mid].second.z, ")",
|
||||||
|
" [", mid2, "] t=", sortedWaypoints[mid2].first, " raw=(", sortedWaypoints[mid2].second.x, ",", sortedWaypoints[mid2].second.y, ",", sortedWaypoints[mid2].second.z, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t idx = 0; idx < sortedWaypoints.size(); idx++) {
|
||||||
|
const auto& [tMs, pos] = sortedWaypoints[idx];
|
||||||
|
|
||||||
|
// TransportAnimation.dbc local offsets use a coordinate system where
|
||||||
|
// the travel axis is negated relative to server world coords.
|
||||||
|
// Negate X and Y before converting to canonical (Z=height stays the same).
|
||||||
|
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(-pos.x, -pos.y, pos.z));
|
||||||
|
|
||||||
|
// CRITICAL: Detect if serverToCanonical is zeroing nonzero inputs
|
||||||
|
if ((pos.x != 0.0f || pos.y != 0.0f || pos.z != 0.0f) &&
|
||||||
|
(canonical.x == 0.0f && canonical.y == 0.0f && canonical.z == 0.0f)) {
|
||||||
|
LOG_ERROR("serverToCanonical ZEROED! entry=", transportEntry,
|
||||||
|
" server=(", pos.x, ",", pos.y, ",", pos.z, ")",
|
||||||
|
" → canon=(", canonical.x, ",", canonical.y, ",", canonical.z, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug waypoint conversion for first transport (entry 2074)
|
||||||
|
if (transportEntry == 2074 && idx < 5) {
|
||||||
|
LOG_INFO("COORD CONVERT: entry=", transportEntry, " t=", tMs,
|
||||||
|
" serverPos=(", pos.x, ", ", pos.y, ", ", pos.z, ")",
|
||||||
|
" → canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DIAGNOSTIC: Log ALL conversions for problematic ferries
|
||||||
|
if (transportEntry == 20655 || transportEntry == 20657 || transportEntry == 149046) {
|
||||||
|
LOG_INFO("CONVERT ", transportEntry, " t=", tMs,
|
||||||
|
" server=(", pos.x, ",", pos.y, ",", pos.z, ")",
|
||||||
|
" → canon=(", canonical.x, ",", canonical.y, ",", canonical.z, ")");
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.push_back({tMs - t0, canonical}); // Normalize: subtract first timeIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get base duration from last normalized timeIndex
|
||||||
|
uint32_t lastTimeMs = sortedWaypoints.back().first - t0;
|
||||||
|
|
||||||
|
// Calculate wrap duration (last → first segment)
|
||||||
|
// Use average segment duration as wrap duration
|
||||||
|
uint32_t totalDelta = 0;
|
||||||
|
int segmentCount = 0;
|
||||||
|
for (size_t i = 1; i < sortedWaypoints.size(); i++) {
|
||||||
|
uint32_t delta = sortedWaypoints[i].first - sortedWaypoints[i-1].first;
|
||||||
|
if (delta > 0) {
|
||||||
|
totalDelta += delta;
|
||||||
|
segmentCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uint32_t wrapMs = (segmentCount > 0) ? (totalDelta / segmentCount) : 1000;
|
||||||
|
|
||||||
|
// Add duplicate first point at end with wrap duration
|
||||||
|
// This makes the wrap segment (last → first) have proper duration
|
||||||
|
const auto& fp = sortedWaypoints.front().second;
|
||||||
|
glm::vec3 firstCanonical = core::coords::serverToCanonical(glm::vec3(-fp.x, -fp.y, fp.z));
|
||||||
|
keys.push_back({lastTimeMs + wrapMs, firstCanonical});
|
||||||
|
|
||||||
|
// Build the spline (time-closed=false because we added explicit wrap point)
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
|
||||||
|
// Detect Z-only paths (elevator/bobbing animation, not real XY travel)
|
||||||
|
const auto& sk = spline.keys();
|
||||||
|
float minX = sk[0].position.x, maxX = minX;
|
||||||
|
float minY = sk[0].position.y, maxY = minY;
|
||||||
|
float minZ = sk[0].position.z, maxZ = minZ;
|
||||||
|
for (const auto& k : sk) {
|
||||||
|
minX = std::min(minX, k.position.x); maxX = std::max(maxX, k.position.x);
|
||||||
|
minY = std::min(minY, k.position.y); maxY = std::max(maxY, k.position.y);
|
||||||
|
minZ = std::min(minZ, k.position.z); maxZ = std::max(maxZ, k.position.z);
|
||||||
|
}
|
||||||
|
float rangeX = maxX - minX;
|
||||||
|
float rangeY = maxY - minY;
|
||||||
|
float rangeZ = maxZ - minZ;
|
||||||
|
float rangeXY = std::max(rangeX, rangeY);
|
||||||
|
// Some elevator paths have tiny XY jitter. Treat them as z-only when horizontal travel
|
||||||
|
// is negligible compared to vertical motion.
|
||||||
|
bool isZOnly = (rangeXY < 0.01f) || (rangeXY < 1.0f && rangeZ > 2.0f);
|
||||||
|
|
||||||
|
// Log first, middle, and last points to verify path data
|
||||||
|
glm::vec3 firstOffset = sk[0].position;
|
||||||
|
size_t midIdx = sk.size() / 2;
|
||||||
|
glm::vec3 midOffset = sk[midIdx].position;
|
||||||
|
glm::vec3 lastOffset = sk[sk.size() - 2].position; // -2 to skip wrap duplicate
|
||||||
|
uint32_t durationMs = spline.durationMs();
|
||||||
|
LOG_INFO(" Transport ", transportEntry, ": ", sk.size() - 1, " waypoints + wrap, ",
|
||||||
|
durationMs, "ms duration (wrap=", wrapMs, "ms, t0_normalized=", sk[0].timeMs, "ms)",
|
||||||
|
" rangeXY=(", rangeX, ",", rangeY, ") rangeZ=", rangeZ, " ",
|
||||||
|
(isZOnly ? "[Z-ONLY]" : "[XY-PATH]"),
|
||||||
|
" firstOffset=(", firstOffset.x, ", ", firstOffset.y, ", ", firstOffset.z, ")",
|
||||||
|
" midOffset=(", midOffset.x, ", ", midOffset.y, ", ", midOffset.z, ")",
|
||||||
|
" lastOffset=(", lastOffset.x, ", ", lastOffset.y, ", ", lastOffset.z, ")");
|
||||||
|
|
||||||
|
// Store path
|
||||||
|
paths_.emplace(transportEntry, PathEntry(std::move(spline), transportEntry, isZOnly, true, false));
|
||||||
|
pathsLoaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Loaded ", pathsLoaded, " transport paths from TransportAnimation.dbc");
|
||||||
|
return pathsLoaded > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DBC: TaxiPathNode ──────────────────────────────────────────
|
||||||
|
|
||||||
|
bool TransportPathRepository::loadTaxiPathNodeDBC(pipeline::AssetManager* assetMgr) {
|
||||||
|
LOG_INFO("Loading TaxiPathNode.dbc...");
|
||||||
|
|
||||||
|
if (!assetMgr) {
|
||||||
|
LOG_ERROR("AssetManager is null");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dbcData = assetMgr->readFile("DBFilesClient\\TaxiPathNode.dbc");
|
||||||
|
if (dbcData.empty()) {
|
||||||
|
LOG_WARNING("TaxiPathNode.dbc not found - MO_TRANSPORT will use fallback paths");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
pipeline::DBCFile dbc;
|
||||||
|
if (!dbc.load(dbcData)) {
|
||||||
|
LOG_ERROR("Failed to parse TaxiPathNode.dbc");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("TaxiPathNode.dbc: ", dbc.getRecordCount(), " records, ",
|
||||||
|
dbc.getFieldCount(), " fields per record");
|
||||||
|
|
||||||
|
// Group nodes by PathID, storing (NodeIndex, MapID, X, Y, Z)
|
||||||
|
struct TaxiNode {
|
||||||
|
uint32_t nodeIndex;
|
||||||
|
uint32_t mapId;
|
||||||
|
float x, y, z;
|
||||||
|
};
|
||||||
|
std::map<uint32_t, std::vector<TaxiNode>> nodesByPath;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < dbc.getRecordCount(); i++) {
|
||||||
|
uint32_t pathId = dbc.getUInt32(i, 1); // PathID
|
||||||
|
uint32_t nodeIdx = dbc.getUInt32(i, 2); // NodeIndex
|
||||||
|
uint32_t mapId = dbc.getUInt32(i, 3); // MapID
|
||||||
|
float posX = dbc.getFloat(i, 4); // X (server coords)
|
||||||
|
float posY = dbc.getFloat(i, 5); // Y (server coords)
|
||||||
|
float posZ = dbc.getFloat(i, 6); // Z (server coords)
|
||||||
|
|
||||||
|
nodesByPath[pathId].push_back({nodeIdx, mapId, posX, posY, posZ});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build world-coordinate transport paths
|
||||||
|
int pathsLoaded = 0;
|
||||||
|
for (auto& [pathId, nodes] : nodesByPath) {
|
||||||
|
if (nodes.size() < 2) continue;
|
||||||
|
|
||||||
|
// Sort by NodeIndex
|
||||||
|
std::sort(nodes.begin(), nodes.end(),
|
||||||
|
[](const TaxiNode& a, const TaxiNode& b) { return a.nodeIndex < b.nodeIndex; });
|
||||||
|
|
||||||
|
// Skip flight-master paths (nodes on different maps are map teleports)
|
||||||
|
// Transport paths stay on the same map
|
||||||
|
bool sameMap = true;
|
||||||
|
uint32_t firstMap = nodes[0].mapId;
|
||||||
|
for (const auto& node : nodes) {
|
||||||
|
if (node.mapId != firstMap) { sameMap = false; break; }
|
||||||
|
}
|
||||||
|
if (!sameMap) continue;
|
||||||
|
|
||||||
|
// Build timed points using distance-based timing (28 units/sec default boat speed)
|
||||||
|
const float transportSpeed = 28.0f; // units per second
|
||||||
|
std::vector<math::SplineKey> keys;
|
||||||
|
keys.reserve(nodes.size() + 1);
|
||||||
|
|
||||||
|
uint32_t cumulativeMs = 0;
|
||||||
|
for (size_t i = 0; i < nodes.size(); i++) {
|
||||||
|
// Convert server coords to canonical
|
||||||
|
glm::vec3 serverPos(nodes[i].x, nodes[i].y, nodes[i].z);
|
||||||
|
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
|
||||||
|
|
||||||
|
keys.push_back({cumulativeMs, canonical});
|
||||||
|
|
||||||
|
if (i + 1 < nodes.size()) {
|
||||||
|
float dx = nodes[i+1].x - nodes[i].x;
|
||||||
|
float dy = nodes[i+1].y - nodes[i].y;
|
||||||
|
float dz = nodes[i+1].z - nodes[i].z;
|
||||||
|
float segDist = std::sqrt(dx*dx + dy*dy + dz*dz);
|
||||||
|
uint32_t segMs = static_cast<uint32_t>((segDist / transportSpeed) * 1000.0f);
|
||||||
|
if (segMs < 100) segMs = 100; // Minimum 100ms per segment
|
||||||
|
cumulativeMs += segMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add wrap point (return to start) for looping
|
||||||
|
float wrapDx = nodes.front().x - nodes.back().x;
|
||||||
|
float wrapDy = nodes.front().y - nodes.back().y;
|
||||||
|
float wrapDz = nodes.front().z - nodes.back().z;
|
||||||
|
float wrapDist = std::sqrt(wrapDx*wrapDx + wrapDy*wrapDy + wrapDz*wrapDz);
|
||||||
|
uint32_t wrapMs = static_cast<uint32_t>((wrapDist / transportSpeed) * 1000.0f);
|
||||||
|
if (wrapMs < 100) wrapMs = 100;
|
||||||
|
cumulativeMs += wrapMs;
|
||||||
|
keys.push_back({cumulativeMs, keys[0].position});
|
||||||
|
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
taxiPaths_.emplace(pathId, PathEntry(std::move(spline), pathId, false, true, true));
|
||||||
|
pathsLoaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("Loaded ", pathsLoaded, " TaxiPathNode transport paths (", nodesByPath.size(), " total taxi paths)");
|
||||||
|
return pathsLoaded > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wowee::game
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "game/world_packets.hpp"
|
#include "game/world_packets.hpp"
|
||||||
#include "game/packet_parsers.hpp"
|
#include "game/packet_parsers.hpp"
|
||||||
|
#include "game/spline_packet.hpp"
|
||||||
#include "game/opcodes.hpp"
|
#include "game/opcodes.hpp"
|
||||||
#include "game/character.hpp"
|
#include "game/character.hpp"
|
||||||
#include "auth/crypto.hpp"
|
#include "auth/crypto.hpp"
|
||||||
|
|
@ -959,141 +960,10 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
|
||||||
|
|
||||||
// Spline data
|
// Spline data
|
||||||
if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED
|
if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED
|
||||||
auto bytesAvailable = [&](size_t n) -> bool { return packet.hasRemaining(n); };
|
SplineBlockData splineData;
|
||||||
if (!bytesAvailable(4)) return false;
|
glm::vec3 entityPos(block.x, block.y, block.z);
|
||||||
uint32_t splineFlags = packet.readUInt32();
|
if (!parseWotlkMoveUpdateSpline(packet, splineData, entityPos)) {
|
||||||
LOG_DEBUG(" Spline: flags=0x", std::hex, splineFlags, std::dec);
|
LOG_WARNING("WotLK spline parse failed for guid=0x", std::hex, block.guid, std::dec);
|
||||||
|
|
||||||
if (splineFlags & 0x00010000) { // SPLINEFLAG_FINAL_POINT
|
|
||||||
if (!bytesAvailable(12)) return false;
|
|
||||||
/*float finalX =*/ packet.readFloat();
|
|
||||||
/*float finalY =*/ packet.readFloat();
|
|
||||||
/*float finalZ =*/ packet.readFloat();
|
|
||||||
} else if (splineFlags & 0x00020000) { // SPLINEFLAG_FINAL_TARGET
|
|
||||||
if (!bytesAvailable(8)) return false;
|
|
||||||
/*uint64_t finalTarget =*/ packet.readUInt64();
|
|
||||||
} else if (splineFlags & 0x00040000) { // SPLINEFLAG_FINAL_ANGLE
|
|
||||||
if (!bytesAvailable(4)) return false;
|
|
||||||
/*float finalAngle =*/ packet.readFloat();
|
|
||||||
}
|
|
||||||
|
|
||||||
// WotLK spline data layout:
|
|
||||||
// timePassed(4)+duration(4)+splineId(4)+durationMod(4)+durationModNext(4)
|
|
||||||
// +[ANIMATION(5)]+verticalAccel(4)+effectStartTime(4)+pointCount(4)+points+mode(1)+endPoint(12)
|
|
||||||
if (!bytesAvailable(12)) return false;
|
|
||||||
/*uint32_t timePassed =*/ packet.readUInt32();
|
|
||||||
/*uint32_t duration =*/ packet.readUInt32();
|
|
||||||
/*uint32_t splineId =*/ packet.readUInt32();
|
|
||||||
|
|
||||||
// Helper: parse spline points + splineMode + endPoint.
|
|
||||||
// WotLK uses compressed points by default (first=12 bytes, rest=4 bytes packed).
|
|
||||||
auto tryParseSplinePoints = [&](bool compressed, const char* tag) -> bool {
|
|
||||||
if (!bytesAvailable(4)) return false;
|
|
||||||
size_t prePointCount = packet.getReadPos();
|
|
||||||
uint32_t pc = packet.readUInt32();
|
|
||||||
if (pc > 256) return false;
|
|
||||||
size_t pointsBytes;
|
|
||||||
if (compressed && pc > 0) {
|
|
||||||
// First point = 3 floats (12 bytes), rest = packed uint32 (4 bytes each)
|
|
||||||
pointsBytes = 12ull + (pc > 1 ? static_cast<size_t>(pc - 1) * 4ull : 0ull);
|
|
||||||
} else {
|
|
||||||
// All uncompressed: 3 floats each
|
|
||||||
pointsBytes = static_cast<size_t>(pc) * 12ull;
|
|
||||||
}
|
|
||||||
size_t needed = pointsBytes + 13ull; // + splineMode(1) + endPoint(12)
|
|
||||||
if (!bytesAvailable(needed)) {
|
|
||||||
packet.setReadPos(prePointCount);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
packet.setReadPos(packet.getReadPos() + pointsBytes);
|
|
||||||
uint8_t splineMode = packet.readUInt8();
|
|
||||||
if (splineMode > 3) {
|
|
||||||
packet.setReadPos(prePointCount);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
float epX = packet.readFloat();
|
|
||||||
float epY = packet.readFloat();
|
|
||||||
float epZ = packet.readFloat();
|
|
||||||
// Validate endPoint: garbage bytes rarely produce finite world coords
|
|
||||||
if (!std::isfinite(epX) || !std::isfinite(epY) || !std::isfinite(epZ) ||
|
|
||||||
std::fabs(epX) > 65000.0f || std::fabs(epY) > 65000.0f ||
|
|
||||||
std::fabs(epZ) > 65000.0f) {
|
|
||||||
packet.setReadPos(prePointCount);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
LOG_DEBUG(" Spline pointCount=", pc, " compressed=", compressed,
|
|
||||||
" endPt=(", epX, ",", epY, ",", epZ, ") (", tag, ")");
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save position before WotLK spline header for fallback
|
|
||||||
size_t beforeSplineHeader = packet.getReadPos();
|
|
||||||
|
|
||||||
// Try 1: WotLK format (durationMod+durationModNext+[ANIMATION]+vertAccel+effectStart+points)
|
|
||||||
bool splineParsed = false;
|
|
||||||
if (bytesAvailable(8)) {
|
|
||||||
/*float durationMod =*/ packet.readFloat();
|
|
||||||
/*float durationModNext =*/ packet.readFloat();
|
|
||||||
bool wotlkOk = true;
|
|
||||||
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
|
|
||||||
if (!bytesAvailable(5)) { wotlkOk = false; }
|
|
||||||
else { packet.readUInt8(); packet.readUInt32(); }
|
|
||||||
}
|
|
||||||
// AzerothCore/ChromieCraft always writes verticalAcceleration(float)
|
|
||||||
// + effectStartTime(uint32) unconditionally -- NOT gated by PARABOLIC flag.
|
|
||||||
if (wotlkOk) {
|
|
||||||
if (!bytesAvailable(8)) { wotlkOk = false; }
|
|
||||||
else { /*float vertAccel =*/ packet.readFloat(); /*uint32_t effectStart =*/ packet.readUInt32(); }
|
|
||||||
}
|
|
||||||
if (wotlkOk) {
|
|
||||||
// WotLK: compressed unless CYCLIC(0x80000) or ENTER_CYCLE(0x2000) set
|
|
||||||
bool useCompressed = (splineFlags & (0x00080000 | 0x00002000)) == 0;
|
|
||||||
splineParsed = tryParseSplinePoints(useCompressed, "wotlk-compressed");
|
|
||||||
if (!splineParsed) {
|
|
||||||
splineParsed = tryParseSplinePoints(false, "wotlk-uncompressed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!splineParsed) {
|
|
||||||
// WotLK compressed+uncompressed both failed. Try without the parabolic
|
|
||||||
// fields (some cores don't send vertAccel+effectStart unconditionally).
|
|
||||||
packet.setReadPos(beforeSplineHeader);
|
|
||||||
if (bytesAvailable(8)) {
|
|
||||||
packet.readFloat(); // durationMod
|
|
||||||
packet.readFloat(); // durationModNext
|
|
||||||
// Skip parabolic fields — try points directly
|
|
||||||
splineParsed = tryParseSplinePoints(false, "wotlk-no-parabolic");
|
|
||||||
if (!splineParsed) {
|
|
||||||
bool useComp = (splineFlags & (0x00080000 | 0x00002000)) == 0;
|
|
||||||
splineParsed = tryParseSplinePoints(useComp, "wotlk-no-parabolic-compressed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try 3: bare points (no WotLK header at all — some spline types skip everything)
|
|
||||||
if (!splineParsed) {
|
|
||||||
packet.setReadPos(beforeSplineHeader);
|
|
||||||
splineParsed = tryParseSplinePoints(false, "bare-uncompressed");
|
|
||||||
if (!splineParsed) {
|
|
||||||
packet.setReadPos(beforeSplineHeader);
|
|
||||||
bool useComp = (splineFlags & (0x00080000 | 0x00002000)) == 0;
|
|
||||||
splineParsed = tryParseSplinePoints(useComp, "bare-compressed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!splineParsed) {
|
|
||||||
// Dump first 5 uint32s at beforeSplineHeader for format diagnosis
|
|
||||||
packet.setReadPos(beforeSplineHeader);
|
|
||||||
uint32_t d[5] = {};
|
|
||||||
for (int di = 0; di < 5 && packet.hasRemaining(4); ++di)
|
|
||||||
d[di] = packet.readUInt32();
|
|
||||||
packet.setReadPos(beforeSplineHeader);
|
|
||||||
LOG_WARNING("WotLK spline parse failed for guid=0x", std::hex, block.guid, std::dec,
|
|
||||||
" splineFlags=0x", splineFlags,
|
|
||||||
" remaining=", std::dec, packet.getRemainingSize(),
|
|
||||||
" header=[0x", std::hex, d[0], " 0x", d[1], " 0x", d[2],
|
|
||||||
" 0x", d[3], " 0x", d[4], "]", std::dec);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1424,10 +1294,12 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data)
|
||||||
UpdateBlock block;
|
UpdateBlock block;
|
||||||
if (!parseUpdateBlock(packet, block)) {
|
if (!parseUpdateBlock(packet, block)) {
|
||||||
static int parseBlockErrors = 0;
|
static int parseBlockErrors = 0;
|
||||||
if (++parseBlockErrors <= 5) {
|
const uint32_t lostBlocks = data.blockCount - i;
|
||||||
|
if (++parseBlockErrors <= 10) {
|
||||||
LOG_ERROR("Failed to parse update block ", i + 1, " of ", data.blockCount,
|
LOG_ERROR("Failed to parse update block ", i + 1, " of ", data.blockCount,
|
||||||
" (", i, " blocks parsed successfully before failure)");
|
" (", i, " blocks parsed, ", lostBlocks, " blocks LOST",
|
||||||
if (parseBlockErrors == 5)
|
", remaining=", packet.getRemainingSize(), " bytes)");
|
||||||
|
if (parseBlockErrors == 10)
|
||||||
LOG_ERROR("(suppressing further update block parse errors)");
|
LOG_ERROR("(suppressing further update block parse errors)");
|
||||||
}
|
}
|
||||||
// Cannot reliably re-sync to the next block after a parse failure,
|
// Cannot reliably re-sync to the next block after a parse failure,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "game/world_packets.hpp"
|
#include "game/world_packets.hpp"
|
||||||
#include "game/packet_parsers.hpp"
|
#include "game/packet_parsers.hpp"
|
||||||
|
#include "game/spline_packet.hpp"
|
||||||
#include "game/opcodes.hpp"
|
#include "game/opcodes.hpp"
|
||||||
#include "game/character.hpp"
|
#include "game/character.hpp"
|
||||||
#include "auth/crypto.hpp"
|
#include "auth/crypto.hpp"
|
||||||
|
|
@ -595,96 +596,22 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) {
|
||||||
if (!packet.hasRemaining(4)) return false;
|
if (!packet.hasRemaining(4)) return false;
|
||||||
data.splineFlags = packet.readUInt32();
|
data.splineFlags = packet.readUInt32();
|
||||||
|
|
||||||
// WotLK 3.3.5a SplineFlags (from TrinityCore/MaNGOS MoveSplineFlag.h):
|
// Consolidated spline body parser
|
||||||
// Animation = 0x00400000
|
{
|
||||||
// Parabolic = 0x00000800
|
SplineBlockData spline;
|
||||||
// Catmullrom = 0x00080000 \ either means uncompressed (absolute) waypoints
|
if (!parseMonsterMoveSplineBody(packet, spline, data.splineFlags,
|
||||||
// Flying = 0x00002000 /
|
glm::vec3(data.x, data.y, data.z))) {
|
||||||
|
|
||||||
// [if Animation] uint8 animationType + int32 effectStartTime (5 bytes)
|
|
||||||
if (data.splineFlags & 0x00400000) {
|
|
||||||
if (!packet.hasRemaining(5)) return false;
|
|
||||||
packet.readUInt8(); // animationType
|
|
||||||
packet.readUInt32(); // effectStartTime (int32, read as uint32 same size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// uint32 duration
|
|
||||||
if (!packet.hasRemaining(4)) return false;
|
|
||||||
data.duration = packet.readUInt32();
|
|
||||||
|
|
||||||
// [if Parabolic] float verticalAcceleration + int32 effectStartTime (8 bytes)
|
|
||||||
if (data.splineFlags & 0x00000800) {
|
|
||||||
if (!packet.hasRemaining(8)) return false;
|
|
||||||
packet.readFloat(); // verticalAcceleration
|
|
||||||
packet.readUInt32(); // effectStartTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// uint32 pointCount
|
|
||||||
if (!packet.hasRemaining(4)) return false;
|
|
||||||
uint32_t pointCount = packet.readUInt32();
|
|
||||||
|
|
||||||
if (pointCount == 0) return true;
|
|
||||||
|
|
||||||
constexpr uint32_t kMaxSplinePoints = 1000;
|
|
||||||
if (pointCount > kMaxSplinePoints) {
|
|
||||||
LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints,
|
|
||||||
" (guid=0x", std::hex, data.guid, std::dec, ")");
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
data.duration = spline.duration;
|
||||||
// Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed).
|
if (spline.hasDest) {
|
||||||
// Otherwise: first float3 is final destination, remaining are packed deltas.
|
data.destX = spline.destination.x;
|
||||||
bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0;
|
data.destY = spline.destination.y;
|
||||||
|
data.destZ = spline.destination.z;
|
||||||
if (uncompressed) {
|
|
||||||
// All waypoints stored as absolute float3 (Catmullrom/Flying paths)
|
|
||||||
// Read all intermediate points, then the final destination
|
|
||||||
for (uint32_t i = 0; i < pointCount - 1; i++) {
|
|
||||||
if (!packet.hasRemaining(12)) return true;
|
|
||||||
MonsterMoveData::Point wp;
|
|
||||||
wp.x = packet.readFloat();
|
|
||||||
wp.y = packet.readFloat();
|
|
||||||
wp.z = packet.readFloat();
|
|
||||||
data.waypoints.push_back(wp);
|
|
||||||
}
|
|
||||||
if (!packet.hasRemaining(12)) return true;
|
|
||||||
data.destX = packet.readFloat();
|
|
||||||
data.destY = packet.readFloat();
|
|
||||||
data.destZ = packet.readFloat();
|
|
||||||
data.hasDest = true;
|
data.hasDest = true;
|
||||||
} else {
|
|
||||||
// Compressed: first 3 floats are the destination (final point)
|
|
||||||
if (!packet.hasRemaining(12)) return true;
|
|
||||||
data.destX = packet.readFloat();
|
|
||||||
data.destY = packet.readFloat();
|
|
||||||
data.destZ = packet.readFloat();
|
|
||||||
data.hasDest = true;
|
|
||||||
|
|
||||||
// Remaining waypoints are packed as uint32 deltas from the midpoint
|
|
||||||
// between the creature's start position and the destination.
|
|
||||||
// Encoding matches TrinityCore MoveSpline::PackXYZ:
|
|
||||||
// x = 11-bit signed (bits 0-10), y = 11-bit signed (bits 11-21),
|
|
||||||
// z = 10-bit signed (bits 22-31), each scaled by 0.25 units.
|
|
||||||
if (pointCount > 1) {
|
|
||||||
float midX = (data.x + data.destX) * 0.5f;
|
|
||||||
float midY = (data.y + data.destY) * 0.5f;
|
|
||||||
float midZ = (data.z + data.destZ) * 0.5f;
|
|
||||||
for (uint32_t i = 0; i < pointCount - 1; i++) {
|
|
||||||
if (!packet.hasRemaining(4)) break;
|
|
||||||
uint32_t packed = packet.readUInt32();
|
|
||||||
// Sign-extend 11-bit x and y, 10-bit z (2's complement)
|
|
||||||
int32_t sx = static_cast<int32_t>(packed & 0x7FF);
|
|
||||||
if (sx & 0x400) sx |= static_cast<int32_t>(0xFFFFF800);
|
|
||||||
int32_t sy = static_cast<int32_t>((packed >> 11) & 0x7FF);
|
|
||||||
if (sy & 0x400) sy |= static_cast<int32_t>(0xFFFFF800);
|
|
||||||
int32_t sz = static_cast<int32_t>((packed >> 22) & 0x3FF);
|
|
||||||
if (sz & 0x200) sz |= static_cast<int32_t>(0xFFFFFC00);
|
|
||||||
MonsterMoveData::Point wp;
|
|
||||||
wp.x = midX - static_cast<float>(sx) * 0.25f;
|
|
||||||
wp.y = midY - static_cast<float>(sy) * 0.25f;
|
|
||||||
wp.z = midZ - static_cast<float>(sz) * 0.25f;
|
|
||||||
data.waypoints.push_back(wp);
|
|
||||||
}
|
}
|
||||||
|
for (const auto& wp : spline.waypoints) {
|
||||||
|
data.waypoints.push_back({wp.x, wp.y, wp.z});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -743,65 +670,22 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d
|
||||||
if (!packet.hasRemaining(4)) return false;
|
if (!packet.hasRemaining(4)) return false;
|
||||||
data.splineFlags = packet.readUInt32();
|
data.splineFlags = packet.readUInt32();
|
||||||
|
|
||||||
// Animation flag (same bit as WotLK MoveSplineFlag::Animation)
|
// Consolidated Vanilla spline body parser (always compressed)
|
||||||
if (data.splineFlags & 0x00400000) {
|
{
|
||||||
if (!packet.hasRemaining(5)) return false;
|
SplineBlockData spline;
|
||||||
packet.readUInt8();
|
if (!parseMonsterMoveSplineBodyVanilla(packet, spline, data.splineFlags,
|
||||||
packet.readUInt32();
|
glm::vec3(data.x, data.y, data.z))) {
|
||||||
}
|
|
||||||
|
|
||||||
if (!packet.hasRemaining(4)) return false;
|
|
||||||
data.duration = packet.readUInt32();
|
|
||||||
|
|
||||||
// Parabolic flag (same bit as WotLK MoveSplineFlag::Parabolic)
|
|
||||||
if (data.splineFlags & 0x00000800) {
|
|
||||||
if (!packet.hasRemaining(8)) return false;
|
|
||||||
packet.readFloat();
|
|
||||||
packet.readUInt32();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!packet.hasRemaining(4)) return false;
|
|
||||||
uint32_t pointCount = packet.readUInt32();
|
|
||||||
|
|
||||||
if (pointCount == 0) return true;
|
|
||||||
|
|
||||||
// Reject extreme point counts from malformed packets.
|
|
||||||
constexpr uint32_t kMaxSplinePoints = 1000;
|
|
||||||
if (pointCount > kMaxSplinePoints) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
data.duration = spline.duration;
|
||||||
size_t requiredBytes = 12;
|
if (spline.hasDest) {
|
||||||
if (pointCount > 1) {
|
data.destX = spline.destination.x;
|
||||||
requiredBytes += static_cast<size_t>(pointCount - 1) * 4ull;
|
data.destY = spline.destination.y;
|
||||||
}
|
data.destZ = spline.destination.z;
|
||||||
if (!packet.hasRemaining(requiredBytes)) return false;
|
|
||||||
|
|
||||||
// First float[3] is destination.
|
|
||||||
data.destX = packet.readFloat();
|
|
||||||
data.destY = packet.readFloat();
|
|
||||||
data.destZ = packet.readFloat();
|
|
||||||
data.hasDest = true;
|
data.hasDest = true;
|
||||||
|
}
|
||||||
// Remaining waypoints are packed as uint32 deltas from midpoint.
|
for (const auto& wp : spline.waypoints) {
|
||||||
if (pointCount > 1) {
|
data.waypoints.push_back({wp.x, wp.y, wp.z});
|
||||||
float midX = (data.x + data.destX) * 0.5f;
|
|
||||||
float midY = (data.y + data.destY) * 0.5f;
|
|
||||||
float midZ = (data.z + data.destZ) * 0.5f;
|
|
||||||
for (uint32_t i = 0; i < pointCount - 1; i++) {
|
|
||||||
if (!packet.hasRemaining(4)) break;
|
|
||||||
uint32_t packed = packet.readUInt32();
|
|
||||||
int32_t sx = static_cast<int32_t>(packed & 0x7FF);
|
|
||||||
if (sx & 0x400) sx |= static_cast<int32_t>(0xFFFFF800);
|
|
||||||
int32_t sy = static_cast<int32_t>((packed >> 11) & 0x7FF);
|
|
||||||
if (sy & 0x400) sy |= static_cast<int32_t>(0xFFFFF800);
|
|
||||||
int32_t sz = static_cast<int32_t>((packed >> 22) & 0x3FF);
|
|
||||||
if (sz & 0x200) sz |= static_cast<int32_t>(0xFFFFFC00);
|
|
||||||
MonsterMoveData::Point wp;
|
|
||||||
wp.x = midX - static_cast<float>(sx) * 0.25f;
|
|
||||||
wp.y = midY - static_cast<float>(sy) * 0.25f;
|
|
||||||
wp.z = midZ - static_cast<float>(sz) * 0.25f;
|
|
||||||
data.waypoints.push_back(wp);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -814,7 +698,7 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d
|
||||||
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 2: Combat Core
|
// Combat Core
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) {
|
bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) {
|
||||||
|
|
@ -1035,7 +919,7 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 3: Spells, Action Bar, Auras
|
// Spells, Action Bar, Auras
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data,
|
bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data,
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,7 @@ bool ChannelNotifyParser::parse(network::Packet& packet, ChannelNotifyData& data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 1: Foundation — Targeting, Name Queries
|
// Foundation — Targeting, Name Queries
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
network::Packet SetSelectionPacket::build(uint64_t targetGuid) {
|
network::Packet SetSelectionPacket::build(uint64_t targetGuid) {
|
||||||
|
|
|
||||||
|
|
@ -321,7 +321,7 @@ bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 4: Group/Party System
|
// Group/Party System
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
network::Packet GroupInvitePacket::build(const std::string& playerName) {
|
network::Packet GroupInvitePacket::build(const std::string& playerName) {
|
||||||
|
|
@ -468,7 +468,7 @@ bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 5: Loot System
|
// Loot System
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
network::Packet LootPacket::build(uint64_t targetGuid) {
|
network::Packet LootPacket::build(uint64_t targetGuid) {
|
||||||
|
|
@ -624,7 +624,7 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 5: NPC Gossip
|
// NPC Gossip
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
network::Packet GossipHelloPacket::build(uint64_t npcGuid) {
|
network::Packet GossipHelloPacket::build(uint64_t npcGuid) {
|
||||||
|
|
@ -1090,7 +1090,7 @@ network::Packet QuestgiverChooseRewardPacket::build(uint64_t npcGuid, uint32_t q
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Phase 5: Vendor
|
// Vendor
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
network::Packet ListInventoryPacket::build(uint64_t npcGuid) {
|
network::Packet ListInventoryPacket::build(uint64_t npcGuid) {
|
||||||
|
|
|
||||||
208
src/math/spline.cpp
Normal file
208
src/math/spline.cpp
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
// src/math/spline.cpp
|
||||||
|
// Standalone Catmull-Rom spline implementation.
|
||||||
|
// Ported from TransportManager::evalTimedCatmullRom() + orientationFromTangent()
|
||||||
|
// with improvements: binary search segment lookup, combined position+tangent eval.
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <glm/gtc/matrix_transform.hpp>
|
||||||
|
|
||||||
|
namespace wowee::math {
|
||||||
|
|
||||||
|
CatmullRomSpline::CatmullRomSpline(std::vector<SplineKey> keys, bool timeClosed)
|
||||||
|
: keys_(std::move(keys))
|
||||||
|
, timeClosed_(timeClosed)
|
||||||
|
, durationMs_(0)
|
||||||
|
{
|
||||||
|
if (keys_.size() >= 2) {
|
||||||
|
durationMs_ = keys_.back().timeMs - keys_.front().timeMs;
|
||||||
|
if (durationMs_ == 0) {
|
||||||
|
durationMs_ = 1; // Avoid division by zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
glm::vec3 CatmullRomSpline::evaluatePosition(uint32_t pathTimeMs) const {
|
||||||
|
if (keys_.empty()) {
|
||||||
|
return glm::vec3(0.0f);
|
||||||
|
}
|
||||||
|
if (keys_.size() == 1) {
|
||||||
|
return keys_[0].position;
|
||||||
|
}
|
||||||
|
return evaluate(pathTimeMs).position;
|
||||||
|
}
|
||||||
|
|
||||||
|
SplineEvalResult CatmullRomSpline::evaluate(uint32_t pathTimeMs) const {
|
||||||
|
if (keys_.empty()) {
|
||||||
|
return {glm::vec3(0.0f), glm::vec3(0.0f, 1.0f, 0.0f)};
|
||||||
|
}
|
||||||
|
if (keys_.size() == 1) {
|
||||||
|
return {keys_[0].position, glm::vec3(0.0f, 1.0f, 0.0f)};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the segment containing pathTimeMs (binary search, O(log n))
|
||||||
|
size_t segIdx = findSegment(pathTimeMs);
|
||||||
|
|
||||||
|
// Calculate t (0.0 to 1.0 within segment)
|
||||||
|
uint32_t t1Ms = keys_[segIdx].timeMs;
|
||||||
|
uint32_t t2Ms = keys_[segIdx + 1].timeMs;
|
||||||
|
uint32_t segDuration = (t2Ms > t1Ms) ? (t2Ms - t1Ms) : 1;
|
||||||
|
|
||||||
|
float t = static_cast<float>(pathTimeMs - t1Ms)
|
||||||
|
/ static_cast<float>(segDuration);
|
||||||
|
t = glm::clamp(t, 0.0f, 1.0f);
|
||||||
|
|
||||||
|
// Get 4 control points and evaluate
|
||||||
|
ControlPoints cp = getControlPoints(segIdx);
|
||||||
|
return evalSegment(cp, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
glm::quat CatmullRomSpline::orientationFromTangent(const glm::vec3& tangent) {
|
||||||
|
// Normalize tangent
|
||||||
|
float tangentLenSq = glm::dot(tangent, tangent);
|
||||||
|
if (tangentLenSq < 1e-6f) {
|
||||||
|
return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity
|
||||||
|
}
|
||||||
|
|
||||||
|
glm::vec3 forward = tangent * glm::inversesqrt(tangentLenSq);
|
||||||
|
glm::vec3 up(0.0f, 0.0f, 1.0f); // WoW Z is up
|
||||||
|
|
||||||
|
// If forward is nearly vertical, use different up vector
|
||||||
|
if (std::abs(forward.z) > 0.99f) {
|
||||||
|
up = glm::vec3(0.0f, 1.0f, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
glm::vec3 right = glm::normalize(glm::cross(forward, up));
|
||||||
|
up = glm::normalize(glm::cross(right, forward));
|
||||||
|
|
||||||
|
// Build rotation matrix and convert to quaternion
|
||||||
|
glm::mat3 rotMat;
|
||||||
|
rotMat[0] = right;
|
||||||
|
rotMat[1] = forward;
|
||||||
|
rotMat[2] = up;
|
||||||
|
|
||||||
|
return glm::quat_cast(rotMat);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CatmullRomSpline::hasXYMovement(float minRange) const {
|
||||||
|
if (keys_.size() < 2) return false;
|
||||||
|
|
||||||
|
float minX = keys_[0].position.x, maxX = minX;
|
||||||
|
float minY = keys_[0].position.y, maxY = minY;
|
||||||
|
|
||||||
|
for (size_t i = 1; i < keys_.size(); ++i) {
|
||||||
|
minX = std::min(minX, keys_[i].position.x);
|
||||||
|
maxX = std::max(maxX, keys_[i].position.x);
|
||||||
|
minY = std::min(minY, keys_[i].position.y);
|
||||||
|
maxY = std::max(maxY, keys_[i].position.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
float rangeX = maxX - minX;
|
||||||
|
float rangeY = maxY - minY;
|
||||||
|
return (rangeX >= minRange || rangeY >= minRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t CatmullRomSpline::findNearestKey(const glm::vec3& position) const {
|
||||||
|
if (keys_.empty()) return 0;
|
||||||
|
|
||||||
|
size_t nearest = 0;
|
||||||
|
float bestDistSq = glm::dot(position - keys_[0].position,
|
||||||
|
position - keys_[0].position);
|
||||||
|
|
||||||
|
for (size_t i = 1; i < keys_.size(); ++i) {
|
||||||
|
glm::vec3 diff = position - keys_[i].position;
|
||||||
|
float distSq = glm::dot(diff, diff);
|
||||||
|
if (distSq < bestDistSq) {
|
||||||
|
bestDistSq = distSq;
|
||||||
|
nearest = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
size_t CatmullRomSpline::findSegment(uint32_t pathTimeMs) const {
|
||||||
|
// Binary search for the segment containing pathTimeMs.
|
||||||
|
// Segment i spans [keys_[i].timeMs, keys_[i+1].timeMs).
|
||||||
|
// We need the largest i such that keys_[i].timeMs <= pathTimeMs.
|
||||||
|
|
||||||
|
if (keys_.size() < 2) return 0;
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
if (pathTimeMs <= keys_.front().timeMs) return 0;
|
||||||
|
if (pathTimeMs >= keys_.back().timeMs) return keys_.size() - 2;
|
||||||
|
|
||||||
|
// Binary search: find rightmost key where timeMs <= pathTimeMs
|
||||||
|
size_t lo = 0;
|
||||||
|
size_t hi = keys_.size() - 1;
|
||||||
|
|
||||||
|
while (lo + 1 < hi) {
|
||||||
|
size_t mid = lo + (hi - lo) / 2;
|
||||||
|
if (keys_[mid].timeMs <= pathTimeMs) {
|
||||||
|
lo = mid;
|
||||||
|
} else {
|
||||||
|
hi = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
|
CatmullRomSpline::ControlPoints CatmullRomSpline::getControlPoints(size_t segIdx) const {
|
||||||
|
// Ported from TransportManager::evalTimedCatmullRom control point logic
|
||||||
|
size_t numPoints = keys_.size();
|
||||||
|
|
||||||
|
auto idxClamp = [numPoints](size_t i) -> size_t {
|
||||||
|
return (i >= numPoints) ? (numPoints - 1) : i;
|
||||||
|
};
|
||||||
|
|
||||||
|
size_t p0Idx, p1Idx, p2Idx, p3Idx;
|
||||||
|
p1Idx = segIdx;
|
||||||
|
|
||||||
|
if (timeClosed_) {
|
||||||
|
// Time-closed path: index wraps around (looping transport)
|
||||||
|
p0Idx = (segIdx == 0) ? (numPoints - 1) : (segIdx - 1);
|
||||||
|
p2Idx = (segIdx + 1) % numPoints;
|
||||||
|
p3Idx = (segIdx + 2) % numPoints;
|
||||||
|
} else {
|
||||||
|
// Clamped endpoints (non-looping path)
|
||||||
|
p0Idx = (segIdx == 0) ? 0 : (segIdx - 1);
|
||||||
|
p2Idx = idxClamp(segIdx + 1);
|
||||||
|
p3Idx = idxClamp(segIdx + 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
keys_[p0Idx].position,
|
||||||
|
keys_[p1Idx].position,
|
||||||
|
keys_[p2Idx].position,
|
||||||
|
keys_[p3Idx].position
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SplineEvalResult CatmullRomSpline::evalSegment(
|
||||||
|
const ControlPoints& cp, float t) const
|
||||||
|
{
|
||||||
|
// Standard Catmull-Rom spline formula (from TransportManager::evalTimedCatmullRom)
|
||||||
|
float t2 = t * t;
|
||||||
|
float t3 = t2 * t;
|
||||||
|
|
||||||
|
// Position: 0.5 * ((2*p1) + (-p0+p2)*t + (2p0-5p1+4p2-p3)*t² + (-p0+3p1-3p2+p3)*t³)
|
||||||
|
glm::vec3 position = 0.5f * (
|
||||||
|
(2.0f * cp.p1) +
|
||||||
|
(-cp.p0 + cp.p2) * t +
|
||||||
|
(2.0f * cp.p0 - 5.0f * cp.p1 + 4.0f * cp.p2 - cp.p3) * t2 +
|
||||||
|
(-cp.p0 + 3.0f * cp.p1 - 3.0f * cp.p2 + cp.p3) * t3
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tangent (derivative): 0.5 * ((-p0+p2) + (2p0-5p1+4p2-p3)*2t + (-p0+3p1-3p2+p3)*3t²)
|
||||||
|
// Ported from TransportManager::orientationFromTangent
|
||||||
|
glm::vec3 tangent = 0.5f * (
|
||||||
|
(-cp.p0 + cp.p2) +
|
||||||
|
(2.0f * cp.p0 - 5.0f * cp.p1 + 4.0f * cp.p2 - cp.p3) * 2.0f * t +
|
||||||
|
(-cp.p0 + 3.0f * cp.p1 - 3.0f * cp.p2 + cp.p3) * 3.0f * t2
|
||||||
|
);
|
||||||
|
|
||||||
|
return {position, tangent};
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace wowee::math
|
||||||
|
|
@ -59,6 +59,38 @@ target_link_libraries(test_srp PRIVATE catch2_main OpenSSL::SSL OpenSSL::Crypto)
|
||||||
add_test(NAME srp COMMAND test_srp)
|
add_test(NAME srp COMMAND test_srp)
|
||||||
register_test_target(test_srp)
|
register_test_target(test_srp)
|
||||||
|
|
||||||
|
# ── test_spline ──────────────────────────────────────────────
|
||||||
|
add_executable(test_spline
|
||||||
|
test_spline.cpp
|
||||||
|
${TEST_COMMON_SOURCES}
|
||||||
|
${CMAKE_SOURCE_DIR}/src/math/spline.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_spline PRIVATE ${TEST_INCLUDE_DIRS})
|
||||||
|
target_include_directories(test_spline SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||||
|
target_link_libraries(test_spline PRIVATE catch2_main)
|
||||||
|
if(TARGET glm::glm)
|
||||||
|
target_link_libraries(test_spline PRIVATE glm::glm)
|
||||||
|
endif()
|
||||||
|
add_test(NAME spline COMMAND test_spline)
|
||||||
|
register_test_target(test_spline)
|
||||||
|
|
||||||
|
# ── test_transport_path_repo ─────────────────────────────────
|
||||||
|
add_executable(test_transport_path_repo
|
||||||
|
test_transport_path_repo.cpp
|
||||||
|
${TEST_COMMON_SOURCES}
|
||||||
|
${CMAKE_SOURCE_DIR}/src/game/transport_path_repository.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/math/spline.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/pipeline/dbc_loader.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_transport_path_repo PRIVATE ${TEST_INCLUDE_DIRS})
|
||||||
|
target_include_directories(test_transport_path_repo SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||||
|
target_link_libraries(test_transport_path_repo PRIVATE catch2_main)
|
||||||
|
if(TARGET glm::glm)
|
||||||
|
target_link_libraries(test_transport_path_repo PRIVATE glm::glm)
|
||||||
|
endif()
|
||||||
|
add_test(NAME transport_path_repo COMMAND test_transport_path_repo)
|
||||||
|
register_test_target(test_transport_path_repo)
|
||||||
|
|
||||||
# ── test_opcode_table ────────────────────────────────────────
|
# ── test_opcode_table ────────────────────────────────────────
|
||||||
add_executable(test_opcode_table
|
add_executable(test_opcode_table
|
||||||
test_opcode_table.cpp
|
test_opcode_table.cpp
|
||||||
|
|
@ -76,10 +108,14 @@ add_executable(test_entity
|
||||||
test_entity.cpp
|
test_entity.cpp
|
||||||
${TEST_COMMON_SOURCES}
|
${TEST_COMMON_SOURCES}
|
||||||
${CMAKE_SOURCE_DIR}/src/game/entity.cpp
|
${CMAKE_SOURCE_DIR}/src/game/entity.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/math/spline.cpp
|
||||||
)
|
)
|
||||||
target_include_directories(test_entity PRIVATE ${TEST_INCLUDE_DIRS})
|
target_include_directories(test_entity PRIVATE ${TEST_INCLUDE_DIRS})
|
||||||
target_include_directories(test_entity SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
target_include_directories(test_entity SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||||
target_link_libraries(test_entity PRIVATE catch2_main)
|
target_link_libraries(test_entity PRIVATE catch2_main)
|
||||||
|
if(TARGET glm::glm)
|
||||||
|
target_link_libraries(test_entity PRIVATE glm::glm)
|
||||||
|
endif()
|
||||||
add_test(NAME entity COMMAND test_entity)
|
add_test(NAME entity COMMAND test_entity)
|
||||||
register_test_target(test_entity)
|
register_test_target(test_entity)
|
||||||
|
|
||||||
|
|
@ -210,6 +246,23 @@ endif()
|
||||||
add_test(NAME indoor_shadows COMMAND test_indoor_shadows)
|
add_test(NAME indoor_shadows COMMAND test_indoor_shadows)
|
||||||
register_test_target(test_indoor_shadows)
|
register_test_target(test_indoor_shadows)
|
||||||
|
|
||||||
|
# ── test_transport_components ────────────────────────────────
|
||||||
|
add_executable(test_transport_components
|
||||||
|
test_transport_components.cpp
|
||||||
|
${TEST_COMMON_SOURCES}
|
||||||
|
${CMAKE_SOURCE_DIR}/src/game/transport_clock_sync.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/game/transport_animator.cpp
|
||||||
|
${CMAKE_SOURCE_DIR}/src/math/spline.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(test_transport_components PRIVATE ${TEST_INCLUDE_DIRS})
|
||||||
|
target_include_directories(test_transport_components SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS})
|
||||||
|
target_link_libraries(test_transport_components PRIVATE catch2_main)
|
||||||
|
if(TARGET glm::glm)
|
||||||
|
target_link_libraries(test_transport_components PRIVATE glm::glm)
|
||||||
|
endif()
|
||||||
|
add_test(NAME transport_components COMMAND test_transport_components)
|
||||||
|
register_test_target(test_transport_components)
|
||||||
|
|
||||||
# ── ASAN / UBSan for test targets ────────────────────────────
|
# ── ASAN / UBSan for test targets ────────────────────────────
|
||||||
if(WOWEE_ENABLE_ASAN AND NOT MSVC)
|
if(WOWEE_ENABLE_ASAN AND NOT MSVC)
|
||||||
foreach(_t IN LISTS ALL_TEST_TARGETS)
|
foreach(_t IN LISTS ALL_TEST_TARGETS)
|
||||||
|
|
|
||||||
240
tests/test_spline.cpp
Normal file
240
tests/test_spline.cpp
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
// tests/test_spline.cpp
|
||||||
|
// Unit tests for wowee::math::CatmullRomSpline
|
||||||
|
#include <catch_amalgamated.hpp>
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace wowee::math;
|
||||||
|
|
||||||
|
// ── Helper: build a simple 4-point linear path ─────────────────────
|
||||||
|
static std::vector<SplineKey> linearKeys() {
|
||||||
|
// Straight line along X axis: (0,0,0) → (10,0,0) → (20,0,0) → (30,0,0)
|
||||||
|
return {
|
||||||
|
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
|
||||||
|
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
|
||||||
|
{2000, glm::vec3(20.0f, 0.0f, 0.0f)},
|
||||||
|
{3000, glm::vec3(30.0f, 0.0f, 0.0f)},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: build a square looping path ─────────────────────────────
|
||||||
|
static std::vector<SplineKey> squareKeys() {
|
||||||
|
// Square path: (0,0,0) → (10,0,0) → (10,10,0) → (0,10,0) → (0,0,0)
|
||||||
|
return {
|
||||||
|
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
|
||||||
|
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
|
||||||
|
{2000, glm::vec3(10.0f, 10.0f, 0.0f)},
|
||||||
|
{3000, glm::vec3(0.0f, 10.0f, 0.0f)},
|
||||||
|
{4000, glm::vec3(0.0f, 0.0f, 0.0f)}, // Wrap back to start
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Construction ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline empty construction", "[spline]") {
|
||||||
|
CatmullRomSpline spline({});
|
||||||
|
REQUIRE(spline.keyCount() == 0);
|
||||||
|
REQUIRE(spline.durationMs() == 0);
|
||||||
|
REQUIRE(spline.evaluatePosition(0) == glm::vec3(0.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline single key", "[spline]") {
|
||||||
|
CatmullRomSpline spline({{500, glm::vec3(1.0f, 2.0f, 3.0f)}});
|
||||||
|
REQUIRE(spline.keyCount() == 1);
|
||||||
|
REQUIRE(spline.durationMs() == 0);
|
||||||
|
|
||||||
|
auto pos = spline.evaluatePosition(0);
|
||||||
|
REQUIRE(pos.x == Catch::Approx(1.0f));
|
||||||
|
REQUIRE(pos.y == Catch::Approx(2.0f));
|
||||||
|
REQUIRE(pos.z == Catch::Approx(3.0f));
|
||||||
|
|
||||||
|
// Any time returns the same position
|
||||||
|
pos = spline.evaluatePosition(9999);
|
||||||
|
REQUIRE(pos.x == Catch::Approx(1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline duration calculation", "[spline]") {
|
||||||
|
CatmullRomSpline spline(linearKeys());
|
||||||
|
REQUIRE(spline.durationMs() == 3000);
|
||||||
|
REQUIRE(spline.keyCount() == 4);
|
||||||
|
REQUIRE_FALSE(spline.isTimeClosed());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Position evaluation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline evaluates at key positions", "[spline]") {
|
||||||
|
auto keys = linearKeys();
|
||||||
|
CatmullRomSpline spline(keys);
|
||||||
|
|
||||||
|
// At exact key times, Catmull-Rom passes through the control point
|
||||||
|
auto pos0 = spline.evaluatePosition(0);
|
||||||
|
REQUIRE(pos0.x == Catch::Approx(0.0f).margin(0.01f));
|
||||||
|
|
||||||
|
auto pos1 = spline.evaluatePosition(1000);
|
||||||
|
REQUIRE(pos1.x == Catch::Approx(10.0f).margin(0.01f));
|
||||||
|
|
||||||
|
auto pos2 = spline.evaluatePosition(2000);
|
||||||
|
REQUIRE(pos2.x == Catch::Approx(20.0f).margin(0.01f));
|
||||||
|
|
||||||
|
auto pos3 = spline.evaluatePosition(3000);
|
||||||
|
REQUIRE(pos3.x == Catch::Approx(30.0f).margin(0.01f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline midpoint evaluation", "[spline]") {
|
||||||
|
CatmullRomSpline spline(linearKeys());
|
||||||
|
|
||||||
|
// For a straight line, midpoint should be approximately halfway.
|
||||||
|
// Catmull-Rom with clamped endpoints at segment boundaries
|
||||||
|
// has some overshoot, so use a wider tolerance.
|
||||||
|
auto mid = spline.evaluatePosition(500);
|
||||||
|
REQUIRE(mid.x == Catch::Approx(5.0f).margin(1.0f));
|
||||||
|
REQUIRE(mid.y == Catch::Approx(0.0f).margin(0.1f));
|
||||||
|
REQUIRE(mid.z == Catch::Approx(0.0f).margin(0.1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline clamping at boundaries", "[spline]") {
|
||||||
|
CatmullRomSpline spline(linearKeys());
|
||||||
|
|
||||||
|
// Before start should clamp to first segment start
|
||||||
|
auto before = spline.evaluatePosition(0);
|
||||||
|
REQUIRE(before.x == Catch::Approx(0.0f).margin(0.01f));
|
||||||
|
|
||||||
|
// After end should clamp to last segment end
|
||||||
|
auto after = spline.evaluatePosition(5000);
|
||||||
|
REQUIRE(after.x == Catch::Approx(30.0f).margin(0.01f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Time-closed (looping) path ──────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline time-closed path", "[spline]") {
|
||||||
|
CatmullRomSpline spline(squareKeys(), true);
|
||||||
|
REQUIRE(spline.durationMs() == 4000);
|
||||||
|
REQUIRE(spline.isTimeClosed());
|
||||||
|
|
||||||
|
// Start position
|
||||||
|
auto pos0 = spline.evaluatePosition(0);
|
||||||
|
REQUIRE(pos0.x == Catch::Approx(0.0f).margin(0.1f));
|
||||||
|
REQUIRE(pos0.y == Catch::Approx(0.0f).margin(0.1f));
|
||||||
|
|
||||||
|
// Quarter way — should be near (10, 0, 0)
|
||||||
|
auto pos1 = spline.evaluatePosition(1000);
|
||||||
|
REQUIRE(pos1.x == Catch::Approx(10.0f).margin(0.1f));
|
||||||
|
REQUIRE(pos1.y == Catch::Approx(0.0f).margin(0.1f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tangent / evaluate() ────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline evaluate returns tangent", "[spline]") {
|
||||||
|
CatmullRomSpline spline(linearKeys());
|
||||||
|
|
||||||
|
auto result = spline.evaluate(1500);
|
||||||
|
// For a straight line along X, tangent should be predominantly in X
|
||||||
|
REQUIRE(std::abs(result.tangent.x) > std::abs(result.tangent.y));
|
||||||
|
REQUIRE(std::abs(result.tangent.x) > std::abs(result.tangent.z));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── orientationFromTangent ──────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("orientationFromTangent identity for zero tangent", "[spline]") {
|
||||||
|
auto q = CatmullRomSpline::orientationFromTangent(glm::vec3(0.0f));
|
||||||
|
// Should return identity quaternion
|
||||||
|
REQUIRE(q.w == Catch::Approx(1.0f).margin(0.01f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("orientationFromTangent for forward direction", "[spline]") {
|
||||||
|
auto q = CatmullRomSpline::orientationFromTangent(glm::vec3(1.0f, 0.0f, 0.0f));
|
||||||
|
// Should return a valid quaternion (unit length)
|
||||||
|
float length = glm::length(q);
|
||||||
|
REQUIRE(length == Catch::Approx(1.0f).margin(0.01f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("orientationFromTangent for vertical tangent", "[spline]") {
|
||||||
|
// Nearly vertical tangent — tests the fallback up vector
|
||||||
|
auto q = CatmullRomSpline::orientationFromTangent(glm::vec3(0.0f, 0.0f, 1.0f));
|
||||||
|
float length = glm::length(q);
|
||||||
|
REQUIRE(length == Catch::Approx(1.0f).margin(0.01f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hasXYMovement ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("hasXYMovement detects horizontal movement", "[spline]") {
|
||||||
|
CatmullRomSpline spline(linearKeys());
|
||||||
|
REQUIRE(spline.hasXYMovement(1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("hasXYMovement detects Z-only (elevator)", "[spline]") {
|
||||||
|
std::vector<SplineKey> elevator = {
|
||||||
|
{0, glm::vec3(5.0f, 5.0f, 0.0f)},
|
||||||
|
{1000, glm::vec3(5.0f, 5.0f, 10.0f)},
|
||||||
|
{2000, glm::vec3(5.0f, 5.0f, 20.0f)},
|
||||||
|
};
|
||||||
|
CatmullRomSpline spline(elevator);
|
||||||
|
REQUIRE_FALSE(spline.hasXYMovement(1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── findNearestKey ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("findNearestKey returns closest key", "[spline]") {
|
||||||
|
CatmullRomSpline spline(linearKeys());
|
||||||
|
|
||||||
|
// Closest to (9, 0, 0) should be key at (10, 0, 0) = index 1
|
||||||
|
size_t idx = spline.findNearestKey(glm::vec3(9.0f, 0.0f, 0.0f));
|
||||||
|
REQUIRE(idx == 1);
|
||||||
|
|
||||||
|
// Closest to (0, 0, 0) should be key 0
|
||||||
|
idx = spline.findNearestKey(glm::vec3(0.0f, 0.0f, 0.0f));
|
||||||
|
REQUIRE(idx == 0);
|
||||||
|
|
||||||
|
// Closest to (25, 0, 0) should be key at (20, 0, 0) = index 2 or (30,0,0) = index 3
|
||||||
|
idx = spline.findNearestKey(glm::vec3(25.0f, 0.0f, 0.0f));
|
||||||
|
REQUIRE((idx == 2 || idx == 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Binary search segment lookup ────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline segment lookup is correct", "[spline]") {
|
||||||
|
// Build a path with uneven timing
|
||||||
|
std::vector<SplineKey> keys = {
|
||||||
|
{0, glm::vec3(0.0f)},
|
||||||
|
{100, glm::vec3(1.0f, 0.0f, 0.0f)},
|
||||||
|
{500, glm::vec3(2.0f, 0.0f, 0.0f)},
|
||||||
|
{2000, glm::vec3(3.0f, 0.0f, 0.0f)},
|
||||||
|
{5000, glm::vec3(4.0f, 0.0f, 0.0f)},
|
||||||
|
};
|
||||||
|
CatmullRomSpline spline(keys);
|
||||||
|
|
||||||
|
// At t=50, should be in first segment → position near key 0
|
||||||
|
auto pos50 = spline.evaluatePosition(50);
|
||||||
|
REQUIRE(pos50.x == Catch::Approx(0.5f).margin(0.5f)); // Somewhere between 0 and 1
|
||||||
|
|
||||||
|
// At t=300, should be in second segment → between key 1 and key 2
|
||||||
|
auto pos300 = spline.evaluatePosition(300);
|
||||||
|
REQUIRE(pos300.x > 1.0f);
|
||||||
|
REQUIRE(pos300.x < 2.5f);
|
||||||
|
|
||||||
|
// At t=3000, should be in fourth segment → between key 3 and key 4
|
||||||
|
auto pos3000 = spline.evaluatePosition(3000);
|
||||||
|
REQUIRE(pos3000.x > 2.5f);
|
||||||
|
REQUIRE(pos3000.x < 4.5f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Two-point spline (minimum viable path) ──────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("CatmullRomSpline with two points", "[spline]") {
|
||||||
|
std::vector<SplineKey> keys = {
|
||||||
|
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
|
||||||
|
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
|
||||||
|
};
|
||||||
|
CatmullRomSpline spline(keys);
|
||||||
|
REQUIRE(spline.durationMs() == 1000);
|
||||||
|
|
||||||
|
auto start = spline.evaluatePosition(0);
|
||||||
|
REQUIRE(start.x == Catch::Approx(0.0f).margin(0.01f));
|
||||||
|
|
||||||
|
auto end = spline.evaluatePosition(1000);
|
||||||
|
REQUIRE(end.x == Catch::Approx(10.0f).margin(0.01f));
|
||||||
|
|
||||||
|
// Midpoint should be near 5
|
||||||
|
auto mid = spline.evaluatePosition(500);
|
||||||
|
REQUIRE(mid.x == Catch::Approx(5.0f).margin(1.0f));
|
||||||
|
}
|
||||||
209
tests/test_transport_components.cpp
Normal file
209
tests/test_transport_components.cpp
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
// tests/test_transport_components.cpp
|
||||||
|
// Unit tests for TransportClockSync and TransportAnimator (Phase 3 extractions).
|
||||||
|
#include <catch_amalgamated.hpp>
|
||||||
|
#include "game/transport_clock_sync.hpp"
|
||||||
|
#include "game/transport_animator.hpp"
|
||||||
|
#include "game/transport_manager.hpp"
|
||||||
|
#include "game/transport_path_repository.hpp"
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include <glm/gtc/constants.hpp>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
using namespace wowee::game;
|
||||||
|
using namespace wowee::math;
|
||||||
|
|
||||||
|
// ── Helper: build a simple circular path ──────────────────────────
|
||||||
|
static PathEntry makeCirclePath() {
|
||||||
|
// Circle-ish path with 4 points, 4000ms duration
|
||||||
|
std::vector<SplineKey> keys = {
|
||||||
|
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
|
||||||
|
{1000, glm::vec3(10.0f, 0.0f, 0.0f)},
|
||||||
|
{2000, glm::vec3(10.0f, 10.0f, 0.0f)},
|
||||||
|
{3000, glm::vec3(0.0f, 10.0f, 0.0f)},
|
||||||
|
{4000, glm::vec3(0.0f, 0.0f, 0.0f)},
|
||||||
|
};
|
||||||
|
CatmullRomSpline spline(std::move(keys), /*timeClosed=*/true);
|
||||||
|
return PathEntry(std::move(spline), /*pathId=*/100, /*zOnly=*/false, /*fromDBC=*/true, /*worldCoords=*/false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: create a fresh ActiveTransport ────────────────────────
|
||||||
|
static ActiveTransport makeTransport(uint64_t guid = 1, uint32_t pathId = 100) {
|
||||||
|
ActiveTransport t{};
|
||||||
|
t.guid = guid;
|
||||||
|
t.pathId = pathId;
|
||||||
|
t.basePosition = glm::vec3(100.0f, 200.0f, 0.0f);
|
||||||
|
t.position = glm::vec3(100.0f, 200.0f, 0.0f);
|
||||||
|
t.rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f);
|
||||||
|
t.playerOnBoard = false;
|
||||||
|
t.playerLocalOffset = glm::vec3(0);
|
||||||
|
t.hasDeckBounds = false;
|
||||||
|
t.localClockMs = 0;
|
||||||
|
t.hasServerClock = false;
|
||||||
|
t.serverClockOffsetMs = 0;
|
||||||
|
t.useClientAnimation = true;
|
||||||
|
t.clientAnimationReverse = false;
|
||||||
|
t.serverYaw = 0.0f;
|
||||||
|
t.hasServerYaw = false;
|
||||||
|
t.serverYawFlipped180 = false;
|
||||||
|
t.serverYawAlignmentScore = 0;
|
||||||
|
t.lastServerUpdate = 0.0;
|
||||||
|
t.serverUpdateCount = 0;
|
||||||
|
t.serverLinearVelocity = glm::vec3(0);
|
||||||
|
t.serverAngularVelocity = 0.0f;
|
||||||
|
t.hasServerVelocity = false;
|
||||||
|
t.allowBootstrapVelocity = true;
|
||||||
|
t.isM2 = false;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// TransportClockSync tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
TEST_CASE("ClockSync: client animation advances localClockMs", "[transport_clock_sync]") {
|
||||||
|
TransportClockSync sync;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.useClientAnimation = true;
|
||||||
|
t.hasServerClock = false;
|
||||||
|
|
||||||
|
uint32_t pathTimeMs = 0;
|
||||||
|
bool result = sync.computePathTime(t, path.spline, 1.0, 0.016f, pathTimeMs);
|
||||||
|
REQUIRE(result);
|
||||||
|
REQUIRE(t.localClockMs > 0); // Should have advanced
|
||||||
|
REQUIRE(pathTimeMs == t.localClockMs % path.spline.durationMs());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ClockSync: server clock mode wraps correctly", "[transport_clock_sync]") {
|
||||||
|
TransportClockSync sync;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.hasServerClock = true;
|
||||||
|
t.serverClockOffsetMs = 500; // Server is 500ms ahead
|
||||||
|
|
||||||
|
uint32_t pathTimeMs = 0;
|
||||||
|
double elapsedTime = 3.7; // 3700ms local → 4200ms server → 200ms wrapped (dur=4000)
|
||||||
|
bool result = sync.computePathTime(t, path.spline, elapsedTime, 0.016f, pathTimeMs);
|
||||||
|
REQUIRE(result);
|
||||||
|
REQUIRE(pathTimeMs == 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ClockSync: strict server mode returns false", "[transport_clock_sync]") {
|
||||||
|
TransportClockSync sync;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.useClientAnimation = false;
|
||||||
|
t.hasServerClock = false;
|
||||||
|
|
||||||
|
uint32_t pathTimeMs = 0;
|
||||||
|
bool result = sync.computePathTime(t, path.spline, 1.0, 0.016f, pathTimeMs);
|
||||||
|
REQUIRE_FALSE(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ClockSync: reverse client animation decrements", "[transport_clock_sync]") {
|
||||||
|
TransportClockSync sync;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.useClientAnimation = true;
|
||||||
|
t.clientAnimationReverse = true;
|
||||||
|
t.localClockMs = 2000;
|
||||||
|
|
||||||
|
uint32_t pathTimeMs = 0;
|
||||||
|
bool result = sync.computePathTime(t, path.spline, 1.0, 0.5f, pathTimeMs);
|
||||||
|
REQUIRE(result);
|
||||||
|
// localClockMs should have decreased by ~500ms
|
||||||
|
REQUIRE(t.localClockMs < 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ClockSync: processServerUpdate sets yaw and rotation", "[transport_clock_sync]") {
|
||||||
|
TransportClockSync sync;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
|
||||||
|
glm::vec3 pos(105.0f, 205.0f, 1.0f);
|
||||||
|
float yaw = 1.5f;
|
||||||
|
sync.processServerUpdate(t, &path, pos, yaw, 10.0);
|
||||||
|
|
||||||
|
REQUIRE(t.serverUpdateCount == 1);
|
||||||
|
REQUIRE(t.hasServerYaw);
|
||||||
|
REQUIRE(t.serverYaw == Catch::Approx(1.5f));
|
||||||
|
REQUIRE(t.position == pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("ClockSync: yaw flip detection after repeated misaligned updates", "[transport_clock_sync]") {
|
||||||
|
TransportClockSync sync;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.useClientAnimation = false;
|
||||||
|
|
||||||
|
// Simulate transport moving east (+X) but reporting yaw pointing west (pi)
|
||||||
|
float westYaw = glm::pi<float>();
|
||||||
|
glm::vec3 pos(100.0f, 200.0f, 0.0f);
|
||||||
|
sync.processServerUpdate(t, &path, pos, westYaw, 1.0);
|
||||||
|
|
||||||
|
// Send several updates moving east with west-facing yaw
|
||||||
|
for (int i = 1; i <= 8; i++) {
|
||||||
|
pos.x += 5.0f;
|
||||||
|
sync.processServerUpdate(t, &path, pos, westYaw, 1.0 + i * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After enough misaligned updates, should have flipped
|
||||||
|
REQUIRE(t.serverYawFlipped180);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// TransportAnimator tests
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
TEST_CASE("Animator: evaluateAndApply updates position from spline", "[transport_animator]") {
|
||||||
|
TransportAnimator animator;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.hasServerYaw = false;
|
||||||
|
|
||||||
|
animator.evaluateAndApply(t, path, 0);
|
||||||
|
// At t=0, path offset is (0,0,0), so pos = base + (0,0,0) = (100,200,0)
|
||||||
|
REQUIRE(t.position.x == Catch::Approx(100.0f));
|
||||||
|
REQUIRE(t.position.y == Catch::Approx(200.0f));
|
||||||
|
|
||||||
|
animator.evaluateAndApply(t, path, 1000);
|
||||||
|
// At t=1000, path offset is (10,0,0), so pos = base + (10,0,0) = (110,200,0)
|
||||||
|
REQUIRE(t.position.x == Catch::Approx(110.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Animator: uses server yaw when available", "[transport_animator]") {
|
||||||
|
TransportAnimator animator;
|
||||||
|
auto path = makeCirclePath();
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.hasServerYaw = true;
|
||||||
|
t.serverYaw = 1.0f;
|
||||||
|
t.serverYawFlipped180 = false;
|
||||||
|
|
||||||
|
animator.evaluateAndApply(t, path, 500);
|
||||||
|
// Rotation should be based on serverYaw=1.0, not spline tangent
|
||||||
|
float expectedYaw = 1.0f;
|
||||||
|
glm::quat expected = glm::angleAxis(expectedYaw, glm::vec3(0.0f, 0.0f, 1.0f));
|
||||||
|
REQUIRE(t.rotation.w == Catch::Approx(expected.w).margin(0.01f));
|
||||||
|
REQUIRE(t.rotation.z == Catch::Approx(expected.z).margin(0.01f));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("Animator: Z clamping on non-world-coord client anim", "[transport_animator]") {
|
||||||
|
TransportAnimator animator;
|
||||||
|
|
||||||
|
// Build a path with a deep negative Z offset
|
||||||
|
std::vector<SplineKey> keys = {
|
||||||
|
{0, glm::vec3(0.0f, 0.0f, 0.0f)},
|
||||||
|
{1000, glm::vec3(5.0f, 0.0f, -50.0f)}, // Deep negative Z
|
||||||
|
{2000, glm::vec3(10.0f, 0.0f, 0.0f)},
|
||||||
|
};
|
||||||
|
CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
PathEntry path(std::move(spline), 200, false, true, false);
|
||||||
|
|
||||||
|
auto t = makeTransport();
|
||||||
|
t.useClientAnimation = true;
|
||||||
|
t.serverUpdateCount = 0; // <= 1, so Z clamping applies
|
||||||
|
|
||||||
|
animator.evaluateAndApply(t, path, 1000);
|
||||||
|
// Z should be clamped to >= -2.0 (kMinFallbackZOffset)
|
||||||
|
REQUIRE(t.position.z >= (t.basePosition.z - 2.0f));
|
||||||
|
}
|
||||||
315
tests/test_transport_path_repo.cpp
Normal file
315
tests/test_transport_path_repo.cpp
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
// Tests for TransportPathRepository (Phase 3 of spline refactoring).
|
||||||
|
// Verifies PathEntry wrapping, path operations, and CatmullRomSpline integration.
|
||||||
|
#include <catch2/catch_amalgamated.hpp>
|
||||||
|
#include "game/transport_path_repository.hpp"
|
||||||
|
#include "pipeline/asset_manager.hpp"
|
||||||
|
#include "math/spline.hpp"
|
||||||
|
#include <glm/glm.hpp>
|
||||||
|
#include <vector>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
// ── Minimal stubs for pipeline symbols referenced by DBC loading functions ──
|
||||||
|
// The test never calls loadTransportAnimationDBC / loadTaxiPathNodeDBC,
|
||||||
|
// but the linker still needs these symbols from the compiled translation unit.
|
||||||
|
namespace wowee::pipeline {
|
||||||
|
AssetManager::AssetManager() {}
|
||||||
|
AssetManager::~AssetManager() {}
|
||||||
|
std::vector<uint8_t> AssetManager::readFile(const std::string&) const { return {}; }
|
||||||
|
std::vector<uint8_t> AssetManager::readFileOptional(const std::string&) const { return {}; }
|
||||||
|
} // namespace wowee::pipeline
|
||||||
|
|
||||||
|
using namespace wowee;
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
static constexpr float kEps = 0.001f;
|
||||||
|
|
||||||
|
static void requireVec3Near(const glm::vec3& v, float x, float y, float z,
|
||||||
|
float eps = kEps) {
|
||||||
|
REQUIRE(std::abs(v.x - x) < eps);
|
||||||
|
REQUIRE(std::abs(v.y - y) < eps);
|
||||||
|
REQUIRE(std::abs(v.z - z) < eps);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PathEntry construction ─────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("PathEntry wraps CatmullRomSpline with metadata", "[transport_path_repo]") {
|
||||||
|
std::vector<math::SplineKey> keys = {
|
||||||
|
{0, {0.0f, 0.0f, 0.0f}},
|
||||||
|
{1000, {10.0f, 0.0f, 0.0f}},
|
||||||
|
{2000, {20.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
game::PathEntry entry(std::move(spline), 42, false, true, false);
|
||||||
|
|
||||||
|
REQUIRE(entry.pathId == 42);
|
||||||
|
REQUIRE(entry.fromDBC == true);
|
||||||
|
REQUIRE(entry.zOnly == false);
|
||||||
|
REQUIRE(entry.worldCoords == false);
|
||||||
|
REQUIRE(entry.spline.keyCount() == 3);
|
||||||
|
REQUIRE(entry.spline.durationMs() == 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("PathEntry is move-constructible", "[transport_path_repo]") {
|
||||||
|
std::vector<math::SplineKey> keys = {
|
||||||
|
{0, {0.0f, 0.0f, 0.0f}},
|
||||||
|
{500, {5.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
game::PathEntry entry(std::move(spline), 99, true, false, true);
|
||||||
|
|
||||||
|
game::PathEntry moved(std::move(entry));
|
||||||
|
REQUIRE(moved.pathId == 99);
|
||||||
|
REQUIRE(moved.zOnly == true);
|
||||||
|
REQUIRE(moved.worldCoords == true);
|
||||||
|
REQUIRE(moved.spline.keyCount() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository: loadPathFromNodes ──────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("loadPathFromNodes stores a retrievable path", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
std::vector<glm::vec3> waypoints = {
|
||||||
|
{0.0f, 0.0f, 0.0f},
|
||||||
|
{100.0f, 0.0f, 0.0f},
|
||||||
|
{200.0f, 0.0f, 0.0f},
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.loadPathFromNodes(1001, waypoints, true, 10.0f);
|
||||||
|
auto* entry = repo.findPath(1001);
|
||||||
|
REQUIRE(entry != nullptr);
|
||||||
|
REQUIRE(entry->pathId == 1001);
|
||||||
|
REQUIRE(entry->fromDBC == false);
|
||||||
|
REQUIRE(entry->zOnly == false);
|
||||||
|
// 3 waypoints + 1 wrap point = 4 keys
|
||||||
|
REQUIRE(entry->spline.keyCount() == 4);
|
||||||
|
REQUIRE(entry->spline.durationMs() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("loadPathFromNodes single waypoint creates stationary path", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
repo.loadPathFromNodes(2001, {{5.0f, 6.0f, 7.0f}}, false, 10.0f);
|
||||||
|
|
||||||
|
auto* entry = repo.findPath(2001);
|
||||||
|
REQUIRE(entry != nullptr);
|
||||||
|
REQUIRE(entry->spline.keyCount() == 1);
|
||||||
|
// Stationary: duration is the last key's time (0ms for single key)
|
||||||
|
requireVec3Near(entry->spline.evaluatePosition(0), 5.0f, 6.0f, 7.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("loadPathFromNodes non-looping omits wrap point", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
std::vector<glm::vec3> waypoints = {
|
||||||
|
{0.0f, 0.0f, 0.0f},
|
||||||
|
{50.0f, 0.0f, 0.0f},
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.loadPathFromNodes(3001, waypoints, false, 10.0f);
|
||||||
|
auto* entry = repo.findPath(3001);
|
||||||
|
REQUIRE(entry != nullptr);
|
||||||
|
// Non-looping: 2 waypoints, no wrap point
|
||||||
|
REQUIRE(entry->spline.keyCount() == 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("loadPathFromNodes looping adds wrap point", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
std::vector<glm::vec3> waypoints = {
|
||||||
|
{0.0f, 0.0f, 0.0f},
|
||||||
|
{50.0f, 0.0f, 0.0f},
|
||||||
|
};
|
||||||
|
|
||||||
|
repo.loadPathFromNodes(3002, waypoints, true, 10.0f);
|
||||||
|
auto* entry = repo.findPath(3002);
|
||||||
|
REQUIRE(entry != nullptr);
|
||||||
|
// Looping: 2 waypoints + 1 wrap point = 3 keys
|
||||||
|
REQUIRE(entry->spline.keyCount() == 3);
|
||||||
|
// Wrap point should match first waypoint
|
||||||
|
const auto& keys = entry->spline.keys();
|
||||||
|
requireVec3Near(keys.back().position, 0.0f, 0.0f, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository: findPath / storePath / hasPathForEntry ──────────
|
||||||
|
|
||||||
|
TEST_CASE("findPath returns nullptr for missing paths", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
REQUIRE(repo.findPath(999) == nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("storePath overwrites existing paths", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
|
||||||
|
// Store first path
|
||||||
|
std::vector<math::SplineKey> keys1 = {{0, {0, 0, 0}}, {1000, {10, 0, 0}}};
|
||||||
|
math::CatmullRomSpline spline1(std::move(keys1), false);
|
||||||
|
repo.storePath(500, game::PathEntry(std::move(spline1), 500, false, true, false));
|
||||||
|
|
||||||
|
auto* e1 = repo.findPath(500);
|
||||||
|
REQUIRE(e1 != nullptr);
|
||||||
|
REQUIRE(e1->spline.keyCount() == 2);
|
||||||
|
|
||||||
|
// Overwrite with different path
|
||||||
|
std::vector<math::SplineKey> keys2 = {{0, {0, 0, 0}}, {500, {5, 0, 0}}, {1000, {10, 5, 0}}};
|
||||||
|
math::CatmullRomSpline spline2(std::move(keys2), false);
|
||||||
|
repo.storePath(500, game::PathEntry(std::move(spline2), 500, true, false, true));
|
||||||
|
|
||||||
|
auto* e2 = repo.findPath(500);
|
||||||
|
REQUIRE(e2 != nullptr);
|
||||||
|
REQUIRE(e2->spline.keyCount() == 3);
|
||||||
|
REQUIRE(e2->zOnly == true);
|
||||||
|
REQUIRE(e2->worldCoords == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("hasPathForEntry checks fromDBC flag", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
|
||||||
|
// Non-DBC path
|
||||||
|
repo.loadPathFromNodes(700, {{0, 0, 0}, {10, 0, 0}}, true, 10.0f);
|
||||||
|
REQUIRE(repo.hasPathForEntry(700) == false); // fromDBC=false
|
||||||
|
|
||||||
|
// DBC path (via storePath)
|
||||||
|
std::vector<math::SplineKey> keys = {{0, {0, 0, 0}}, {1000, {10, 0, 0}}};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
repo.storePath(701, game::PathEntry(std::move(spline), 701, false, true, false));
|
||||||
|
REQUIRE(repo.hasPathForEntry(701) == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository: hasUsableMovingPathForEntry ─────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("hasUsableMovingPathForEntry rejects stationary/z-only", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
|
||||||
|
// Single-point path (stationary)
|
||||||
|
std::vector<math::SplineKey> keys1 = {{0, {0, 0, 0}}};
|
||||||
|
math::CatmullRomSpline sp1(std::move(keys1), false);
|
||||||
|
repo.storePath(800, game::PathEntry(std::move(sp1), 800, false, true, false));
|
||||||
|
REQUIRE(repo.hasUsableMovingPathForEntry(800) == false);
|
||||||
|
|
||||||
|
// Z-only path (flagged)
|
||||||
|
std::vector<math::SplineKey> keys2 = {{0, {0, 0, 0}}, {1000, {0, 0, 5}}};
|
||||||
|
math::CatmullRomSpline sp2(std::move(keys2), false);
|
||||||
|
repo.storePath(801, game::PathEntry(std::move(sp2), 801, true, true, false));
|
||||||
|
REQUIRE(repo.hasUsableMovingPathForEntry(801) == false);
|
||||||
|
|
||||||
|
// Moving XY path
|
||||||
|
std::vector<math::SplineKey> keys3 = {{0, {0, 0, 0}}, {1000, {100, 0, 0}}};
|
||||||
|
math::CatmullRomSpline sp3(std::move(keys3), false);
|
||||||
|
repo.storePath(802, game::PathEntry(std::move(sp3), 802, false, true, false));
|
||||||
|
REQUIRE(repo.hasUsableMovingPathForEntry(802) == true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository: inferDbcPathForSpawn ────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("inferDbcPathForSpawn finds nearest DBC path", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
|
||||||
|
// Path A at (100, 0, 0)
|
||||||
|
std::vector<math::SplineKey> keysA = {{0, {100, 0, 0}}, {1000, {200, 0, 0}}};
|
||||||
|
math::CatmullRomSpline spA(std::move(keysA), false);
|
||||||
|
repo.storePath(10, game::PathEntry(std::move(spA), 10, false, true, false));
|
||||||
|
|
||||||
|
// Path B at (500, 0, 0)
|
||||||
|
std::vector<math::SplineKey> keysB = {{0, {500, 0, 0}}, {1000, {600, 0, 0}}};
|
||||||
|
math::CatmullRomSpline spB(std::move(keysB), false);
|
||||||
|
repo.storePath(20, game::PathEntry(std::move(spB), 20, false, true, false));
|
||||||
|
|
||||||
|
// Spawn near Path A
|
||||||
|
uint32_t result = repo.inferDbcPathForSpawn({105.0f, 0.0f, 0.0f}, 200.0f, true);
|
||||||
|
REQUIRE(result == 10);
|
||||||
|
|
||||||
|
// Spawn near Path B
|
||||||
|
result = repo.inferDbcPathForSpawn({510.0f, 0.0f, 0.0f}, 200.0f, true);
|
||||||
|
REQUIRE(result == 20);
|
||||||
|
|
||||||
|
// Spawn too far from both
|
||||||
|
result = repo.inferDbcPathForSpawn({9999.0f, 0.0f, 0.0f}, 200.0f, true);
|
||||||
|
REQUIRE(result == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("inferMovingPathForSpawn skips z-only paths", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
|
||||||
|
// Z-only path near spawn
|
||||||
|
std::vector<math::SplineKey> keys = {{0, {10, 0, 0}}, {1000, {10, 0, 5}}};
|
||||||
|
math::CatmullRomSpline sp(std::move(keys), false);
|
||||||
|
repo.storePath(30, game::PathEntry(std::move(sp), 30, true, true, false));
|
||||||
|
|
||||||
|
// inferMovingPathForSpawn passes allowZOnly=false
|
||||||
|
uint32_t result = repo.inferMovingPathForSpawn({10.0f, 0.0f, 0.0f}, 200.0f);
|
||||||
|
REQUIRE(result == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Repository: taxi paths ─────────────────────────────────────
|
||||||
|
|
||||||
|
TEST_CASE("hasTaxiPath and findTaxiPath", "[transport_path_repo]") {
|
||||||
|
game::TransportPathRepository repo;
|
||||||
|
REQUIRE(repo.hasTaxiPath(100) == false);
|
||||||
|
REQUIRE(repo.findTaxiPath(100) == nullptr);
|
||||||
|
|
||||||
|
// loadTaxiPathNodeDBC would populate this, but we can't test it without AssetManager.
|
||||||
|
// This just verifies the API works with empty data.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Spline evaluation through PathEntry (Phase 3 integration) ──
|
||||||
|
|
||||||
|
TEST_CASE("PathEntry spline evaluates position at midpoint", "[transport_path_repo]") {
|
||||||
|
std::vector<math::SplineKey> keys = {
|
||||||
|
{0, {0.0f, 0.0f, 0.0f}},
|
||||||
|
{1000, {100.0f, 0.0f, 0.0f}},
|
||||||
|
{2000, {200.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
game::PathEntry entry(std::move(spline), 1, false, true, false);
|
||||||
|
|
||||||
|
// At t=1000ms, should be near (100, 0, 0) — exactly at key 1
|
||||||
|
glm::vec3 pos = entry.spline.evaluatePosition(1000);
|
||||||
|
requireVec3Near(pos, 100.0f, 0.0f, 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("PathEntry spline evaluates position at interpolated time", "[transport_path_repo]") {
|
||||||
|
std::vector<math::SplineKey> keys = {
|
||||||
|
{0, {0.0f, 0.0f, 0.0f}},
|
||||||
|
{1000, {100.0f, 0.0f, 0.0f}},
|
||||||
|
{2000, {200.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
game::PathEntry entry(std::move(spline), 1, false, true, false);
|
||||||
|
|
||||||
|
// At t=500ms, should be approximately (50, 0, 0)
|
||||||
|
glm::vec3 pos = entry.spline.evaluatePosition(500);
|
||||||
|
REQUIRE(pos.x > 40.0f);
|
||||||
|
REQUIRE(pos.x < 60.0f);
|
||||||
|
REQUIRE(std::abs(pos.y) < 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("PathEntry spline evaluate returns tangent for orientation", "[transport_path_repo]") {
|
||||||
|
std::vector<math::SplineKey> keys = {
|
||||||
|
{0, {0.0f, 0.0f, 0.0f}},
|
||||||
|
{1000, {100.0f, 0.0f, 0.0f}},
|
||||||
|
{2000, {200.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
game::PathEntry entry(std::move(spline), 1, false, true, false);
|
||||||
|
|
||||||
|
// Tangent at midpoint should point roughly in +X direction
|
||||||
|
auto result = entry.spline.evaluate(1000);
|
||||||
|
REQUIRE(result.tangent.x > 0.0f);
|
||||||
|
REQUIRE(std::abs(result.tangent.y) < 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_CASE("PathEntry findNearestKey finds closest waypoint", "[transport_path_repo]") {
|
||||||
|
std::vector<math::SplineKey> keys = {
|
||||||
|
{0, {0.0f, 0.0f, 0.0f}},
|
||||||
|
{1000, {100.0f, 0.0f, 0.0f}},
|
||||||
|
{2000, {200.0f, 0.0f, 0.0f}},
|
||||||
|
};
|
||||||
|
math::CatmullRomSpline spline(std::move(keys), false);
|
||||||
|
game::PathEntry entry(std::move(spline), 1, false, true, false);
|
||||||
|
|
||||||
|
// Point near key 1 (100, 0, 0)
|
||||||
|
size_t nearest = entry.spline.findNearestKey({105.0f, 0.0f, 0.0f});
|
||||||
|
REQUIRE(nearest == 1);
|
||||||
|
|
||||||
|
// Point near key 2 (200, 0, 0)
|
||||||
|
nearest = entry.spline.findNearestKey({195.0f, 0.0f, 0.0f});
|
||||||
|
REQUIRE(nearest == 2);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue