From f4c115ade9a6d67a1d0663c2a2a54b5c42dbc4fa Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 6 Mar 2026 23:01:11 -0800 Subject: [PATCH] Fix Deeprun Tram: visual movement, direction, and player riding - Fix NULL renderer pointers by moving TransportManager connection after initializeRenderers for WMO-only maps - Fix tram direction by negating DBC TransportAnimation X/Y local offsets before serverToCanonical conversion - Implement client-side M2 transport boarding via proximity detection (server doesn't send transport attachment for trams) - Use position-delta approach: player keeps normal movement while transport's frame-to-frame motion is applied on top - Prevent server movement packets from clearing client-side M2 transport state (isClientM2Transport guard) - Fix getPlayerWorldPosition for M2 transports: simple canonical addition instead of render-space matrix multiplication --- include/game/game_handler.hpp | 3 + include/game/transport_manager.hpp | 5 + src/core/application.cpp | 152 ++++++++++++++++++++++++----- src/game/game_handler.cpp | 27 ++++- src/game/transport_manager.cpp | 116 ++++++++++++++-------- 5 files changed, 233 insertions(+), 70 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index add60a7b..8a3ee441 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -658,6 +658,9 @@ public: playerTransportStickyTimer_ = 8.0f; movementInfo.transportGuid = transportGuid; } + void setPlayerTransportOffset(const glm::vec3& offset) { + playerTransportOffset_ = offset; + } void clearPlayerTransport() { if (playerTransportGuid_ != 0) { playerTransportStickyGuid_ = playerTransportGuid_; diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp index 571d3b3f..496380c4 100644 --- a/include/game/transport_manager.hpp +++ b/include/game/transport_manager.hpp @@ -9,6 +9,7 @@ namespace wowee::rendering { class WMORenderer; + class M2Renderer; } namespace wowee::pipeline { @@ -71,6 +72,7 @@ struct ActiveTransport { float serverAngularVelocity; bool hasServerVelocity; bool allowBootstrapVelocity; // Disable DBC bootstrap when spawn/path mismatch is clearly invalid + bool isM2 = false; // True if rendered as M2 (not WMO), uses M2Renderer for transforms }; class TransportManager { @@ -79,12 +81,14 @@ public: ~TransportManager(); void setWMORenderer(rendering::WMORenderer* renderer) { wmoRenderer_ = renderer; } + void setM2Renderer(rendering::M2Renderer* renderer) { m2Renderer_ = renderer; } void update(float deltaTime); void registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry = 0); void unregisterTransport(uint64_t guid); ActiveTransport* getTransport(uint64_t guid); + const std::unordered_map& getTransports() const { return transports_; } glm::vec3 getPlayerWorldPosition(uint64_t transportGuid, const glm::vec3& localOffset); glm::mat4 getTransportInvTransform(uint64_t transportGuid); @@ -141,6 +145,7 @@ private: 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 float elapsedTime_ = 0.0f; // Total elapsed time (seconds) }; diff --git a/src/core/application.cpp b/src/core/application.cpp index 417d6438..3310c406 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -968,6 +968,15 @@ void Application::update(float deltaTime) { gameHandler->isTaxiMountActive() || gameHandler->isTaxiActivationPending()); bool onTransportNow = gameHandler && gameHandler->isOnTransport(); + // M2 transports (trams) use position-delta approach: player keeps normal + // movement and the transport's frame-to-frame delta is applied on top. + // Only WMO transports (ships) use full external-driven mode. + bool isM2Transport = false; + if (onTransportNow && gameHandler->getTransportManager()) { + auto* tr = gameHandler->getTransportManager()->getTransport(gameHandler->getPlayerTransportGuid()); + isM2Transport = (tr && tr->isM2); + } + bool onWMOTransport = onTransportNow && !isM2Transport; if (worldEntryMovementGraceTimer_ > 0.0f) { worldEntryMovementGraceTimer_ -= deltaTime; // Clear stale movement from before teleport each frame @@ -976,7 +985,7 @@ void Application::update(float deltaTime) { renderer->getCameraController()->clearMovementInputs(); } if (renderer && renderer->getCameraController()) { - const bool externallyDrivenMotion = onTaxi || onTransportNow || chargeActive_; + const bool externallyDrivenMotion = onTaxi || onWMOTransport || chargeActive_; // Keep physics frozen (externalFollow) during landing clamp when terrain // hasn't loaded yet — prevents gravity from pulling player through void. bool landingClampActive = !onTaxi && taxiLandingClampTimer_ > 0.0f && @@ -1057,14 +1066,18 @@ void Application::update(float deltaTime) { // Sync character render position ↔ canonical WoW coords each frame if (renderer && gameHandler) { - bool onTransport = gameHandler->isOnTransport(); + // For position sync branching, only WMO transports use the dedicated + // onTransport branch. M2 transports use the normal movement else branch + // with a position-delta correction applied on top. + bool onTransport = onWMOTransport; - // Debug: Log transport state changes static bool wasOnTransport = false; - if (onTransport != wasOnTransport) { - LOG_DEBUG("Transport state changed: onTransport=", onTransport, + bool onTransportNowDbg = gameHandler->isOnTransport(); + if (onTransportNowDbg != wasOnTransport) { + LOG_DEBUG("Transport state changed: onTransport=", onTransportNowDbg, + " isM2=", isM2Transport, " guid=0x", std::hex, gameHandler->getPlayerTransportGuid(), std::dec); - wasOnTransport = onTransport; + wasOnTransport = onTransportNowDbg; } if (onTaxi) { @@ -1092,13 +1105,11 @@ void Application::update(float deltaTime) { } } } else if (onTransport) { - // Transport mode: compose world position from transport transform + local offset + // WMO transport mode (ships): compose world position from transform + local offset glm::vec3 canonical = gameHandler->getComposedWorldPosition(); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); renderer->getCharacterPosition() = renderPos; - // Keep movementInfo in lockstep with composed transport world position. gameHandler->setPosition(canonical.x, canonical.y, canonical.z); - // Update camera follow target if (renderer->getCameraController()) { glm::vec3* followTarget = renderer->getCameraController()->getFollowTargetMutable(); if (followTarget) { @@ -1172,6 +1183,27 @@ void Application::update(float deltaTime) { } } else { glm::vec3 renderPos = renderer->getCharacterPosition(); + + // M2 transport riding: apply transport's frame-to-frame position delta + // so the player moves with the tram while retaining normal movement input. + if (isM2Transport && gameHandler->getTransportManager()) { + auto* tr = gameHandler->getTransportManager()->getTransport( + gameHandler->getPlayerTransportGuid()); + if (tr) { + static glm::vec3 lastTransportCanonical(0); + static uint64_t lastTransportGuid = 0; + if (lastTransportGuid == gameHandler->getPlayerTransportGuid()) { + glm::vec3 deltaCanonical = tr->position - lastTransportCanonical; + glm::vec3 deltaRender = core::coords::canonicalToRender(deltaCanonical) + - core::coords::canonicalToRender(glm::vec3(0)); + renderPos += deltaRender; + renderer->getCharacterPosition() = renderPos; + } + lastTransportCanonical = tr->position; + lastTransportGuid = gameHandler->getPlayerTransportGuid(); + } + } + glm::vec3 canonical = core::coords::renderToCanonical(renderPos); gameHandler->setPosition(canonical.x, canonical.y, canonical.z); @@ -1203,6 +1235,41 @@ void Application::update(float deltaTime) { facingSendCooldown_ = 0.1f; // max 10 Hz } } + + // Client-side transport boarding detection (for M2 transports like trams + // where the server doesn't send transport attachment data). + // Use a generous AABB around each transport's current position. + if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) { + auto* tm = gameHandler->getTransportManager(); + glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); + + for (auto& [guid, transport] : tm->getTransports()) { + if (!transport.isM2) continue; + glm::vec3 diff = playerCanonical - transport.position; + float horizDistSq = diff.x * diff.x + diff.y * diff.y; + float vertDist = std::abs(diff.z); + if (horizDistSq < 144.0f && vertDist < 15.0f) { + gameHandler->setPlayerOnTransport(guid, playerCanonical - transport.position); + LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, guid, std::dec); + break; + } + } + } + + // M2 transport disembark: player walked far enough from transport center + if (isM2Transport && gameHandler->getTransportManager()) { + auto* tm = gameHandler->getTransportManager(); + auto* tr = tm->getTransport(gameHandler->getPlayerTransportGuid()); + if (tr) { + glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); + glm::vec3 diff = playerCanonical - tr->position; + float horizDistSq = diff.x * diff.x + diff.y * diff.y; + if (horizDistSq > 225.0f) { + gameHandler->clearPlayerTransport(); + LOG_DEBUG("M2 transport disembark"); + } + } + } } } }); @@ -2073,7 +2140,7 @@ void Application::setupUICallbacks() { } uint32_t wmoInstanceId = it->second.instanceId; - LOG_DEBUG("Registering server transport: GUID=0x", std::hex, guid, std::dec, + LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId, " pos=(", x, ", ", y, ", ", z, ")"); @@ -2101,15 +2168,18 @@ void Application::setupUICallbacks() { hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); } + LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + if (preferServerData) { // Strict server-authoritative mode: do not infer/remap fallback routes. if (!hasUsablePath) { std::vector path = { canonicalSpawnPos }; transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_DEBUG("Server-first strict registration: stationary fallback for GUID 0x", + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", std::hex, guid, std::dec, " entry=", entry); } else { - LOG_DEBUG("Server-first transport registration: using entry DBC path for entry ", entry); + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry); } } else if (!hasUsablePath) { // Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids. @@ -2119,12 +2189,12 @@ void Application::setupUICallbacks() { canonicalSpawnPos, 1200.0f, allowZOnly); if (inferredPath != 0) { pathId = inferredPath; - LOG_DEBUG("Using inferred transport path ", pathId, " for entry ", entry); + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", entry); } else { uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); if (remappedPath != 0) { pathId = remappedPath; - LOG_DEBUG("Using remapped fallback transport path ", pathId, + LOG_WARNING("Using remapped fallback transport path ", pathId, " for entry ", entry, " displayId=", displayId, " (usableEntryPath=", transportManager->hasPathForEntry(entry), ")"); } else { @@ -2137,12 +2207,19 @@ void Application::setupUICallbacks() { } } } else { - LOG_DEBUG("Using real transport path from TransportAnimation.dbc for entry ", entry); + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", entry); } // Register the transport with spawn position (prevents rendering at origin until server update) transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); + // Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer + if (!it->second.isWmo) { + if (auto* tr = transportManager->getTransport(guid)) { + tr->isM2 = true; + } + } + // Server-authoritative movement - set initial position from spawn data glm::vec3 canonicalPos(x, y, z); transportManager->updateServerTransport(guid, canonicalPos, orientation); @@ -2171,7 +2248,7 @@ void Application::setupUICallbacks() { } if (auto* tr = transportManager->getTransport(guid); tr) { - LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec, + LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, " pathId=", tr->pathId, " mode=", (tr->useClientAnimation ? "client" : "server"), @@ -3458,11 +3535,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float renderer->getTerrainManager()->setMapName(mapName); } - // Connect TransportManager to WMORenderer (for server transports) - if (gameHandler && gameHandler->getTransportManager() && renderer->getWMORenderer()) { - gameHandler->getTransportManager()->setWMORenderer(renderer->getWMORenderer()); - LOG_INFO("TransportManager connected to WMORenderer for online mode"); - } + // NOTE: TransportManager renderer connection moved to after initializeRenderers (later in this function) // Connect WMORenderer to M2Renderer (for hierarchical transforms: doodads following WMO parents) if (renderer->getWMORenderer() && renderer->getM2Renderer()) { @@ -3931,9 +4004,18 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float renderer->getCameraController()->reset(); } - // Set up test transport (development feature) + // Test transport disabled — real transports come from server via UPDATEFLAG_TRANSPORT showProgress("Finalizing world...", 0.94f); - setupTestTransport(); + // setupTestTransport(); + + // Connect TransportManager to renderers (must happen AFTER initializeRenderers) + if (gameHandler && gameHandler->getTransportManager()) { + auto* tm = gameHandler->getTransportManager(); + if (renderer->getWMORenderer()) tm->setWMORenderer(renderer->getWMORenderer()); + if (renderer->getM2Renderer()) tm->setM2Renderer(renderer->getM2Renderer()); + LOG_WARNING("TransportManager connected: wmoR=", (renderer->getWMORenderer() ? "yes" : "NULL"), + " m2R=", (renderer->getM2Renderer() ? "yes" : "NULL")); + } // Set up NPC animation callbacks (for online creatures) showProgress("Preparing creatures...", 0.97f); @@ -6368,6 +6450,10 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); + } else if (displayId == 3831) { + // Deeprun Tram car + modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; + LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); } } @@ -6508,7 +6594,12 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t // Transport GameObjects are not always named "transport" in their WMO path // (e.g. elevators/lifts). If the server marks it as a transport, always // notify so TransportManager can animate/carry passengers. - if (gameHandler && gameHandler->isTransportGuid(guid)) { + bool isTG = gameHandler && gameHandler->isTransportGuid(guid); + LOG_WARNING("WMO GO spawned: guid=0x", std::hex, guid, std::dec, + " entry=", entry, " displayId=", displayId, + " isTransport=", isTG, + " pos=(", x, ", ", y, ", ", z, ")"); + if (isTG) { gameHandler->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); } @@ -6572,18 +6663,27 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t return; } - // Freeze animation for static gameobjects, but let portals/effects animate + // Freeze animation for static gameobjects, but let portals/effects/transports animate + bool isTransportGO = gameHandler && gameHandler->isTransportGuid(guid); std::string lowerPath = modelPath; std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), ::tolower); bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || lowerPath.find("instancenewportal") != std::string::npos || lowerPath.find("portalfx") != std::string::npos || lowerPath.find("spellportal") != std::string::npos); - if (!isAnimatedEffect) { + if (!isAnimatedEffect && !isTransportGO) { m2Renderer->setInstanceAnimationFrozen(instanceId, true); } gameObjectInstances_[guid] = {modelId, instanceId, false}; + + // Notify transport system for M2 transports (e.g. Deeprun Tram cars) + if (gameHandler && gameHandler->isTransportGuid(guid)) { + LOG_WARNING("M2 transport spawned: guid=0x", std::hex, guid, std::dec, + " entry=", entry, " displayId=", displayId, + " instanceId=", instanceId); + gameHandler->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); + } } LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c8dba11f..e80e727f 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -4936,10 +4936,17 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); } else { - if (playerTransportGuid_ != 0) { - LOG_INFO("Player left transport"); + // Don't clear client-side M2 transport boarding (trams) — + // the server doesn't know about client-detected transport attachment. + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport"); + clearPlayerTransport(); } - clearPlayerTransport(); } } @@ -5173,9 +5180,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { queryGameObjectInfo(itEntry->second, block.guid); } // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) + LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), " displayId=", go->getDisplayId(), + " updateFlags=0x", std::hex, block.updateFlags, std::dec, + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); if (block.updateFlags & 0x0002) { transportGuids_.insert(block.guid); - LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, " entry=", go->getEntry(), " displayId=", go->getDisplayId(), " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); @@ -5691,7 +5702,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { movementInfo.x = pos.x; movementInfo.y = pos.y; movementInfo.z = pos.z; - if (playerTransportGuid_ != 0) { + // Don't clear client-side M2 transport boarding + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { LOG_INFO("Player left transport (MOVEMENT)"); clearPlayerTransport(); } diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index f134b5c0..955f8eaa 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -1,5 +1,6 @@ #include "game/transport_manager.hpp" #include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" #include "core/coordinates.hpp" #include "core/logger.hpp" #include "pipeline/dbc_loader.hpp" @@ -80,10 +81,11 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, transport.localClockMs = 0; transport.hasServerClock = false; transport.serverClockOffsetMs = 0; - // Default is server-authoritative movement. - // Exception: elevator-style transports (z-only DBC paths) often do not stream continuous - // movement updates from the server, but the client is expected to animate them. - transport.useClientAnimation = (path.fromDBC && path.zOnly && path.durationMs > 0); + // Start with client-side animation for all DBC paths with real movement. + // 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.clientAnimationReverse = false; transport.serverYaw = 0.0f; transport.hasServerYaw = false; @@ -98,16 +100,19 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, if (transport.useClientAnimation && path.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.0f) % path.durationMs; - LOG_INFO("TransportManager: Enabled client animation for z-only transport 0x", + LOG_INFO("TransportManager: Enabled client animation for transport 0x", std::hex, guid, std::dec, " path=", pathId, - " durationMs=", path.durationMs, " seedMs=", transport.localClockMs); + " durationMs=", path.durationMs, " seedMs=", transport.localClockMs, + (path.worldCoords ? " [worldCoords]" : (path.zOnly ? " [z-only]" : ""))); } updateTransformMatrices(transport); // CRITICAL: Update WMO renderer with initial transform - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } transports_[guid] = transport; @@ -140,6 +145,14 @@ glm::vec3 TransportManager::getPlayerWorldPosition(uint64_t transportGuid, const return localOffset; // Fallback } + if (transport->isM2) { + // M2 transports (trams): localOffset is a canonical world-space delta + // from the transport's canonical position. Just add directly. + return transport->position + localOffset; + } + + // WMO transports (ships): localOffset is in transport-local space, + // use the render-space transform matrix. glm::vec4 localPos(localOffset, 1.0f); glm::vec4 worldPos = transport->transform * localPos; return glm::vec3(worldPos); @@ -284,14 +297,17 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float 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. - if (transport.useClientAnimation && transport.serverUpdateCount <= 1) { - constexpr float kMinFallbackZOffset = -2.0f; - pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset); - } - if (!transport.useClientAnimation && !transport.hasServerClock) { - constexpr float kMinFallbackZOffset = -2.0f; - constexpr float kMaxFallbackZOffset = 8.0f; - pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset); + // Skip Z clamping for world-coordinate paths (TaxiPathNode) where values are absolute positions. + if (!path.worldCoords) { + if (transport.useClientAnimation && transport.serverUpdateCount <= 1) { + constexpr float kMinFallbackZOffset = -2.0f; + pathOffset.z = glm::max(pathOffset.z, kMinFallbackZOffset); + } + if (!transport.useClientAnimation && !transport.hasServerClock) { + constexpr float kMinFallbackZOffset = -2.0f; + constexpr float kMaxFallbackZOffset = 8.0f; + pathOffset.z = glm::clamp(pathOffset.z, kMinFallbackZOffset, kMaxFallbackZOffset); + } } transport.position = transport.basePosition + pathOffset; @@ -307,24 +323,20 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float updateTransformMatrices(transport); // Update WMO instance position - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } - // Debug logging every 120 frames (~2 seconds at 60fps) + // Debug logging every 600 frames (~10 seconds at 60fps) static int debugFrameCount = 0; - if (debugFrameCount++ % 120 == 0) { - // Log canonical position AND render position to check coordinate conversion - glm::vec3 renderPos = core::coords::canonicalToRender(transport.position); + if (debugFrameCount++ % 600 == 0) { LOG_DEBUG("Transport 0x", std::hex, transport.guid, std::dec, " pathTime=", pathTimeMs, "ms / ", path.durationMs, "ms", - " canonicalPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", - " renderPos=(", renderPos.x, ", ", renderPos.y, ", ", renderPos.z, ")", - " basePos=(", transport.basePosition.x, ", ", transport.basePosition.y, ", ", transport.basePosition.z, ")", - " pathOffset=(", pathOffset.x, ", ", pathOffset.y, ", ", pathOffset.z, ")", + " pos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")", " mode=", (transport.useClientAnimation ? "client" : "server"), - " hasServerClock=", transport.hasServerClock, - " offset=", transport.serverClockOffsetMs, "ms"); + " isM2=", transport.isM2); } } @@ -561,12 +573,24 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos // Track server updates transport->serverUpdateCount++; transport->lastServerUpdate = elapsedTime_; - // Server updates take precedence for moving XY transports, but z-only elevators should - // remain client-animated (server may only send sparse state updates). - if (!isZOnlyPath) { - transport->useClientAnimation = false; - } else { + // 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) { + float posDelta = glm::length(position - transport->position); + if (posDelta > 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 (posDelta=", posDelta, ")"); + } + // 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; @@ -576,8 +600,10 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos transport->position = position; transport->rotation = glm::angleAxis(orientation, glm::vec3(0.0f, 0.0f, 1.0f)); updateTransformMatrices(*transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + if (transport->isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); } return; } @@ -846,12 +872,23 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg std::vector timedPoints; timedPoints.reserve(sortedWaypoints.size() + 1); // +1 for wrap point - // Log first few waypoints for transport 2074 to see conversion + // 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_WARNING("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 uses server coordinates - convert to canonical - glm::vec3 canonical = core::coords::serverToCanonical(pos); + // 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) && @@ -896,7 +933,8 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg // Add duplicate first point at end with wrap duration // This makes the wrap segment (last → first) have proper duration - glm::vec3 firstCanonical = core::coords::serverToCanonical(sortedWaypoints.front().second); + 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;