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
This commit is contained in:
Kelsi 2026-03-06 23:01:11 -08:00
parent e001aaa2b6
commit f4c115ade9
5 changed files with 233 additions and 70 deletions

View file

@ -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_;

View file

@ -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<uint64_t, ActiveTransport>& 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<uint32_t, TransportPath> paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc)
std::unordered_map<uint32_t, TransportPath> taxiPaths_; // Indexed by TaxiPath.dbc ID (world-coord paths for MO_TRANSPORT)
rendering::WMORenderer* wmoRenderer_ = nullptr;
rendering::M2Renderer* m2Renderer_ = nullptr;
bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction
float elapsedTime_ = 0.0f; // Total elapsed time (seconds)
};

View file

@ -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<glm::vec3> 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,

View file

@ -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();
}

View file

@ -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<uint32_t>(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<TimedPoint> 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;