diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp index 76f89e22..8cc427c9 100644 --- a/include/game/transport_manager.hpp +++ b/include/game/transport_manager.hpp @@ -27,6 +27,7 @@ struct TransportPath { 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 }; struct ActiveTransport { @@ -100,7 +101,7 @@ private: std::unordered_map transports_; std::unordered_map paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc) rendering::WMORenderer* wmoRenderer_ = nullptr; - bool clientSideAnimation_ = true; // Enable client animation - smooth movement, synced with server updates + bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction float elapsedTime_ = 0.0f; // Total elapsed time (seconds) }; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 045de7d3..51af5f73 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -411,6 +411,14 @@ struct MovementInfo { float jumpCosAngle = 0.0f; // Jump horizontal cos float jumpXYSpeed = 0.0f; // Jump horizontal speed + // Transport fields (when ONTRANSPORT flag is set) + uint64_t transportGuid = 0; // GUID of transport (boat/zeppelin/etc) + float transportX = 0.0f; // Local position on transport + float transportY = 0.0f; + float transportZ = 0.0f; + float transportO = 0.0f; // Local orientation on transport + uint32_t transportTime = 0; // Transport movement timestamp + bool hasFlag(MovementFlags flag) const { return (flags & static_cast(flag)) != 0; } diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 8b6b2885..29adc48a 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -21,6 +21,7 @@ namespace rendering { class Camera; class Shader; class Frustum; +class M2Renderer; /** * WMO (World Model Object) Renderer @@ -49,6 +50,11 @@ public: */ void shutdown(); + /** + * Set M2 renderer for hierarchical transform updates (doodads follow parent WMO) + */ + void setM2Renderer(M2Renderer* renderer) { m2Renderer_ = renderer; } + /** * Load WMO model and create GPU resources * @param model WMO model with geometry data @@ -89,6 +95,27 @@ public: */ void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); + /** + * Add doodad (child M2) to WMO instance + * @param instanceId WMO instance to add doodad to + * @param m2InstanceId M2 instance ID of the doodad + * @param localTransform Local transform relative to WMO origin + */ + void addDoodadToInstance(uint32_t instanceId, uint32_t m2InstanceId, const glm::mat4& localTransform); + + // Forward declare DoodadTemplate for public API + struct DoodadTemplate { + std::string m2Path; + glm::mat4 localTransform; + }; + + /** + * Get doodad templates for a WMO model + * @param modelId WMO model ID + * @return Vector of doodad templates (empty if no doodads or model not found) + */ + const std::vector* getDoodadTemplates(uint32_t modelId) const; + /** * Remove WMO instance * @param instanceId Instance to remove @@ -363,6 +390,10 @@ private: glm::vec3 boundingBoxMax; bool isLowPlatform = false; + // Doodad templates (M2 models placed in WMO, stored for instancing) + // Uses the public DoodadTemplate struct defined above + std::vector doodadTemplates; + // Texture handles for this model (indexed by texture path order) std::vector textures; @@ -510,6 +541,9 @@ private: // Asset manager for loading textures pipeline::AssetManager* assetManager = nullptr; + // M2 renderer for hierarchical transforms (doodads following WMO parent) + M2Renderer* m2Renderer_ = nullptr; + // Current map name for zone-specific floor cache std::string mapName_; diff --git a/src/core/application.cpp b/src/core/application.cpp index 97cd3b19..e99051f2 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -903,16 +903,10 @@ void Application::setupUICallbacks() { // Register the transport with spawn position (prevents rendering at origin until server update) transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos); - if (clientAnim) { - LOG_INFO("Transport registered - client-side animation enabled"); - } else { - // Only call updateServerTransport if client animation is disabled - // This sets the exact spawn position for server-controlled transports - // Coordinates are already canonical (converted in game_handler.cpp) - glm::vec3 canonicalPos(x, y, z); - transportManager->updateServerTransport(guid, canonicalPos, orientation); - LOG_INFO("Transport registered - server-controlled movement"); - } + // Server-authoritative movement - set initial position from spawn data + glm::vec3 canonicalPos(x, y, z); + transportManager->updateServerTransport(guid, canonicalPos, orientation); + LOG_INFO("Transport registered - server-authoritative movement"); }); // Transport move callback (online mode) - update transport gameobject positions @@ -1792,6 +1786,12 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float LOG_INFO("TransportManager connected to WMORenderer for online mode"); } + // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) + if (renderer->getWMORenderer() && renderer->getM2Renderer()) { + renderer->getWMORenderer()->setM2Renderer(renderer->getM2Renderer()); + LOG_INFO("WMORenderer connected to M2Renderer for hierarchical doodad transforms"); + } + showProgress("Loading character model...", 0.05f); // Build faction hostility map for this character's race @@ -2870,6 +2870,65 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t LOG_INFO("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); + // Spawn WMO doodads (chairs, furniture, etc.) as child M2 instances + // TODO: Re-enable after implementing deferred/background loading + // Currently disabled - spawning 134 doodads synchronously causes massive slowdown + bool isTransport = false; + if (gameHandler) { + std::string lowerModelPath = modelPath; + std::transform(lowerModelPath.begin(), lowerModelPath.end(), lowerModelPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + isTransport = (lowerModelPath.find("transport") != std::string::npos); + } + + auto* m2Renderer = renderer->getM2Renderer(); + if (false && m2Renderer && isTransport) { // DISABLED - too slow + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); + if (doodadTemplates && !doodadTemplates->empty()) { + LOG_INFO("Spawning ", doodadTemplates->size(), " doodads for transport WMO instance ", instanceId); + int spawnedDoodads = 0; + + for (const auto& doodadTemplate : *doodadTemplates) { + // Load M2 model (may be cached) + uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); + auto m2Data = assetManager->readFile(doodadTemplate.m2Path); + if (m2Data.empty()) continue; + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } + if (!m2Model.isValid()) continue; + + // Load model to renderer (cached if already loaded) + m2Renderer->loadModel(m2Model, doodadModelId); + + // Create M2 instance at world origin (transform will be updated by WMO parent) + uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); + if (m2InstanceId == 0) continue; + + // Link doodad to WMO instance + wmoRenderer->addDoodadToInstance(instanceId, m2InstanceId, doodadTemplate.localTransform); + spawnedDoodads++; + } + + if (spawnedDoodads > 0) { + LOG_INFO("Spawned ", spawnedDoodads, " doodads for transport WMO instance ", instanceId); + + // Initial transform update to position doodads correctly + // (subsequent updates will happen automatically via setInstanceTransform) + glm::mat4 wmoTransform(1.0f); + wmoTransform = glm::translate(wmoTransform, renderPos); + wmoTransform = glm::rotate(wmoTransform, renderYaw, glm::vec3(0, 0, 1)); + wmoRenderer->setInstanceTransform(instanceId, wmoTransform); + } + } else { + LOG_INFO("Transport WMO has no doodads or templates not available"); + } + } + // Check if this is a transport and notify via special method if (gameHandler) { std::string lowerModelPath = modelPath; @@ -3285,6 +3344,12 @@ void Application::setupTestTransport() { // Connect transport manager to WMO renderer transportManager->setWMORenderer(wmoRenderer); + // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) + if (renderer->getM2Renderer()) { + wmoRenderer->setM2Renderer(renderer->getM2Renderer()); + LOG_INFO("WMORenderer connected to M2Renderer for test transport doodad transforms"); + } + // Define a simple circular path around Stormwind harbor (canonical coordinates) // These coordinates are approximate - adjust based on actual harbor layout std::vector harborPath = { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index b3ff6395..12e0a35d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1775,8 +1775,24 @@ void GameHandler::sendMovement(Opcode opcode) { break; } + // Add transport data if player is on a transport + if (isOnTransport()) { + movementInfo.flags |= static_cast(MovementFlags::ONTRANSPORT); + movementInfo.transportGuid = playerTransportGuid_; + movementInfo.transportX = playerTransportOffset_.x; + movementInfo.transportY = playerTransportOffset_.y; + movementInfo.transportZ = playerTransportOffset_.z; + movementInfo.transportO = movementInfo.orientation; // Use same orientation as player + movementInfo.transportTime = movementInfo.time; // Use same timestamp + } else { + // Clear transport flag if not on transport + movementInfo.flags &= ~static_cast(MovementFlags::ONTRANSPORT); + movementInfo.transportGuid = 0; + } + LOG_DEBUG("Sending movement packet: opcode=0x", std::hex, - static_cast(opcode), std::dec); + static_cast(opcode), std::dec, + (isOnTransport() ? " ONTRANSPORT" : "")); // Convert canonical → server coordinates for the wire MovementInfo wireInfo = movementInfo; @@ -1785,6 +1801,15 @@ void GameHandler::sendMovement(Opcode opcode) { wireInfo.y = serverPos.y; wireInfo.z = serverPos.z; + // Also convert transport local position to server coordinates if on transport + if (isOnTransport()) { + glm::vec3 serverTransportPos = core::coords::canonicalToServer( + glm::vec3(wireInfo.transportX, wireInfo.transportY, wireInfo.transportZ)); + wireInfo.transportX = serverTransportPos.x; + wireInfo.transportY = serverTransportPos.y; + wireInfo.transportZ = serverTransportPos.z; + } + // Build and send movement packet auto packet = MovementPacket::build(opcode, wireInfo, playerGuid); socket->send(packet); @@ -1869,7 +1894,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (block.guid == playerGuid) { if (block.onTransport) { playerTransportGuid_ = block.transportGuid; - playerTransportOffset_ = glm::vec3(block.transportX, block.transportY, block.transportZ); + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); } else { @@ -2359,6 +2386,21 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { movementInfo.y = pos.y; movementInfo.z = pos.z; movementInfo.orientation = block.orientation; + + // Track player-on-transport state from MOVEMENT updates + if (block.onTransport) { + playerTransportGuid_ = block.transportGuid; + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); + LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); + } else { + if (playerTransportGuid_ != 0) { + LOG_INFO("Player left transport (MOVEMENT)"); + playerTransportGuid_ = 0; + playerTransportOffset_ = glm::vec3(0.0f); + } + } } // Fire transport move callback if this is a known transport diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index d1526bb7..c3c765ba 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -55,6 +55,16 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, glm::vec3 offset0 = evalTimedCatmullRom(path, 0); transport.basePosition = spawnWorldPos - offset0; // Infer base from spawn transport.position = spawnWorldPos; // Start at spawn position (base + offset0) + + // Sanity check: firstWaypoint should match spawnWorldPos + glm::vec3 firstWaypoint = path.points[0].pos; + glm::vec3 waypointDiff = spawnWorldPos - firstWaypoint; + if (glm::length(waypointDiff) > 1.0f) { + LOG_WARNING("Transport 0x", std::hex, guid, std::dec, " path ", pathId, + ": firstWaypoint mismatch! spawnPos=(", spawnWorldPos.x, ",", spawnWorldPos.y, ",", spawnWorldPos.z, ")", + " firstWaypoint=(", firstWaypoint.x, ",", firstWaypoint.y, ",", firstWaypoint.z, ")", + " diff=(", waypointDiff.x, ",", waypointDiff.y, ",", waypointDiff.z, ")"); + } } transport.rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity quaternion @@ -64,7 +74,8 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, transport.localClockMs = 0; transport.hasServerClock = false; transport.serverClockOffsetMs = 0; - transport.useClientAnimation = clientSideAnimation_; // Enable client animation by default + // Server-authoritative movement only - no client-side animation + transport.useClientAnimation = false; transport.serverYaw = 0.0f; transport.hasServerYaw = false; transport.lastServerUpdate = 0.0f; @@ -128,33 +139,53 @@ void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector uint32_t { + if (speed <= 0.0f) return 1000; + return (uint32_t)((dist / speed) * 1000.0f); + }; - // Convert waypoints to TimedPoint (for fallback paths, use arbitrary timing) // Single point = stationary (durationMs = 0) - // Multiple points = assume constant speed - path.points.reserve(waypoints.size()); if (waypoints.size() == 1) { path.points.push_back({0, waypoints[0]}); - path.durationMs = 0; // Stationary - } else { - // Calculate cumulative time based on distance and speed - 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]); - uint32_t segmentMs = speed > 0.0f ? (uint32_t)((dist / speed) * 1000.0f) : 1000; - cumulativeMs += segmentMs; - path.points.push_back({cumulativeMs, waypoints[i]}); - } - path.durationMs = cumulativeMs; + 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 + 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()}); // Duplicate first point + path.looping = false; // Time-closed path, no need for index wrapping + } else { + path.looping = false; + } + + path.durationMs = cumulativeMs; paths_[pathId] = path; LOG_INFO("TransportManager: Loaded path ", pathId, - " with ", waypoints.size(), " waypoints, " - "looping=", looping, ", speed=", speed); + " with ", waypoints.size(), " waypoints", + (looping ? " + wrap segment" : ""), + ", duration=", path.durationMs, "ms, speed=", speed); } void TransportManager::setDeckBounds(uint64_t guid, const glm::vec3& min, const glm::vec3& max) { @@ -270,24 +301,34 @@ glm::vec3 TransportManager::evalTimedCatmullRom(const TransportPath& path, uint3 } } - // Handle not found (wraparound or timing gaps) + // Handle not found (timing gaps or past last segment) if (!found) { - segmentIdx = path.looping ? (path.points.size() - 1) : - (path.points.size() >= 2 ? path.points.size() - 2 : 0); + // 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 - size_t p0Idx = (segmentIdx == 0) ? (path.looping ? numPoints - 1 : 0) : segmentIdx - 1; - size_t p1Idx = segmentIdx; - size_t p2Idx = (segmentIdx + 1) % numPoints; - size_t p3Idx = (segmentIdx + 2) % numPoints; + // Helper to clamp index (no wrapping for non-looping paths) + auto idxClamp = [&](size_t i) -> size_t { + return (i >= numPoints) ? (numPoints - 1) : i; + }; - if (!path.looping) { - if (segmentIdx == 0) p0Idx = 0; - if (segmentIdx >= numPoints - 2) p3Idx = numPoints - 1; - if (segmentIdx >= numPoints - 1) p2Idx = numPoints - 1; + 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; @@ -337,24 +378,34 @@ glm::quat TransportManager::orientationFromTangent(const TransportPath& path, ui } } - // Handle not found (wraparound or timing gaps) + // Handle not found (timing gaps or past last segment) if (!found) { - segmentIdx = path.looping ? (path.points.size() - 1) : - (path.points.size() >= 2 ? path.points.size() - 2 : 0); + // 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 - size_t p0Idx = (segmentIdx == 0) ? (path.looping ? numPoints - 1 : 0) : segmentIdx - 1; - size_t p1Idx = segmentIdx; - size_t p2Idx = (segmentIdx + 1) % numPoints; - size_t p3Idx = (segmentIdx + 2) % numPoints; + // 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; + }; - if (!path.looping) { - if (segmentIdx == 0) p0Idx = 0; - if (segmentIdx >= numPoints - 2) p3Idx = numPoints - 1; - if (segmentIdx >= numPoints - 1) p2Idx = numPoints - 1; + 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; @@ -461,6 +512,24 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos const auto& path = pathIt->second; + // Z-only paths (elevator/bobbing): server is authoritative, no projection needed + if (path.zOnly) { + transport->position = position; + transport->serverYaw = orientation; + transport->hasServerYaw = true; + transport->rotation = glm::angleAxis(transport->serverYaw, glm::vec3(0.0f, 0.0f, 1.0f)); + transport->useClientAnimation = false; // Server-driven + + updateTransformMatrices(*transport); + if (wmoRenderer_) { + wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + } + + LOG_INFO("TransportManager: Z-only transport 0x", std::hex, guid, std::dec, + " updated from server: pos=(", position.x, ", ", position.y, ", ", position.z, ")"); + return; + } + // Seed basePosition from t=0 assumption before first search // (t=0 corresponds to spawn point / first path point) if (!transport->hasServerClock) { @@ -659,6 +728,14 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg " 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)}); } @@ -687,6 +764,14 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg // TransportAnimation.dbc uses server coordinates - convert to canonical glm::vec3 canonical = core::coords::serverToCanonical(pos); + // 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, @@ -694,6 +779,13 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg " → 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 } @@ -720,14 +812,30 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg 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; + 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); + } + float rangeX = maxX - minX; + float rangeY = maxY - minY; + bool isZOnly = (rangeX < 0.01f && rangeY < 0.01f); + // Store path TransportPath path; path.pathId = transportEntry; path.points = timedPoints; - // Keep looping=true even with duplicate wrap point for smooth control point selection at seam - // This prevents kinks on the last segment approaching the wrap - path.looping = true; + // 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; paths_[transportEntry] = path; pathsLoaded++; @@ -738,6 +846,7 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg 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, ") ", (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, ")"); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8a2161f2..5e82837b 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -581,6 +581,34 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u packet.writeBytes(reinterpret_cast(&info.jumpXYSpeed), sizeof(float)); } + // Write transport data if on transport + if (info.hasFlag(MovementFlags::ONTRANSPORT)) { + // Write packed transport GUID + uint8_t transMask = 0; + uint8_t transGuidBytes[8]; + int transGuidByteCount = 0; + for (int i = 0; i < 8; i++) { + uint8_t byte = static_cast((info.transportGuid >> (i * 8)) & 0xFF); + if (byte != 0) { + transMask |= (1 << i); + transGuidBytes[transGuidByteCount++] = byte; + } + } + packet.writeUInt8(transMask); + for (int i = 0; i < transGuidByteCount; i++) { + packet.writeUInt8(transGuidBytes[i]); + } + + // Write transport local position + packet.writeBytes(reinterpret_cast(&info.transportX), sizeof(float)); + packet.writeBytes(reinterpret_cast(&info.transportY), sizeof(float)); + packet.writeBytes(reinterpret_cast(&info.transportZ), sizeof(float)); + packet.writeBytes(reinterpret_cast(&info.transportO), sizeof(float)); + + // Write transport time + packet.writeUInt32(info.transportTime); + } + // Detailed hex dump for debugging static int mvLog = 5; if (mvLog-- > 0) { @@ -596,7 +624,11 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u " flags=0x", std::hex, info.flags, std::dec, " flags2=0x", std::hex, info.flags2, std::dec, " pos=(", info.x, ",", info.y, ",", info.z, ",", info.orientation, ")", - " fallTime=", info.fallTime); + " fallTime=", info.fallTime, + (info.hasFlag(MovementFlags::ONTRANSPORT) ? + " ONTRANSPORT guid=0x" + std::to_string(info.transportGuid) + + " localPos=(" + std::to_string(info.transportX) + "," + + std::to_string(info.transportY) + "," + std::to_string(info.transportZ) + ")" : "")); LOG_INFO("MOVEPKT hex: ", hex); } diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index be7de102..d52e8a8b 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -1,4 +1,5 @@ #include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" #include "rendering/texture.hpp" #include "rendering/shader.hpp" #include "rendering/camera.hpp" @@ -438,6 +439,49 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { " refs: ", modelData.portalRefs.size()); } + // Store doodad templates (M2 models placed in WMO) for instancing later + if (!model.doodadSets.empty() && !model.doodads.empty()) { + const auto& doodadSet = model.doodadSets[0]; // Use first doodad set + for (uint32_t di = 0; di < doodadSet.count; di++) { + uint32_t doodadIdx = doodadSet.startIndex + di; + if (doodadIdx >= model.doodads.size()) break; + + const auto& doodad = model.doodads[doodadIdx]; + auto nameIt = model.doodadNames.find(doodad.nameIndex); + if (nameIt == model.doodadNames.end()) continue; + + std::string m2Path = nameIt->second; + if (m2Path.empty()) continue; + + // Convert .mdx/.mdl to .m2 + if (m2Path.size() > 4) { + std::string ext = m2Path.substr(m2Path.size() - 4); + for (char& c : ext) c = std::tolower(c); + if (ext == ".mdx" || ext == ".mdl") { + m2Path = m2Path.substr(0, m2Path.size() - 4) + ".m2"; + } + } + + // Build doodad's local transform (WoW coordinates) + // WMO doodads use quaternion rotation (X/Y swapped for correct orientation) + glm::quat fixedRotation(doodad.rotation.w, doodad.rotation.y, doodad.rotation.x, doodad.rotation.z); + + glm::mat4 localTransform(1.0f); + localTransform = glm::translate(localTransform, doodad.position); + localTransform *= glm::mat4_cast(fixedRotation); + localTransform = glm::scale(localTransform, glm::vec3(doodad.scale)); + + DoodadTemplate doodadTemplate; + doodadTemplate.m2Path = m2Path; + doodadTemplate.localTransform = localTransform; + modelData.doodadTemplates.push_back(doodadTemplate); + } + + if (!modelData.doodadTemplates.empty()) { + core::Logger::getInstance().info("WMO has ", modelData.doodadTemplates.size(), " doodad templates"); + } + } + loadedModels[id] = std::move(modelData); core::Logger::getInstance().info("WMO model ", id, " loaded successfully (", loadedGroups, " groups)"); return true; @@ -594,7 +638,37 @@ void WMORenderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tra inst.worldGroupBounds.emplace_back(gMin, gMax); } } - rebuildSpatialIndex(); + + // Propagate transform to child M2 doodads (chairs, furniture on transports) + if (m2Renderer_ && !inst.doodads.empty()) { + for (const auto& doodad : inst.doodads) { + glm::mat4 worldTransform = inst.modelMatrix * doodad.localTransform; + m2Renderer_->setInstanceTransform(doodad.m2InstanceId, worldTransform); + } + } + + // NOTE: Don't rebuild spatial index on every transform update - causes flickering + // Spatial grid is only used for collision queries, render iterates all instances + // rebuildSpatialIndex(); +} + +void WMORenderer::addDoodadToInstance(uint32_t instanceId, uint32_t m2InstanceId, const glm::mat4& localTransform) { + auto it = std::find_if(instances.begin(), instances.end(), + [instanceId](const WMOInstance& inst) { return inst.id == instanceId; }); + if (it != instances.end()) { + WMOInstance::DoodadInfo doodad; + doodad.m2InstanceId = m2InstanceId; + doodad.localTransform = localTransform; + it->doodads.push_back(doodad); + } +} + +const std::vector* WMORenderer::getDoodadTemplates(uint32_t modelId) const { + auto it = loadedModels.find(modelId); + if (it != loadedModels.end() && !it->second.doodadTemplates.empty()) { + return &it->second.doodadTemplates; + } + return nullptr; } void WMORenderer::removeInstance(uint32_t instanceId) {