diff --git a/CMakeLists.txt b/CMakeLists.txt index 72594684..9eaa8148 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -504,6 +504,9 @@ set(WOWEE_SOURCES src/core/logger.cpp src/core/memory_monitor.cpp + # Math + src/math/spline.cpp + # Network src/network/socket.cpp src/network/packet.cpp @@ -543,6 +546,9 @@ set(WOWEE_SOURCES src/game/warden_emulator.cpp src/game/warden_memory.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/player.cpp src/game/entity.cpp @@ -552,6 +558,7 @@ set(WOWEE_SOURCES src/game/world_packets_entity.cpp src/game/world_packets_world.cpp src/game/world_packets_economy.cpp + src/game/spline_packet.cpp src/game/packet_parsers_tbc.cpp src/game/packet_parsers_classic.cpp src/game/character.cpp diff --git a/include/core/appearance_composer.hpp b/include/core/appearance_composer.hpp index d38ab53c..4e5e2c6f 100644 --- a/include/core/appearance_composer.hpp +++ b/include/core/appearance_composer.hpp @@ -74,6 +74,10 @@ public: bool isWeaponsSheathed() const { return 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) const std::string& getBodySkinPath() const { return bodySkinPath_; } const std::vector& getUnderwearPaths() const { return underwearPaths_; } @@ -96,6 +100,7 @@ private: uint32_t cloakTextureSlotIndex_ = 0; bool weaponsSheathed_ = false; + bool showingRanged_ = false; }; } // namespace core diff --git a/include/core/application.hpp b/include/core/application.hpp index 4e7fd2d0..b7a69d20 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -113,7 +113,7 @@ public: // World loader access WorldLoader* getWorldLoader() { return worldLoader_.get(); } - // Audio coordinator access (Section 4.1: extracted audio subsystem) + // Audio coordinator access audio::AudioCoordinator* getAudioCoordinator() { return audioCoordinator_.get(); } private: diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 2166bfa8..981aeb82 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include "math/spline.hpp" namespace wowee { namespace game { @@ -80,31 +82,41 @@ public: orientation = o; isMoving_ = false; // Instant position set cancels interpolation usePathMode_ = false; + activeSpline_.reset(); } - // Multi-segment path movement + // Multi-segment path movement (Catmull-Rom spline interpolation) void startMoveAlongPath(const std::vector>& path, float destO, float totalDuration) { if (path.empty()) return; if (path.size() == 1 || totalDuration <= 0.0f) { startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); return; } - // Compute cumulative distances for proportional segment timing - pathPoints_ = path; - pathSegDists_.resize(path.size()); - pathSegDists_[0] = 0.0f; + // Build cumulative distances for proportional time assignment + std::vector cumDist(path.size(), 0.0f); float totalDist = 0.0f; for (size_t i = 1; i < path.size(); i++) { float dx = path[i][0] - path[i - 1][0]; float dy = path[i][1] - path[i - 1][1]; float dz = path[i][2] - path[i - 1][2]; totalDist += std::sqrt(dx * dx + dy * dy + dz * dz); - pathSegDists_[i] = totalDist; + cumDist[i] = totalDist; } if (totalDist < 0.001f) { startMoveTo(path.back()[0], path.back()[1], path.back()[2], destO, totalDuration); return; } + // Build SplineKeys with distance-proportional time + uint32_t durationMs = static_cast(totalDuration * 1000.0f); + std::vector keys(path.size()); + for (size_t i = 0; i < path.size(); i++) { + float fraction = cumDist[i] / totalDist; + keys[i].timeMs = static_cast(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 if (isMoving_ && moveElapsed_ >= moveDuration_) { x = moveEndX_; y = moveEndY_; z = moveEndZ_; @@ -130,6 +142,7 @@ public: // Movement interpolation (syncs entity position with renderer during movement) void startMoveTo(float destX, float destY, float destZ, float destO, float durationSec) { usePathMode_ = false; + activeSpline_.reset(); if (durationSec <= 0.0f) { setPosition(destX, destY, destZ, destO); return; @@ -172,24 +185,14 @@ public: if (!isMoving_) return; moveElapsed_ += deltaTime; if (moveElapsed_ < moveDuration_) { - if (usePathMode_ && pathPoints_.size() > 1) { - // Multi-segment path interpolation - float totalDist = pathSegDists_.back(); - float t = moveElapsed_ / moveDuration_; - float targetDist = t * totalDist; - // Find the segment containing targetDist - size_t seg = 1; - while (seg < pathSegDists_.size() - 1 && pathSegDists_[seg] < targetDist) - 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; + if (usePathMode_ && activeSpline_) { + // Catmull-Rom spline interpolation + uint32_t pathTimeMs = static_cast(moveElapsed_ * 1000.0f); + if (pathTimeMs >= splineDurationMs_) pathTimeMs = splineDurationMs_ - 1; + glm::vec3 pos = activeSpline_->evaluatePosition(pathTimeMs); + x = pos.x; + y = pos.y; + z = pos.z; } else { // Single-segment linear interpolation float t = moveElapsed_ / moveDuration_; @@ -277,9 +280,9 @@ protected: float moveDuration_ = 0; float moveElapsed_ = 0; float velX_ = 0, velY_ = 0, velZ_ = 0; // Smoothed velocity for dead reckoning - // Multi-segment path data - std::vector> pathPoints_; - std::vector pathSegDists_; // Cumulative distances for each waypoint + // CatmullRom spline for multi-segment path movement (replaces linear pathPoints_/pathSegDists_) + std::optional activeSpline_; + uint32_t splineDurationMs_ = 0; }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 7cff33f0..b59eec47 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -199,7 +199,7 @@ public: using CharCreateCallback = std::function; void setCharCreateCallback(CharCreateCallback cb) { charCreateCallback_ = std::move(cb); } - using CharDeleteCallback = std::function; + using CharDeleteCallback = std::function; void setCharDeleteCallback(CharDeleteCallback cb) { charDeleteCallback_ = std::move(cb); } uint8_t getLastCharDeleteResult() const { return lastCharDeleteResult_; } @@ -943,6 +943,10 @@ public: using MeleeSwingCallback = std::function; 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 setRangedWeaponSwapCallback(RangedWeaponSwapCallback cb) { rangedWeaponSwapCallback_ = std::move(cb); } + // Spell cast animation callbacks — true=start cast/channel, false=finish/cancel // guid: caster (may be player or another unit), isChannel: channel vs regular cast // castType: DIRECTED (unit target), OMNI (self/no target), AREA (ground AoE) @@ -2399,6 +2403,13 @@ public: auto& knockBackCallbackRef() { return knockBackCallback_; } auto& lootWindowCallbackRef() { return lootWindowCallback_; } 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& npcAggroCallbackRef() { return npcAggroCallback_; } auto& npcDeathCallbackRef() { return npcDeathCallback_; } @@ -3336,6 +3347,10 @@ private: CharDeleteCallback charDeleteCallback_; CharLoginFailCallback charLoginFailCallback_; uint8_t lastCharDeleteResult_ = 0xFF; + bool pendingCharDeleteResponse_ = false; + uint64_t pendingDeleteGuid_ = 0; + float pendingDeleteTimer_ = 0.0f; + bool pendingDeleteFallbackEnum_ = false; bool pendingCharCreateResult_ = false; bool pendingCharCreateSuccess_ = false; std::string pendingCharCreateMsg_; @@ -3432,6 +3447,8 @@ private: AppearanceChangedCallback appearanceChangedCallback_; GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; + RangedWeaponSwapCallback rangedWeaponSwapCallback_; + bool suppressMeleeSwingAnim_ = false; // lastMeleeSwingMs_ moved to CombatHandler SpellCastAnimCallback spellCastAnimCallback_; SpellCastFailedCallback spellCastFailedCallback_; diff --git a/include/game/spline_packet.hpp b/include/game/spline_packet.hpp new file mode 100644 index 00000000..e64c1ff6 --- /dev/null +++ b/include/game/spline_packet.hpp @@ -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 +#include +#include + +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 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 diff --git a/include/game/transport_animator.hpp b/include/game/transport_animator.hpp new file mode 100644 index 00000000..1102cd89 --- /dev/null +++ b/include/game/transport_animator.hpp @@ -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 +#include +#include + +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 diff --git a/include/game/transport_clock_sync.hpp b/include/game/transport_clock_sync.hpp new file mode 100644 index 00000000..fe4286c2 --- /dev/null +++ b/include/game/transport_clock_sync.hpp @@ -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 +#include + +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 diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp index 57eaa6bc..94904c17 100644 --- a/include/game/transport_manager.hpp +++ b/include/game/transport_manager.hpp @@ -1,5 +1,8 @@ #pragma once +#include "game/transport_path_repository.hpp" +#include "game/transport_clock_sync.hpp" +#include "game/transport_animator.hpp" #include #include #include @@ -18,21 +21,6 @@ namespace wowee::pipeline { 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 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 { uint64_t guid; // Entity GUID uint32_t wmoInstanceId; // WMO renderer instance ID @@ -137,13 +125,13 @@ public: private: 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 pushTransform(ActiveTransport& transport); + TransportPathRepository pathRepo_; + TransportClockSync clockSync_; + TransportAnimator animator_; std::unordered_map transports_; - std::unordered_map paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc) - std::unordered_map taxiPaths_; // Indexed by TaxiPath.dbc ID (world-coord paths for MO_TRANSPORT) rendering::WMORenderer* wmoRenderer_ = nullptr; rendering::M2Renderer* m2Renderer_ = nullptr; bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction diff --git a/include/game/transport_path_repository.hpp b/include/game/transport_path_repository.hpp new file mode 100644 index 00000000..4cb1eecf --- /dev/null +++ b/include/game/transport_path_repository.hpp @@ -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 +#include +#include +#include + +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& 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 paths_; + std::unordered_map taxiPaths_; +}; + +} // namespace wowee::game diff --git a/include/math/spline.hpp b/include/math/spline.hpp new file mode 100644 index 00000000..0e27731b --- /dev/null +++ b/include/math/spline.hpp @@ -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 +#include +#include +#include + +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 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& 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 keys_; + bool timeClosed_; + uint32_t durationMs_; +}; + +} // namespace wowee::math diff --git a/include/ui/action_bar_panel.hpp b/include/ui/action_bar_panel.hpp index 4a62e642..9d4c2995 100644 --- a/include/ui/action_bar_panel.hpp +++ b/include/ui/action_bar_panel.hpp @@ -71,7 +71,7 @@ public: std::unordered_map macroPrimarySpellCache_; 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; } private: diff --git a/include/ui/chat_panel.hpp b/include/ui/chat_panel.hpp index b6528545..d1217fc7 100644 --- a/include/ui/chat_panel.hpp +++ b/include/ui/chat_panel.hpp @@ -110,14 +110,14 @@ public: /** Reset all chat settings to defaults. */ void restoreDefaults(); - // Section 3.5: UIServices injection (Phase B singleton breaking) + // UIServices injection (Phase B singleton breaking) void setServices(const UIServices& services) { services_ = services; } /** Replace $g/$G and $n/$N gender/name placeholders in quest/chat text. */ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler); private: - // Section 3.5: Injected UI services (Phase B singleton breaking) + // Injected UI services (Phase B singleton breaking) UIServices services_; // ---- Chat input state ---- diff --git a/include/ui/combat_ui.hpp b/include/ui/combat_ui.hpp index b09d2fc4..fef6ec37 100644 --- a/include/ui/combat_ui.hpp +++ b/include/ui/combat_ui.hpp @@ -72,7 +72,7 @@ public: void renderThreatWindow(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; } private: diff --git a/include/ui/dialog_manager.hpp b/include/ui/dialog_manager.hpp index 9bd075ee..8c9b357d 100644 --- a/include/ui/dialog_manager.hpp +++ b/include/ui/dialog_manager.hpp @@ -35,11 +35,11 @@ public: /// called in render() after reclaim corpse button 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; } private: - // Section 3.5: Injected UI services + // Injected UI services UIServices services_; // Common ImGui window flags for popup dialogs static constexpr ImGuiWindowFlags kDialogFlags = diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index f35bd679..50190ccf 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -55,7 +55,7 @@ public: // Dependency injection for extracted classes (Phase A singleton breaking) 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); private: diff --git a/include/ui/social_panel.hpp b/include/ui/social_panel.hpp index 1eed9a93..762a341d 100644 --- a/include/ui/social_panel.hpp +++ b/include/ui/social_panel.hpp @@ -73,7 +73,7 @@ public: void renderInspectWindow(game::GameHandler& gameHandler, InventoryScreen& inventoryScreen); - // Section 3.5: UIServices injection (Phase B singleton breaking) + // UIServices injection (singleton breaking) void setServices(const UIServices& services) { services_ = services; } private: diff --git a/include/ui/toast_manager.hpp b/include/ui/toast_manager.hpp index 34cafa4d..9ff81d92 100644 --- a/include/ui/toast_manager.hpp +++ b/include/ui/toast_manager.hpp @@ -41,7 +41,7 @@ public: /// Fire achievement earned toast + sound 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; } // --- public state consumed by GameScreen for the golden burst overlay --- @@ -49,7 +49,7 @@ public: uint32_t levelUpDisplayLevel = 0; private: - // Section 3.5: Injected UI services + // Injected UI services UIServices services_; // ---- Ding effect (own level-up) ---- diff --git a/include/ui/ui_manager.hpp b/include/ui/ui_manager.hpp index bdc217d9..2775d498 100644 --- a/include/ui/ui_manager.hpp +++ b/include/ui/ui_manager.hpp @@ -75,7 +75,7 @@ public: 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) { services_ = services; if (gameScreen) gameScreen->setServices(services); @@ -86,7 +86,7 @@ public: private: core::Window* window = nullptr; - UIServices services_; // Section 3.5: Injected services + UIServices services_; // Injected services // UI Screens std::unique_ptr authScreen; diff --git a/include/ui/ui_services.hpp b/include/ui/ui_services.hpp index fdcd4b2b..ea168758 100644 --- a/include/ui/ui_services.hpp +++ b/include/ui/ui_services.hpp @@ -23,7 +23,7 @@ namespace ui { /** * 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. * Application creates this struct and injects it into UIManager, diff --git a/include/ui/window_manager.hpp b/include/ui/window_manager.hpp index a5f3564c..8f21fb0c 100644 --- a/include/ui/window_manager.hpp +++ b/include/ui/window_manager.hpp @@ -174,7 +174,7 @@ public: std::unordered_map extendedCostCache_; 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; } private: diff --git a/src/core/animation_callback_handler.cpp b/src/core/animation_callback_handler.cpp index 9f891c34..afbd6101 100644 --- a/src/core/animation_callback_handler.cpp +++ b/src/core/animation_callback_handler.cpp @@ -343,9 +343,9 @@ void AnimationCallbackHandler::setupCallbacks() { // Spell cast animation callback — play cast animation on caster (player or NPC/other player) // WoW-accurate 3-phase spell animation sequence: - // Phase 1: SPELL_PRECAST (31) — one-shot wind-up - // Phase 2: 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_PRECAST (31) — one-shot wind-up + // READY_SPELL_DIRECTED/OMNI (51/52) — looping hold while cast bar fills + // 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). // castType comes from the spell packet's targetGuid: // DIRECTED — spell targets a specific unit (Frostbolt, Heal) @@ -398,13 +398,13 @@ void AnimationCallbackHandler::setupCallbacks() { return 0; }; - // Phase 1: Precast wind-up (one-shot, non-channels only) + // Precast wind-up (one-shot, non-channels only) uint32_t precastAnim = 0; if (!isChannel) { 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; if (isChannel) { // Channel hold: prefer DIRECTED/OMNI based on spell target classification @@ -449,7 +449,7 @@ void AnimationCallbackHandler::setupCallbacks() { } 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, // DIRECTED → SPELL_CAST_DIRECTED, OMNI → SPELL_CAST_OMNI uint32_t finalizeAnim = 0; diff --git a/src/core/appearance_composer.cpp b/src/core/appearance_composer.cpp index 9f14bd32..d7d38cc2 100644 --- a/src/core/appearance_composer.cpp +++ b/src/core/appearance_composer.cpp @@ -324,6 +324,7 @@ bool AppearanceComposer::loadWeaponM2(const std::string& m2Path, pipeline::M2Mod } void AppearanceComposer::loadEquippedWeapons() { + showingRanged_ = false; if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; if (!gameHandler_) return; @@ -354,9 +355,12 @@ void AppearanceComposer::loadEquippedWeapons() { for (const auto& ws : weaponSlots) { charRenderer->detachWeapon(charInstanceId, ws.attachmentId); } + charRenderer->detachWeapon(charInstanceId, 1); // ranged may also use right hand return; } + bool rightHandFilled = false; + for (const auto& ws : weaponSlots) { const auto& equipSlot = inventory.getEquipSlot(ws.slot); @@ -421,8 +425,123 @@ void AppearanceComposer::loadEquippedWeapons() { weaponModel, weaponModelId, texturePath); if (ok) { 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(recIdx), idiL ? (*idiL)["LeftModel"] : 1); + std::string textureName = displayInfoDbc->getString(static_cast(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(recIdx), idiL ? (*idiL)["LeftModel"] : 1); + std::string textureName = displayInfoDbc->getString(static_cast(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 diff --git a/src/core/application.cpp b/src/core/application.cpp index 966bbba5..8cdcfcfb 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -980,14 +980,23 @@ void Application::setState(AppState newState) { if (renderer) { // Ranged auto-attack spells: Auto Shot (75), Shoot (5019), Throw (2764) if (spellId == 75 || spellId == 5019 || spellId == 2764) { + if (appearanceComposer_ && !appearanceComposer_->isShowingRanged()) + appearanceComposer_->showRangedWeapon(true); if (auto* ac = renderer->getAnimationController()) ac->triggerRangedShot(); } else if (spellId != 0) { + if (appearanceComposer_ && appearanceComposer_->isShowingRanged()) + appearanceComposer_->showRangedWeapon(false); if (auto* ac = renderer->getAnimationController()) ac->triggerSpecialAttack(spellId); } else { + if (appearanceComposer_ && appearanceComposer_->isShowingRanged()) + appearanceComposer_->showRangedWeapon(false); 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) { if (renderer && renderer->getCameraController()) { renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); @@ -1216,6 +1225,10 @@ void Application::update(float deltaTime) { appearanceComposer_->setWeaponsSheathed(false); appearanceComposer_->loadEquippedWeapons(); } + // Swap back to melee weapon when auto-attack stops + if (!autoAttacking && wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isShowingRanged()) { + appearanceComposer_->showRangedWeapon(false); + } wasAutoAttacking_ = autoAttacking; } diff --git a/src/core/entity_spawner.cpp b/src/core/entity_spawner.cpp index 8c03a122..0e8fb7e6 100644 --- a/src/core/entity_spawner.cpp +++ b/src/core/entity_spawner.cpp @@ -136,6 +136,7 @@ void EntitySpawner::resetAllState() { // Clear display/spawn caches nonRenderableCreatureDisplayIds_.clear(); + displayIdModelCache_.clear(); displayIdTexturesApplied_.clear(); charSectionsCache_.clear(); charSectionsCacheBuilt_ = false; diff --git a/src/core/ui_screen_callback_handler.cpp b/src/core/ui_screen_callback_handler.cpp index f2016991..16de9e57 100644 --- a/src/core/ui_screen_callback_handler.cpp +++ b/src/core/ui_screen_callback_handler.cpp @@ -166,15 +166,10 @@ void UIScreenCallbackHandler::setupCallbacks() { }); // 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) { - uiManager_.getCharacterScreen().setStatus("Character deleted."); - // Refresh character list gameHandler_.requestCharacterList(); - } else { - uint8_t code = gameHandler_.getLastCharDeleteResult(); - uiManager_.getCharacterScreen().setStatus( - "Delete failed (code " + std::to_string(static_cast(code)) + ").", true); } }); } diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index 6dc004f6..31648bdf 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -206,14 +206,21 @@ void CombatHandler::startAutoAttack(uint64_t targetGuid) { 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. if (auto target = owner_.getEntityManager().getEntity(targetGuid)) { float dx = owner_.movementInfoRef().x - target->getLatestX(); float dy = owner_.movementInfoRef().y - target->getLatestY(); float dz = owner_.movementInfoRef().z - target->getLatestZ(); 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) { owner_.addSystemChatMessage("Target is too far away."); autoAttackRangeWarnCooldown_ = 1.25f; @@ -443,7 +450,14 @@ void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) { lastMeleeSwingMs_ = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); - if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(0); + // 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 (!isPlayerAttacker && owner_.npcSwingCallbackRef()) { owner_.npcSwingCallbackRef()(data.attackerGuid); diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 2af369de..ff4f0d80 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -73,17 +73,24 @@ EntityController::EntityController(GameHandler& owner) : owner_(owner) { initTypeHandlers(); } void EntityController::registerOpcodes(DispatchTable& table) { - // World object updates - table[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) { + // World object updates — accept during ENTERING_WORLD too so that entity + // 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(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(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) { - if (owner_.getState() == WorldState::IN_WORLD) handleDestroyObject(packet); + table[Opcode::SMSG_DESTROY_OBJECT] = [this, inWorldOrEntering](network::Packet& packet) { + if (inWorldOrEntering()) handleDestroyObject(packet); }; // Entity queries diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9eeb836b..1e6bd3b6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -756,6 +756,43 @@ void GameHandler::update(float deltaTime) { updateNetworking(deltaTime); 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 if (targetGuid != 0 && !entityController_->getEntityManager().hasEntity(targetGuid)) { clearTarget(); diff --git a/src/game/game_handler_callbacks.cpp b/src/game/game_handler_callbacks.cpp index 8eb1c43b..53236e9b 100644 --- a/src/game/game_handler_callbacks.cpp +++ b/src/game/game_handler_callbacks.cpp @@ -342,13 +342,16 @@ void GameHandler::handleCharCreateResponse(network::Packet& packet) { void GameHandler::deleteCharacter(uint64_t characterGuid) { if (!socket) { - if (charDeleteCallback_) charDeleteCallback_(false); + if (charDeleteCallback_) charDeleteCallback_(false, "Delete failed: not connected to server."); return; } network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE)); packet.writeUInt64(characterGuid); socket->send(packet); + pendingCharDeleteResponse_ = true; + pendingDeleteGuid_ = characterGuid; + pendingDeleteTimer_ = 0.0f; LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec); } diff --git a/src/game/game_handler_packets.cpp b/src/game/game_handler_packets.cpp index 469aeb8f..84b65727 100644 --- a/src/game/game_handler_packets.cpp +++ b/src/game/game_handler_packets.cpp @@ -151,10 +151,23 @@ void GameHandler::registerOpcodeHandlers() { dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) { uint8_t result = packet.readUInt8(); lastCharDeleteResult_ = result; + pendingCharDeleteResponse_ = false; bool success = (result == 0x00 || result == 0x47); LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast(result), success ? " (success)" : " (failed)"); 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(result)) + ")."; break; + } + } + if (charDeleteCallback_) charDeleteCallback_(success, msg); }; dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) { if (state == WorldState::CHAR_LIST_REQUESTED) diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index 6c814235..259f6752 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -2,6 +2,7 @@ #include "game/game_handler.hpp" #include "game/game_utils.hpp" #include "game/packet_parsers.hpp" +#include "game/spline_packet.hpp" #include "game/transport_manager.hpp" #include "game/entity.hpp" #include "network/world_socket.hpp" @@ -1572,52 +1573,17 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { if (packet.getReadPos() + 4 > packet.getSize()) return; uint32_t splineFlags = packet.readUInt32(); - if (splineFlags & 0x00400000) { - if (packet.getReadPos() + 5 > packet.getSize()) return; - packet.readUInt8(); packet.readUInt32(); - } - - 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; - } - } + // Consolidated spline body parser + SplineBlockData spline; + if (!parseMonsterMoveSplineBody(packet, spline, splineFlags, + glm::vec3(localX, localY, localZ))) { + return; } + 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()) { LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x", diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index f757b931..d3f9494d 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1,4 +1,5 @@ #include "game/packet_parsers.hpp" +#include "game/spline_packet.hpp" #include "core/logger.hpp" #include #include @@ -258,45 +259,8 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // Spline data (Classic: SPLINE_ENABLED=0x00400000) if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) { - if (rem() < 4) return false; - uint32_t splineFlags = packet.readUInt32(); - 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(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(); + SplineBlockData splineData; + if (!parseClassicMoveUpdateSpline(packet, splineData)) return false; } } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { @@ -2004,45 +1968,8 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) || (moveFlags & TurtleMoveFlags::SPLINE_TBC); if (hasSpline) { - if (rem() < 4) return false; - uint32_t splineFlags = packet.readUInt32(); - 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(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(); + SplineBlockData splineData; + if (!parseClassicMoveUpdateSpline(packet, splineData)) return false; } LOG_DEBUG(" [Turtle] LIVING block consumed ", packet.getReadPos() - livingStart, diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 165a4348..905fb798 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -1,4 +1,5 @@ #include "game/packet_parsers.hpp" +#include "game/spline_packet.hpp" #include "core/logger.hpp" namespace wowee { @@ -670,46 +671,20 @@ bool TbcPacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData if (!packet.hasRemaining(4)) return false; data.splineFlags = packet.readUInt32(); - // TBC 2.4.3 SplineFlags animation bit is same as WotLK: 0x00400000 - if (data.splineFlags & 0x00400000) { - if (!packet.hasRemaining(5)) return false; - packet.readUInt8(); // animationType - packet.readUInt32(); // effectStartTime - } - - if (!packet.hasRemaining(4)) return false; - data.duration = packet.readUInt32(); - - if (data.splineFlags & 0x00000800) { - 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(); + // Consolidated spline body parser (TBC uses different uncompressed mask) + { + SplineBlockData spline; + if (!parseMonsterMoveSplineBody(packet, spline, data.splineFlags, + glm::vec3(data.x, data.y, data.z), true)) { + return false; + } + data.duration = spline.duration; + if (spline.hasDest) { + data.destX = spline.destination.x; + data.destY = spline.destination.y; + data.destZ = spline.destination.z; + data.hasDest = true; } - 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; } LOG_DEBUG("[TBC] MonsterMove: guid=0x", std::hex, data.guid, std::dec, diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index 4ce04d9f..72476c3c 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -1025,8 +1025,16 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { if (!owner_.isProfessionSpell(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; + if (sid == 75 || sid == 5019 || sid == 2764) { + if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(sid); + owner_.suppressNextMeleeSwingAnim(); + } + + // Instant melee abilities → trigger attack animation bool isMeleeAbility = false; if (!owner_.isProfessionSpell(sid)) { owner_.loadSpellNameCache(); diff --git a/src/game/spline_packet.cpp b/src/game/spline_packet.cpp new file mode 100644 index 00000000..d31f7c24 --- /dev/null +++ b/src/game/spline_packet.cpp @@ -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 + +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(packed & 0x7FF); + if (sx & 0x400) sx |= static_cast(0xFFFFF800); // sign-extend 11-bit + + int32_t sy = static_cast((packed >> 11) & 0x7FF); + if (sy & 0x400) sy |= static_cast(0xFFFFF800); // sign-extend 11-bit + + int32_t sz = static_cast((packed >> 22) & 0x3FF); + if (sz & 0x200) sz |= static_cast(0xFFFFFC00); // sign-extend 10-bit + + return glm::vec3( + midpoint.x - static_cast(sx) * 0.25f, + midpoint.y - static_cast(sy) * 0.25f, + midpoint.z - static_cast(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(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(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(pc - 1) * 4ull : 0ull); + } else { + // All uncompressed: 3 floats each + pointsBytes = static_cast(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 diff --git a/src/game/transport_animator.cpp b/src/game/transport_animator.cpp new file mode 100644 index 00000000..ba0344c0 --- /dev/null +++ b/src/game/transport_animator.cpp @@ -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 +#include + +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() : 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 diff --git a/src/game/transport_clock_sync.cpp b/src/game/transport_clock_sync.cpp new file mode 100644 index 00000000..5db82f2e --- /dev/null +++ b/src/game/transport_clock_sync.cpp @@ -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 +#include + +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(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(nowMs) + transport.serverClockOffsetMs; + int64_t mod = static_cast(durationMs); + int64_t wrapped = serverTimeMs % mod; + if (wrapped < 0) wrapped += mod; + outPathTimeMs = static_cast(wrapped); + return true; + } + + if (transport.useClientAnimation) { + // Pure local clock (no server sync yet, client-driven) + uint32_t dtMs = static_cast(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() : 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(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() : 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(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 diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 5f97f41d..65be6b45 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -1,16 +1,15 @@ #include "game/transport_manager.hpp" +#include "game/transport_clock_sync.hpp" +#include "game/transport_animator.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" #include "core/coordinates.hpp" #include "core/logger.hpp" -#include "pipeline/dbc_loader.hpp" -#include "pipeline/asset_manager.hpp" #include #include #include #include -#include -#include +#include namespace wowee::game { @@ -28,14 +27,14 @@ void TransportManager::update(float deltaTime) { } void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry) { - auto pathIt = paths_.find(pathId); - if (pathIt == paths_.end()) { + auto* pathEntry = pathRepo_.findPath(pathId); + if (!pathEntry) { LOG_ERROR("TransportManager: Path ", pathId, " not found for transport ", guid); return; } - const auto& path = pathIt->second; - if (path.points.empty()) { + const auto& spline = pathEntry->spline; + if (spline.keyCount() == 0) { LOG_ERROR("TransportManager: Path ", pathId, " has no waypoints"); return; } @@ -49,23 +48,23 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, // CRITICAL: Set basePosition from spawn position and t=0 offset // For stationary paths (1 waypoint), just use spawn position directly - if (path.durationMs == 0 || path.points.size() <= 1) { + if (spline.durationMs() == 0 || spline.keyCount() <= 1) { // Stationary transport - no path animation transport.basePosition = spawnWorldPos; transport.position = spawnWorldPos; - } else if (path.worldCoords) { + } else if (pathEntry->worldCoords) { // World-coordinate path (TaxiPathNode) - points are absolute world positions transport.basePosition = glm::vec3(0.0f); - transport.position = evalTimedCatmullRom(path, 0); + transport.position = spline.evaluatePosition(0); } else { // Moving transport - infer base from first path offset - glm::vec3 offset0 = evalTimedCatmullRom(path, 0); + glm::vec3 offset0 = spline.evaluatePosition(0); transport.basePosition = spawnWorldPos - offset0; // Infer base from spawn transport.position = spawnWorldPos; // Start at spawn position (base + offset0) // TransportAnimation paths are local offsets; first waypoint is expected near origin. // Warn only if the local path itself looks suspicious. - glm::vec3 firstWaypoint = path.points[0].pos; + glm::vec3 firstWaypoint = spline.keys()[0].position; if (glm::dot(firstWaypoint, firstWaypoint) > 100.0f) { LOG_WARNING("Transport 0x", std::hex, guid, std::dec, " path ", pathId, ": first local waypoint far from origin: (", @@ -84,7 +83,7 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, // If the server sends actual position updates, updateServerTransport() will switch // to server-driven mode. This ensures transports like trams (which the server doesn't // stream updates for) still animate, while ships/zeppelins switch to server authority. - transport.useClientAnimation = (path.fromDBC && path.durationMs > 0); + transport.useClientAnimation = (pathEntry->fromDBC && spline.durationMs() > 0); transport.clientAnimationReverse = false; transport.serverYaw = 0.0f; transport.hasServerYaw = false; @@ -96,29 +95,25 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, transport.serverAngularVelocity = 0.0f; transport.hasServerVelocity = false; - if (transport.useClientAnimation && path.durationMs > 0) { + if (transport.useClientAnimation && spline.durationMs() > 0) { // Seed to a stable phase based on our local clock so elevators don't all start at t=0. - transport.localClockMs = static_cast(elapsedTime_ * 1000.0) % path.durationMs; + transport.localClockMs = static_cast(elapsedTime_ * 1000.0) % spline.durationMs(); LOG_INFO("TransportManager: Enabled client animation for transport 0x", std::hex, guid, std::dec, " path=", pathId, - " durationMs=", path.durationMs, " seedMs=", transport.localClockMs, - (path.worldCoords ? " [worldCoords]" : (path.zOnly ? " [z-only]" : ""))); + " durationMs=", spline.durationMs(), " seedMs=", transport.localClockMs, + (pathEntry->worldCoords ? " [worldCoords]" : (pathEntry->zOnly ? " [z-only]" : ""))); } updateTransformMatrices(transport); // CRITICAL: Update WMO renderer with initial transform - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); transports_[guid] = transport; glm::vec3 renderPos = core::coords::canonicalToRender(transport.position); LOG_INFO("TransportManager: Registered transport 0x", std::hex, guid, std::dec, - " at path ", pathId, " with ", path.points.size(), " waypoints", + " at path ", pathId, " with ", (pathEntry ? pathEntry->spline.keyCount() : 0u), " waypoints", " wmoInstanceId=", wmoInstanceId, " spawnPos=(", spawnWorldPos.x, ", ", spawnWorldPos.y, ", ", spawnWorldPos.z, ")", " basePos=(", transport.basePosition.x, ", ", transport.basePosition.y, ", ", transport.basePosition.z, ")", @@ -168,62 +163,7 @@ glm::mat4 TransportManager::getTransportInvTransform(uint64_t transportGuid) { } void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector& waypoints, bool looping, float speed) { - if (waypoints.empty()) { - LOG_ERROR("TransportManager: Cannot load empty path ", pathId); - return; - } - - TransportPath path; - path.pathId = pathId; - path.zOnly = false; // Manually loaded paths are assumed to have XY movement - path.fromDBC = false; - - // Helper: compute segment duration from distance and speed - auto segMsFromDist = [&](float dist) -> uint32_t { - if (speed <= 0.0f) return 1000; - return static_cast((dist / speed) * 1000.0f); - }; - - // Single point = stationary (durationMs = 0) - if (waypoints.size() == 1) { - path.points.push_back({0, waypoints[0]}); - path.durationMs = 0; - path.looping = false; - paths_[pathId] = path; - LOG_INFO("TransportManager: Loaded stationary path ", pathId); - return; - } - - // Multiple points: calculate cumulative time based on distance and speed - path.points.reserve(waypoints.size() + (looping ? 1 : 0)); - uint32_t cumulativeMs = 0; - path.points.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)); - path.points.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 evalTimedCatmullRom handles wrap via modular time - // instead of index wrapping — so looping is always false after construction. - if (looping) { - float wrapDist = glm::distance(waypoints.back(), waypoints.front()); - uint32_t wrapMs = glm::max(1u, segMsFromDist(wrapDist)); - cumulativeMs += wrapMs; - path.points.push_back({cumulativeMs, waypoints.front()}); - } - path.looping = false; - - path.durationMs = cumulativeMs; - paths_[pathId] = path; - - LOG_INFO("TransportManager: Loaded path ", pathId, - " with ", waypoints.size(), " waypoints", - (looping ? " + wrap segment" : ""), - ", duration=", path.durationMs, "ms, speed=", speed); + pathRepo_.loadPathFromNodes(pathId, waypoints, looping, speed); } void TransportManager::setDeckBounds(uint64_t guid, const glm::vec3& min, const glm::vec3& max) { @@ -239,296 +179,58 @@ void TransportManager::setDeckBounds(uint64_t guid, const glm::vec3& min, const } void TransportManager::updateTransportMovement(ActiveTransport& transport, float deltaTime) { - auto pathIt = paths_.find(transport.pathId); - if (pathIt == paths_.end()) { + auto* pathEntry = pathRepo_.findPath(transport.pathId); + if (!pathEntry) { return; } - const auto& path = pathIt->second; - if (path.points.empty()) { + const auto& spline = pathEntry->spline; + if (spline.keyCount() == 0) { return; } // Stationary transport (durationMs = 0) - if (path.durationMs == 0) { + if (spline.durationMs() == 0) { // Just update transform (position already set) updateTransformMatrices(transport); - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); return; } - // Evaluate path time - uint32_t nowMs = static_cast(elapsedTime_ * 1000.0); + // Compute path time via ClockSync uint32_t pathTimeMs = 0; - - if (transport.hasServerClock) { - // Predict server time using clock offset (works for both client and server-driven modes) - int64_t serverTimeMs = static_cast(nowMs) + transport.serverClockOffsetMs; - int64_t mod = static_cast(path.durationMs); - int64_t wrapped = serverTimeMs % mod; - if (wrapped < 0) wrapped += mod; - pathTimeMs = static_cast(wrapped); - } else if (transport.useClientAnimation) { - // Pure local clock (no server sync yet, client-driven) - uint32_t dtMs = static_cast(deltaTime * 1000.0f); - if (!transport.clientAnimationReverse) { - transport.localClockMs += dtMs; - } else { - if (dtMs > path.durationMs) { - dtMs %= path.durationMs; - } - if (transport.localClockMs >= dtMs) { - transport.localClockMs -= dtMs; - } else { - transport.localClockMs = path.durationMs - (dtMs - transport.localClockMs); - } - } - pathTimeMs = transport.localClockMs % path.durationMs; - } else { + if (!clockSync_.computePathTime(transport, spline, elapsedTime_, deltaTime, pathTimeMs)) { // Strict server-authoritative mode: do not guess movement between server snapshots. updateTransformMatrices(transport); - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); return; } - // Evaluate position from time (path is local offsets, add base position) - glm::vec3 pathOffset = evalTimedCatmullRom(path, pathTimeMs); - // Guard against bad fallback Z curves on some remapped transport paths (notably icebreakers), - // where path offsets can sink far below sea level when we only have spawn-time data. - // Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions. - // 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). - constexpr float kMinFallbackZOffset = -2.0f; - constexpr float kMaxFallbackZOffset = 8.0f; - if (!path.worldCoords) { - if (transport.useClientAnimation && transport.serverUpdateCount <= 1) { - pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset); - } - if (!transport.useClientAnimation && !transport.hasServerClock) { - pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset); - } - } - transport.position = transport.basePosition + pathOffset; - - // Use server yaw if available (authoritative), otherwise compute from tangent - if (transport.hasServerYaw) { - float effectiveYaw = transport.serverYaw + (transport.serverYawFlipped180 ? glm::pi() : 0.0f); - transport.rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); - } else { - transport.rotation = orientationFromTangent(path, pathTimeMs); - } + // Evaluate position + rotation via Animator + animator_.evaluateAndApply(transport, *pathEntry, pathTimeMs); // Update transform matrices updateTransformMatrices(transport); - - // Update WMO instance position - if (transport.isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); // Debug logging every 600 frames (~10 seconds at 60fps) static int debugFrameCount = 0; if (debugFrameCount++ % 600 == 0) { LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec, - " pathTime=", pathTimeMs, "ms / ", path.durationMs, "ms", + " pathTime=", pathTimeMs, "ms / ", spline.durationMs(), "ms", " pos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", " mode=", (transport.useClientAnimation ? "client" : "server"), " isM2=", transport.isM2); } } -glm::vec3 TransportManager::evalTimedCatmullRom(const TransportPath& path, uint32_t pathTimeMs) { - if (path.points.empty()) { - return glm::vec3(0.0f); - } - if (path.points.size() == 1) { - return path.points[0].pos; - } - - // Find the segment containing pathTimeMs - size_t segmentIdx = 0; - bool found = false; - - for (size_t i = 0; i + 1 < path.points.size(); i++) { - if (pathTimeMs >= path.points[i].tMs && pathTimeMs < path.points[i + 1].tMs) { - segmentIdx = i; - found = true; - break; - } - } - - // Handle not found (timing gaps or past last segment) - if (!found) { - // For time-closed paths (explicit wrap point), last valid segment is points.size() - 2 - segmentIdx = (path.points.size() >= 2) ? (path.points.size() - 2) : 0; - } - - size_t numPoints = path.points.size(); - - // Get 4 control points for Catmull-Rom - // Helper to clamp index (no wrapping for non-looping paths) - auto idxClamp = [&](size_t i) -> size_t { - return (i >= numPoints) ? (numPoints - 1) : i; - }; - - size_t p0Idx, p1Idx, p2Idx, p3Idx; - p1Idx = segmentIdx; - - if (path.looping) { - // Index-wrapped path (old DBC style with looping=true) - p0Idx = (segmentIdx == 0) ? (numPoints - 1) : (segmentIdx - 1); - p2Idx = (segmentIdx + 1) % numPoints; - p3Idx = (segmentIdx + 2) % numPoints; +// Push transform to the appropriate renderer (WMO or M2). +void TransportManager::pushTransform(ActiveTransport& transport) { + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } else { - // Time-closed path (explicit wrap point at end, looping=false) - // No index wrapping - points are sequential with possible duplicate at end - p0Idx = (segmentIdx == 0) ? 0 : (segmentIdx - 1); - p2Idx = idxClamp(segmentIdx + 1); - p3Idx = idxClamp(segmentIdx + 2); + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } - - glm::vec3 p0 = path.points[p0Idx].pos; - glm::vec3 p1 = path.points[p1Idx].pos; - glm::vec3 p2 = path.points[p2Idx].pos; - glm::vec3 p3 = path.points[p3Idx].pos; - - // Calculate t (0.0 to 1.0 within segment) - // No special case needed - wrap point is explicit in the array now - uint32_t t1Ms = path.points[p1Idx].tMs; - uint32_t t2Ms = path.points[p2Idx].tMs; - uint32_t segmentDurationMs = (t2Ms > t1Ms) ? (t2Ms - t1Ms) : 1; - float t = static_cast(pathTimeMs - t1Ms) / static_cast(segmentDurationMs); - t = glm::clamp(t, 0.0f, 1.0f); - - // Catmull-Rom spline formula - float t2 = t * t; - float t3 = t2 * t; - - glm::vec3 result = 0.5f * ( - (2.0f * p1) + - (-p0 + p2) * t + - (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * t2 + - (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * t3 - ); - - return result; -} - -glm::quat TransportManager::orientationFromTangent(const TransportPath& path, uint32_t pathTimeMs) { - if (path.points.empty()) { - return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); - } - if (path.points.size() == 1) { - return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); - } - - // Find the segment containing pathTimeMs - size_t segmentIdx = 0; - bool found = false; - - for (size_t i = 0; i + 1 < path.points.size(); i++) { - if (pathTimeMs >= path.points[i].tMs && pathTimeMs < path.points[i + 1].tMs) { - segmentIdx = i; - found = true; - break; - } - } - - // Handle not found (timing gaps or past last segment) - if (!found) { - // For time-closed paths (explicit wrap point), last valid segment is points.size() - 2 - segmentIdx = (path.points.size() >= 2) ? (path.points.size() - 2) : 0; - } - - size_t numPoints = path.points.size(); - - // Get 4 control points for tangent calculation - // Helper to clamp index (no wrapping for non-looping paths) - auto idxClamp = [&](size_t i) -> size_t { - return (i >= numPoints) ? (numPoints - 1) : i; - }; - - size_t p0Idx, p1Idx, p2Idx, p3Idx; - p1Idx = segmentIdx; - - if (path.looping) { - // Index-wrapped path (old DBC style with looping=true) - p0Idx = (segmentIdx == 0) ? (numPoints - 1) : (segmentIdx - 1); - p2Idx = (segmentIdx + 1) % numPoints; - p3Idx = (segmentIdx + 2) % numPoints; - } else { - // Time-closed path (explicit wrap point at end, looping=false) - // No index wrapping - points are sequential with possible duplicate at end - p0Idx = (segmentIdx == 0) ? 0 : (segmentIdx - 1); - p2Idx = idxClamp(segmentIdx + 1); - p3Idx = idxClamp(segmentIdx + 2); - } - - glm::vec3 p0 = path.points[p0Idx].pos; - glm::vec3 p1 = path.points[p1Idx].pos; - glm::vec3 p2 = path.points[p2Idx].pos; - glm::vec3 p3 = path.points[p3Idx].pos; - - // Calculate t (0.0 to 1.0 within segment) - // No special case needed - wrap point is explicit in the array now - uint32_t t1Ms = path.points[p1Idx].tMs; - uint32_t t2Ms = path.points[p2Idx].tMs; - uint32_t segmentDurationMs = (t2Ms > t1Ms) ? (t2Ms - t1Ms) : 1; - float t = static_cast(pathTimeMs - t1Ms) / static_cast(segmentDurationMs); - t = glm::clamp(t, 0.0f, 1.0f); - - // Tangent of Catmull-Rom spline (derivative) - float t2 = t * t; - glm::vec3 tangent = 0.5f * ( - (-p0 + p2) + - (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * 2.0f * t + - (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * 3.0f * t2 - ); - - // Normalize tangent - float tangentLenSq = glm::dot(tangent, tangent); - if (tangentLenSq < 1e-6f) { - // Fallback to simple direction - tangent = p2 - p1; - tangentLenSq = glm::dot(tangent, tangent); - } - - if (tangentLenSq < 1e-6f) { - return glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity - } - - tangent *= glm::inversesqrt(tangentLenSq); - - // Calculate rotation from forward direction - glm::vec3 forward = tangent; - 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(up, forward)); - up = glm::cross(forward, right); - - // Build rotation matrix and convert to quaternion - glm::mat3 rotMat; - rotMat[0] = right; - rotMat[1] = forward; - rotMat[2] = up; - - return glm::quat_cast(rotMat); } void TransportManager::updateTransformMatrices(ActiveTransport& transport) { @@ -560,581 +262,43 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos return; } - const bool hadPrevUpdate = (transport->serverUpdateCount > 0); - const float prevUpdateTime = transport->lastServerUpdate; - const glm::vec3 prevPos = transport->position; + auto* pathEntry = pathRepo_.findPath(transport->pathId); - auto pathIt = paths_.find(transport->pathId); - const bool hasPath = (pathIt != paths_.end()); - const bool isZOnlyPath = (hasPath && pathIt->second.fromDBC && pathIt->second.zOnly && pathIt->second.durationMs > 0); - const bool isWorldCoordPath = (hasPath && pathIt->second.worldCoords && pathIt->second.durationMs > 0); - - // Don't let (0,0,0) server updates override a TaxiPathNode world-coordinate path - if (isWorldCoordPath && glm::dot(position, position) < 1.0f) { + if (!pathEntry || pathEntry->spline.durationMs() == 0) { + // No path or stationary — handle directly before delegating to ClockSync. + // Still track update count so future path assignments work. 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 && pathIt->second.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, 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 || !pathIt->second.fromDBC) { - // No DBC path — purely server-driven - transport->useClientAnimation = false; - } - transport->clientAnimationReverse = false; - - if (!hasPath || pathIt->second.durationMs == 0) { - // No path or stationary - just set position directly transport->basePosition = position; transport->position = position; transport->rotation = glm::angleAxis(orientation, glm::vec3(0.0f, 0.0f, 1.0f)); updateTransformMatrices(*transport); - if (transport->isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } + pushTransform(*transport); return; } - // Server-authoritative transport mode: - // Trust explicit server world position/orientation directly for all moving transports. - // This avoids wrong-route and direction errors when local DBC path mapping differs from server route IDs. - 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() : 0.0f); - transport->rotation = glm::angleAxis(effectiveYaw, glm::vec3(0.0f, 0.0f, 1.0f)); - - if (hadPrevUpdate) { - const float dt = elapsedTime_ - prevUpdateTime; - if (dt > 0.001f) { - glm::vec3 v = (position - prevPos) / dt; - float speedSq = glm::dot(v, v); - constexpr float kMinAuthoritativeSpeed = 0.15f; - constexpr float kMaxSpeed = 60.0f; - if (speedSq >= kMinAuthoritativeSpeed * kMinAuthoritativeSpeed) { - // 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(v.x, v.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, 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, guid, std::dec, - " disabled 180-degree yaw correction (alignScore=", - transport->serverYawAlignmentScore, ")"); - } - } - - 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() : 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. - auto pathIt2 = paths_.find(transport->pathId); - if (pathIt2 != paths_.end()) { - const auto& path = pathIt2->second; - if (!path.points.empty() && path.durationMs > 0) { - glm::vec3 local = position - transport->basePosition; - size_t bestIdx = 0; - float bestDistSq = std::numeric_limits::max(); - for (size_t i = 0; i < path.points.size(); ++i) { - glm::vec3 d = path.points[i].pos - local; - float distSq = glm::dot(d, d); - if (distSq < bestDistSq) { - bestDistSq = distSq; - bestIdx = i; - } - } - transport->localClockMs = path.points[bestIdx].tMs % path.durationMs; - } - } - - // Bootstrap velocity from mapped DBC path on first authoritative sample. - // This avoids "stalled at dock" when server sends sparse transport snapshots. - pathIt2 = paths_.find(transport->pathId); - if (transport->allowBootstrapVelocity && pathIt2 != paths_.end()) { - const auto& path = pathIt2->second; - if (path.points.size() >= 2 && path.durationMs > 0) { - glm::vec3 local = position - transport->basePosition; - size_t bestIdx = 0; - float bestDistSq = std::numeric_limits::max(); - for (size_t i = 0; i < path.points.size(); ++i) { - glm::vec3 d = path.points[i].pos - local; - float distSq = glm::dot(d, d); - if (distSq < bestDistSq) { - bestDistSq = distSq; - bestIdx = i; - } - } - - constexpr float kMaxBootstrapNearestDist = 80.0f; - if (bestDistSq > (kMaxBootstrapNearestDist * kMaxBootstrapNearestDist)) { - LOG_WARNING("Transport 0x", std::hex, guid, std::dec, - " skipping DBC bootstrap velocity: nearest path point too far (dist=", - std::sqrt(bestDistSq), ", path=", transport->pathId, ")"); - } else { - size_t n = path.points.size(); - constexpr float kMinBootstrapSpeed = 0.25f; - constexpr float kMaxSpeed = 60.0f; - - auto tryApplySegment = [&](size_t a, size_t b) { - uint32_t t0 = path.points[a].tMs; - uint32_t t1 = path.points[b].tMs; - if (b == 0 && t1 <= t0 && path.durationMs > 0) { - t1 = path.durationMs; - } - if (t1 <= t0) return; - glm::vec3 seg = path.points[b].pos - path.points[a].pos; - float dtSeg = static_cast(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, 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, guid, std::dec, - " skipped DBC bootstrap velocity (segment too short/static)"); - } - } - } - } else if (!transport->allowBootstrapVelocity) { - LOG_INFO("Transport 0x", std::hex, guid, std::dec, - " DBC bootstrap velocity disabled for this transport"); - } - } + // Delegate clock sync, yaw correction, and velocity bootstrap to ClockSync. + clockSync_.processServerUpdate(*transport, pathEntry, position, orientation, elapsedTime_); updateTransformMatrices(*transport); - if (transport->isM2) { - if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } else { - if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); - } - return; + pushTransform(*transport); } bool TransportManager::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>> 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 evalTimedCatmullRom(path, 0) valid and stabilizes basePosition seeding - uint32_t t0 = sortedWaypoints.front().first; - - // Build TimedPoint array with normalized time indices - std::vector timedPoints; - timedPoints.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, ")"); - } - - timedPoints.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)); - timedPoints.push_back({lastTimeMs + wrapMs, firstCanonical}); - - uint32_t durationMs = lastTimeMs + wrapMs; - - // Detect Z-only paths (elevator/bobbing animation, not real XY travel) - float minX = timedPoints[0].pos.x; - float maxX = timedPoints[0].pos.x; - float minY = timedPoints[0].pos.y; - float maxY = timedPoints[0].pos.y; - float minZ = timedPoints[0].pos.z; - float maxZ = timedPoints[0].pos.z; - for (const auto& pt : timedPoints) { - minX = std::min(minX, pt.pos.x); - maxX = std::max(maxX, pt.pos.x); - minY = std::min(minY, pt.pos.y); - maxY = std::max(maxY, pt.pos.y); - minZ = std::min(minZ, pt.pos.z); - maxZ = std::max(maxZ, pt.pos.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); - - // Store path - TransportPath path; - path.pathId = transportEntry; - path.points = timedPoints; - // CRITICAL: We added an explicit wrap point (last → first), so this is TIME-CLOSED, not index-wrapped - // Setting looping=false ensures evalTimedCatmullRom uses clamp logic (not modulo) for control points - path.looping = false; - path.durationMs = durationMs; - path.zOnly = isZOnly; - path.fromDBC = true; - paths_[transportEntry] = path; - pathsLoaded++; - - // Log first, middle, and last points to verify path data - glm::vec3 firstOffset = timedPoints[0].pos; - size_t midIdx = timedPoints.size() / 2; - glm::vec3 midOffset = timedPoints[midIdx].pos; - glm::vec3 lastOffset = timedPoints[timedPoints.size() - 2].pos; // -2 to skip wrap duplicate - LOG_INFO(" Transport ", transportEntry, ": ", timedPoints.size() - 1, " waypoints + wrap, ", - durationMs, "ms duration (wrap=", wrapMs, "ms, t0_normalized=", timedPoints[0].tMs, "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, ")"); - } - - LOG_INFO("Loaded ", pathsLoaded, " transport paths from TransportAnimation.dbc"); - return pathsLoaded > 0; + return pathRepo_.loadTransportAnimationDBC(assetMgr); } bool TransportManager::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> 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; } - } - - // Calculate total path distance to identify transport routes (long water crossings) - float totalDist = 0.0f; - for (size_t i = 1; i < nodes.size(); i++) { - float dx = nodes[i].x - nodes[i-1].x; - float dy = nodes[i].y - nodes[i-1].y; - float dz = nodes[i].z - nodes[i-1].z; - totalDist += std::sqrt(dx*dx + dy*dy + dz*dz); - } - - // Transport routes are typically >500 units long and stay on same map - // Flight paths can also be long, but we'll store all same-map paths - // and let the caller choose the right one by pathId - 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 timedPoints; - timedPoints.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); - - timedPoints.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((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((wrapDist / transportSpeed) * 1000.0f); - if (wrapMs < 100) wrapMs = 100; - cumulativeMs += wrapMs; - timedPoints.push_back({cumulativeMs, timedPoints[0].pos}); - - TransportPath path; - path.pathId = pathId; - path.points = timedPoints; - path.looping = false; // Explicit wrap point added - path.durationMs = cumulativeMs; - path.zOnly = false; - path.fromDBC = true; - path.worldCoords = true; // TaxiPathNode uses absolute world coordinates - - taxiPaths_[pathId] = path; - pathsLoaded++; - } - - LOG_INFO("Loaded ", pathsLoaded, " TaxiPathNode transport paths (", nodesByPath.size(), " total taxi paths)"); - return pathsLoaded > 0; + return pathRepo_.loadTaxiPathNodeDBC(assetMgr); } bool TransportManager::hasTaxiPath(uint32_t taxiPathId) const { - return taxiPaths_.find(taxiPathId) != taxiPaths_.end(); + return pathRepo_.hasTaxiPath(taxiPathId); } bool TransportManager::assignTaxiPathToTransport(uint32_t entry, uint32_t taxiPathId) { - auto taxiIt = taxiPaths_.find(taxiPathId); - if (taxiIt == taxiPaths_.end()) { + auto* taxiEntry = pathRepo_.findTaxiPath(taxiPathId); + if (!taxiEntry) { LOG_WARNING("No TaxiPathNode path for taxiPathId=", taxiPathId); return false; } @@ -1144,33 +308,32 @@ bool TransportManager::assignTaxiPathToTransport(uint32_t entry, uint32_t taxiPa if (transport.entry != entry) continue; if (glm::dot(transport.position, transport.position) > 1.0f) continue; // Already has real position - // Copy the taxi path into the main paths_ map (indexed by entry for this transport) - TransportPath path = taxiIt->second; - path.pathId = entry; // Index by GO entry - paths_[entry] = path; + // Copy the taxi path into the main paths (indexed by GO entry for this transport) + PathEntry copied(taxiEntry->spline, entry, taxiEntry->zOnly, taxiEntry->fromDBC, taxiEntry->worldCoords); + pathRepo_.storePath(entry, std::move(copied)); + + auto* storedEntry = pathRepo_.findPath(entry); // Update transport to use the new path transport.pathId = entry; transport.basePosition = glm::vec3(0.0f); // World-coordinate path, no base offset - if (!path.points.empty()) { - transport.position = evalTimedCatmullRom(path, 0); + if (storedEntry && storedEntry->spline.keyCount() > 0) { + transport.position = storedEntry->spline.evaluatePosition(0); } transport.useClientAnimation = true; // Server won't send position updates // Seed local clock to a deterministic phase - if (path.durationMs > 0) { - transport.localClockMs = static_cast(elapsedTime_ * 1000.0) % path.durationMs; + if (storedEntry && storedEntry->spline.durationMs() > 0) { + transport.localClockMs = static_cast(elapsedTime_ * 1000.0) % storedEntry->spline.durationMs(); } updateTransformMatrices(transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); - } + pushTransform(transport); LOG_INFO("Assigned TaxiPathNode path to transport 0x", std::hex, guid, std::dec, " entry=", entry, " taxiPathId=", taxiPathId, - " waypoints=", path.points.size(), - " duration=", path.durationMs, "ms", + " waypoints=", storedEntry ? storedEntry->spline.keyCount() : 0u, + " duration=", storedEntry ? storedEntry->spline.durationMs() : 0u, "ms", " startPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")"); return true; } @@ -1180,129 +343,25 @@ bool TransportManager::assignTaxiPathToTransport(uint32_t entry, uint32_t taxiPa } bool TransportManager::hasPathForEntry(uint32_t entry) const { - auto it = paths_.find(entry); - return it != paths_.end() && it->second.fromDBC; + return pathRepo_.hasPathForEntry(entry); } bool TransportManager::hasUsableMovingPathForEntry(uint32_t entry, float minXYRange) const { - auto it = paths_.find(entry); - if (it == paths_.end()) return false; - - const auto& path = it->second; - if (!path.fromDBC || path.points.size() < 2 || path.durationMs == 0 || path.zOnly) { - return false; - } - - float minX = path.points.front().pos.x; - float maxX = minX; - float minY = path.points.front().pos.y; - float maxY = minY; - for (const auto& p : path.points) { - minX = std::min(minX, p.pos.x); - maxX = std::max(maxX, p.pos.x); - minY = std::min(minY, p.pos.y); - maxY = std::max(maxY, p.pos.y); - } - - float rangeXY = std::max(maxX - minX, maxY - minY); - return rangeXY >= minXYRange; + return pathRepo_.hasUsableMovingPathForEntry(entry, minXYRange); } uint32_t TransportManager::inferDbcPathForSpawn(const glm::vec3& spawnWorldPos, float maxDistance, bool allowZOnly) const { - float bestD2 = maxDistance * maxDistance; - uint32_t bestPathId = 0; - - for (const auto& [pathId, path] : paths_) { - if (!path.fromDBC || path.durationMs == 0 || path.points.empty()) { - continue; - } - if (!allowZOnly && path.zOnly) { - continue; - } - - // Find nearest waypoint on this path to spawn. - for (const auto& p : path.points) { - glm::vec3 diff = p.pos - spawnWorldPos; - float d2 = glm::dot(diff, diff); - if (d2 < bestD2) { - bestD2 = d2; - bestPathId = pathId; - } - } - } - - if (bestPathId != 0) { - LOG_INFO("TransportManager: Inferred DBC path ", bestPathId, - " (allowZOnly=", allowZOnly ? "yes" : "no", - ") for spawn at (", spawnWorldPos.x, ", ", spawnWorldPos.y, ", ", spawnWorldPos.z, - "), dist=", std::sqrt(bestD2)); - } - - return bestPathId; + return pathRepo_.inferDbcPathForSpawn(spawnWorldPos, maxDistance, allowZOnly); } uint32_t TransportManager::inferMovingPathForSpawn(const glm::vec3& spawnWorldPos, float maxDistance) const { - return inferDbcPathForSpawn(spawnWorldPos, maxDistance, /*allowZOnly=*/false); + return pathRepo_.inferMovingPathForSpawn(spawnWorldPos, maxDistance); } uint32_t TransportManager::pickFallbackMovingPath(uint32_t entry, uint32_t displayId) const { - auto isUsableMovingPath = [this](uint32_t pathId) -> bool { - auto it = paths_.find(pathId); - if (it == paths_.end()) return false; - const auto& path = it->second; - return path.fromDBC && !path.zOnly && path.durationMs > 0 && path.points.size() > 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 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, path] : paths_) { - if (path.fromDBC && !path.zOnly && path.durationMs > 0 && path.points.size() > 1) { - return pathId; - } - } - - return 0; + return pathRepo_.pickFallbackMovingPath(entry, displayId); } } // namespace wowee::game diff --git a/src/game/transport_path_repository.cpp b/src/game/transport_path_repository.cpp new file mode 100644 index 00000000..87ad5836 --- /dev/null +++ b/src/game/transport_path_repository.cpp @@ -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 +#include +#include + +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 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& 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((dist / speed) * 1000.0f); + }; + + // Single point = stationary (durationMs = 0) + if (waypoints.size() == 1) { + std::vector 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 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>> 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 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> 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 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((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((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 diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c4497b79..b40d8b12 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1,5 +1,6 @@ #include "game/world_packets.hpp" #include "game/packet_parsers.hpp" +#include "game/spline_packet.hpp" #include "game/opcodes.hpp" #include "game/character.hpp" #include "auth/crypto.hpp" @@ -959,141 +960,10 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Spline data if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED - auto bytesAvailable = [&](size_t n) -> bool { return packet.hasRemaining(n); }; - if (!bytesAvailable(4)) return false; - uint32_t splineFlags = packet.readUInt32(); - LOG_DEBUG(" Spline: flags=0x", std::hex, splineFlags, 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(pc - 1) * 4ull : 0ull); - } else { - // All uncompressed: 3 floats each - pointsBytes = static_cast(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); + SplineBlockData splineData; + glm::vec3 entityPos(block.x, block.y, block.z); + if (!parseWotlkMoveUpdateSpline(packet, splineData, entityPos)) { + LOG_WARNING("WotLK spline parse failed for guid=0x", std::hex, block.guid, std::dec); return false; } } @@ -1424,10 +1294,12 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) UpdateBlock block; if (!parseUpdateBlock(packet, block)) { 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, - " (", i, " blocks parsed successfully before failure)"); - if (parseBlockErrors == 5) + " (", i, " blocks parsed, ", lostBlocks, " blocks LOST", + ", remaining=", packet.getRemainingSize(), " bytes)"); + if (parseBlockErrors == 10) LOG_ERROR("(suppressing further update block parse errors)"); } // Cannot reliably re-sync to the next block after a parse failure, diff --git a/src/game/world_packets_entity.cpp b/src/game/world_packets_entity.cpp index f529da70..0be38742 100644 --- a/src/game/world_packets_entity.cpp +++ b/src/game/world_packets_entity.cpp @@ -1,5 +1,6 @@ #include "game/world_packets.hpp" #include "game/packet_parsers.hpp" +#include "game/spline_packet.hpp" #include "game/opcodes.hpp" #include "game/character.hpp" #include "auth/crypto.hpp" @@ -595,96 +596,22 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { if (!packet.hasRemaining(4)) return false; data.splineFlags = packet.readUInt32(); - // WotLK 3.3.5a SplineFlags (from TrinityCore/MaNGOS MoveSplineFlag.h): - // Animation = 0x00400000 - // Parabolic = 0x00000800 - // Catmullrom = 0x00080000 \ either means uncompressed (absolute) waypoints - // Flying = 0x00002000 / - - // [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; - } - - // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). - // Otherwise: first float3 is final destination, remaining are packed deltas. - bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; - - 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); + // Consolidated spline body parser + { + SplineBlockData spline; + if (!parseMonsterMoveSplineBody(packet, spline, data.splineFlags, + glm::vec3(data.x, data.y, data.z))) { + return false; } - if (!packet.hasRemaining(12)) return true; - data.destX = packet.readFloat(); - data.destY = packet.readFloat(); - data.destZ = packet.readFloat(); - 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(packed & 0x7FF); - if (sx & 0x400) sx |= static_cast(0xFFFFF800); - int32_t sy = static_cast((packed >> 11) & 0x7FF); - if (sy & 0x400) sy |= static_cast(0xFFFFF800); - int32_t sz = static_cast((packed >> 22) & 0x3FF); - if (sz & 0x200) sz |= static_cast(0xFFFFFC00); - MonsterMoveData::Point wp; - wp.x = midX - static_cast(sx) * 0.25f; - wp.y = midY - static_cast(sy) * 0.25f; - wp.z = midZ - static_cast(sz) * 0.25f; - data.waypoints.push_back(wp); - } + data.duration = spline.duration; + if (spline.hasDest) { + data.destX = spline.destination.x; + data.destY = spline.destination.y; + data.destZ = spline.destination.z; + data.hasDest = true; + } + 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; data.splineFlags = packet.readUInt32(); - // Animation flag (same bit as WotLK MoveSplineFlag::Animation) - if (data.splineFlags & 0x00400000) { - if (!packet.hasRemaining(5)) return false; - packet.readUInt8(); - packet.readUInt32(); - } - - 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; - } - - size_t requiredBytes = 12; - if (pointCount > 1) { - requiredBytes += static_cast(pointCount - 1) * 4ull; - } - 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; - - // Remaining waypoints are packed as uint32 deltas from midpoint. - 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(); - int32_t sx = static_cast(packed & 0x7FF); - if (sx & 0x400) sx |= static_cast(0xFFFFF800); - int32_t sy = static_cast((packed >> 11) & 0x7FF); - if (sy & 0x400) sy |= static_cast(0xFFFFF800); - int32_t sz = static_cast((packed >> 22) & 0x3FF); - if (sz & 0x200) sz |= static_cast(0xFFFFFC00); - MonsterMoveData::Point wp; - wp.x = midX - static_cast(sx) * 0.25f; - wp.y = midY - static_cast(sy) * 0.25f; - wp.z = midZ - static_cast(sz) * 0.25f; - data.waypoints.push_back(wp); + // Consolidated Vanilla spline body parser (always compressed) + { + SplineBlockData spline; + if (!parseMonsterMoveSplineBodyVanilla(packet, spline, data.splineFlags, + glm::vec3(data.x, data.y, data.z))) { + return false; + } + data.duration = spline.duration; + if (spline.hasDest) { + data.destX = spline.destination.x; + data.destY = spline.destination.y; + data.destZ = spline.destination.z; + data.hasDest = true; + } + for (const auto& wp : spline.waypoints) { + data.waypoints.push_back({wp.x, wp.y, wp.z}); } } @@ -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) { @@ -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, diff --git a/src/game/world_packets_social.cpp b/src/game/world_packets_social.cpp index 95ec7fe4..27366631 100644 --- a/src/game/world_packets_social.cpp +++ b/src/game/world_packets_social.cpp @@ -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) { diff --git a/src/game/world_packets_world.cpp b/src/game/world_packets_world.cpp index 83d631c1..dea66945 100644 --- a/src/game/world_packets_world.cpp +++ b/src/game/world_packets_world.cpp @@ -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) { @@ -468,7 +468,7 @@ bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData } // ============================================================ -// Phase 5: Loot System +// Loot System // ============================================================ 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) { @@ -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) { diff --git a/src/math/spline.cpp b/src/math/spline.cpp new file mode 100644 index 00000000..db36c23e --- /dev/null +++ b/src/math/spline.cpp @@ -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 +#include +#include + +namespace wowee::math { + +CatmullRomSpline::CatmullRomSpline(std::vector 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(pathTimeMs - t1Ms) + / static_cast(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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ae168aec..3e543dc3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,6 +59,38 @@ target_link_libraries(test_srp PRIVATE catch2_main OpenSSL::SSL OpenSSL::Crypto) add_test(NAME srp COMMAND test_srp) register_test_target(test_srp) +# ── test_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 ──────────────────────────────────────── add_executable(test_opcode_table test_opcode_table.cpp @@ -76,10 +108,14 @@ add_executable(test_entity test_entity.cpp ${TEST_COMMON_SOURCES} ${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 SYSTEM PRIVATE ${TEST_SYSTEM_INCLUDE_DIRS}) 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) register_test_target(test_entity) @@ -210,6 +246,23 @@ endif() add_test(NAME indoor_shadows COMMAND 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 ──────────────────────────── if(WOWEE_ENABLE_ASAN AND NOT MSVC) foreach(_t IN LISTS ALL_TEST_TARGETS) diff --git a/tests/test_spline.cpp b/tests/test_spline.cpp new file mode 100644 index 00000000..796140eb --- /dev/null +++ b/tests/test_spline.cpp @@ -0,0 +1,240 @@ +// tests/test_spline.cpp +// Unit tests for wowee::math::CatmullRomSpline +#include +#include "math/spline.hpp" +#include + +using namespace wowee::math; + +// ── Helper: build a simple 4-point linear path ───────────────────── +static std::vector 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 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 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 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 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)); +} diff --git a/tests/test_transport_components.cpp b/tests/test_transport_components.cpp new file mode 100644 index 00000000..629e13aa --- /dev/null +++ b/tests/test_transport_components.cpp @@ -0,0 +1,209 @@ +// tests/test_transport_components.cpp +// Unit tests for TransportClockSync and TransportAnimator (Phase 3 extractions). +#include +#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 +#include + +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 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(); + 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 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)); +} diff --git a/tests/test_transport_path_repo.cpp b/tests/test_transport_path_repo.cpp new file mode 100644 index 00000000..0f1fc815 --- /dev/null +++ b/tests/test_transport_path_repo.cpp @@ -0,0 +1,315 @@ +// Tests for TransportPathRepository (Phase 3 of spline refactoring). +// Verifies PathEntry wrapping, path operations, and CatmullRomSpline integration. +#include +#include "game/transport_path_repository.hpp" +#include "pipeline/asset_manager.hpp" +#include "math/spline.hpp" +#include +#include +#include + +// ── 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 AssetManager::readFile(const std::string&) const { return {}; } +std::vector 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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); +}