Add transport registration to movement packets (WIP - awaiting server MOVEMENT updates)

- Added transport fields to MovementInfo struct (transportGuid, transportX/Y/Z/O, transportTime)
- Updated MovementPacket::build() to serialize transport data when ONTRANSPORT flag set
- Modified GameHandler::sendMovement() to include transport info when player on transport
- Fixed coordinate conversion for transport offsets (server↔canonical)
- Added transport tracking in both CREATE_OBJECT and MOVEMENT update handlers
- Connected M2Renderer to WMORenderer for hierarchical doodad transforms
- Server-authoritative transport movement (no client-side animation)

Issue: Server not sending MOVEMENT updates for transports, so they remain stationary.
Transports register successfully but don't animate without server position updates.
This commit is contained in:
Kelsi 2026-02-11 02:23:37 -08:00
parent f3f3b62880
commit 55a40fc3aa
8 changed files with 425 additions and 60 deletions

View file

@ -27,6 +27,7 @@ struct TransportPath {
std::vector<TimedPoint> points; // Time-indexed waypoints (includes duplicate first point at end for wrap)
bool looping; // Set to false after adding explicit wrap point
uint32_t durationMs; // Total loop duration in ms (includes wrap segment if added)
bool zOnly; // True if path only has Z movement (elevator/bobbing), false if real XY travel
};
struct ActiveTransport {
@ -100,7 +101,7 @@ private:
std::unordered_map<uint64_t, ActiveTransport> transports_;
std::unordered_map<uint32_t, TransportPath> 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)
};

View file

@ -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<uint32_t>(flag)) != 0;
}

View file

@ -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<DoodadTemplate>* 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<DoodadTemplate> doodadTemplates;
// Texture handles for this model (indexed by texture path order)
std::vector<GLuint> 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_;

View file

@ -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<char>(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<uint32_t>(std::hash<std::string>{}(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<uint8_t> 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<glm::vec3> harborPath = {

View file

@ -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<uint32_t>(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<uint32_t>(MovementFlags::ONTRANSPORT);
movementInfo.transportGuid = 0;
}
LOG_DEBUG("Sending movement packet: opcode=0x", std::hex,
static_cast<uint16_t>(opcode), std::dec);
static_cast<uint16_t>(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

View file

@ -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<glm:
TransportPath path;
path.pathId = pathId;
path.looping = looping;
path.zOnly = 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 (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, ")");

View file

@ -581,6 +581,34 @@ network::Packet MovementPacket::build(Opcode opcode, const MovementInfo& info, u
packet.writeBytes(reinterpret_cast<const uint8_t*>(&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<uint8_t>((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<const uint8_t*>(&info.transportX), sizeof(float));
packet.writeBytes(reinterpret_cast<const uint8_t*>(&info.transportY), sizeof(float));
packet.writeBytes(reinterpret_cast<const uint8_t*>(&info.transportZ), sizeof(float));
packet.writeBytes(reinterpret_cast<const uint8_t*>(&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);
}

View file

@ -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::DoodadTemplate>* 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) {