Add MCLQ water, TaxiPathNode transports, and vanilla M2 particles

- Parse MCLQ sub-chunks in vanilla ADTs for water rendering (WotLK uses MH2O)
- Load TaxiPathNode.dbc for MO_TRANSPORT world-coordinate paths (vanilla boats)
- Parse data[] from SMSG_GAMEOBJECT_QUERY_RESPONSE (taxiPathId for transports)
- Support vanilla M2 particle emitters (504-byte struct, different from WotLK 476)
- Add character preview texture diagnostic logging
- Fix disconnect handling on character screen (show error only when no chars)
This commit is contained in:
Kelsi 2026-02-14 20:20:43 -08:00
parent a01bcb3a00
commit b76527c2f7
14 changed files with 556 additions and 55 deletions

View file

@ -5392,7 +5392,9 @@ void GameHandler::handleCreatureQueryResponse(network::Packet& packet) {
void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) {
GameObjectQueryResponseData data;
if (!GameObjectQueryResponseParser::parse(packet, data)) return;
bool ok = packetParsers_ ? packetParsers_->parseGameObjectQueryResponse(packet, data)
: GameObjectQueryResponseParser::parse(packet, data);
if (!ok) return;
pendingGameObjectQueries_.erase(data.entry);
@ -5407,6 +5409,19 @@ void GameHandler::handleGameObjectQueryResponse(network::Packet& packet) {
}
}
}
// MO_TRANSPORT (type 15): assign TaxiPathNode path if available
if (data.type == 15 && data.hasData && data.data[0] != 0 && transportManager_) {
uint32_t taxiPathId = data.data[0];
if (transportManager_->hasTaxiPath(taxiPathId)) {
if (transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) {
LOG_INFO("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId);
}
} else {
LOG_INFO("MO_TRANSPORT entry=", data.entry, " taxiPathId=", taxiPathId,
" not found in TaxiPathNode.dbc");
}
}
}
}

View file

@ -580,6 +580,49 @@ bool ClassicPacketParsers::parseGuildQueryResponse(network::Packet& packet, Guil
return true;
}
// ============================================================================
// GameObject Query — Classic has no extra strings before data[]
// WotLK has iconName + castBarCaption + unk1 between names and data[].
// Vanilla: entry, type, displayId, name[4], data[24]
// ============================================================================
bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) {
data.entry = packet.readUInt32();
// High bit set means gameobject not found
if (data.entry & 0x80000000) {
data.entry &= ~0x80000000;
data.name = "";
return true;
}
data.type = packet.readUInt32();
data.displayId = packet.readUInt32();
// 4 name strings
data.name = packet.readString();
packet.readString();
packet.readString();
packet.readString();
// Classic: data[24] comes immediately after names (no extra strings)
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining >= 24 * 4) {
for (int i = 0; i < 24; i++) {
data.data[i] = packet.readUInt32();
}
data.hasData = true;
}
if (data.type == 15) { // MO_TRANSPORT
LOG_INFO("Classic GO query: MO_TRANSPORT entry=", data.entry,
" name=\"", data.name, "\" displayId=", data.displayId,
" taxiPathId=", data.data[0], " moveSpeed=", data.data[1]);
} else {
LOG_DEBUG("Classic GO query: ", data.name, " type=", data.type, " entry=", data.entry);
}
return true;
}
// ============================================================================
// Gossip — Classic has no menuId, and quest items lack questFlags + isRepeatable
// ============================================================================

View file

@ -27,7 +27,7 @@ void TransportManager::update(float deltaTime) {
}
}
void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos) {
void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry) {
auto pathIt = paths_.find(pathId);
if (pathIt == paths_.end()) {
std::cerr << "TransportManager: Path " << pathId << " not found for transport " << guid << std::endl;
@ -44,6 +44,7 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId,
transport.guid = guid;
transport.wmoInstanceId = wmoInstanceId;
transport.pathId = pathId;
transport.entry = entry;
transport.allowBootstrapVelocity = false;
// CRITICAL: Set basePosition from spawn position and t=0 offset
@ -52,6 +53,10 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId,
// Stationary transport - no path animation
transport.basePosition = spawnWorldPos;
transport.position = spawnWorldPos;
} else if (path.worldCoords) {
// World-coordinate path (TaxiPathNode) - points are absolute world positions
transport.basePosition = glm::vec3(0.0f);
transport.position = evalTimedCatmullRom(path, 0);
} else {
// Moving transport - infer base from first path offset
glm::vec3 offset0 = evalTimedCatmullRom(path, 0);
@ -542,6 +547,16 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos
auto pathIt = paths_.find(transport->pathId);
const bool hasPath = (pathIt != paths_.end());
const bool isZOnlyPath = (hasPath && pathIt->second.fromDBC && pathIt->second.zOnly && pathIt->second.durationMs > 0);
const bool isWorldCoordPath = (hasPath && pathIt->second.worldCoords && pathIt->second.durationMs > 0);
// Don't let (0,0,0) server updates override a TaxiPathNode world-coordinate path
if (isWorldCoordPath && glm::length(position) < 1.0f) {
transport->serverUpdateCount++;
transport->lastServerUpdate = elapsedTime_;
transport->serverYaw = orientation;
transport->hasServerYaw = true;
return;
}
// Track server updates
transport->serverUpdateCount++;
@ -940,6 +955,181 @@ bool TransportManager::loadTransportAnimationDBC(pipeline::AssetManager* assetMg
return pathsLoaded > 0;
}
bool TransportManager::loadTaxiPathNodeDBC(pipeline::AssetManager* assetMgr) {
LOG_INFO("Loading TaxiPathNode.dbc...");
if (!assetMgr) {
LOG_ERROR("AssetManager is null");
return false;
}
auto dbcData = assetMgr->readFile("DBFilesClient\\TaxiPathNode.dbc");
if (dbcData.empty()) {
LOG_WARNING("TaxiPathNode.dbc not found - MO_TRANSPORT will use fallback paths");
return false;
}
pipeline::DBCFile dbc;
if (!dbc.load(dbcData)) {
LOG_ERROR("Failed to parse TaxiPathNode.dbc");
return false;
}
LOG_INFO("TaxiPathNode.dbc: ", dbc.getRecordCount(), " records, ",
dbc.getFieldCount(), " fields per record");
// Group nodes by PathID, storing (NodeIndex, MapID, X, Y, Z)
struct TaxiNode {
uint32_t nodeIndex;
uint32_t mapId;
float x, y, z;
};
std::map<uint32_t, std::vector<TaxiNode>> nodesByPath;
for (uint32_t i = 0; i < dbc.getRecordCount(); i++) {
uint32_t pathId = dbc.getUInt32(i, 1); // PathID
uint32_t nodeIdx = dbc.getUInt32(i, 2); // NodeIndex
uint32_t mapId = dbc.getUInt32(i, 3); // MapID
float posX = dbc.getFloat(i, 4); // X (server coords)
float posY = dbc.getFloat(i, 5); // Y (server coords)
float posZ = dbc.getFloat(i, 6); // Z (server coords)
nodesByPath[pathId].push_back({nodeIdx, mapId, posX, posY, posZ});
}
// Build world-coordinate transport paths
int pathsLoaded = 0;
for (auto& [pathId, nodes] : nodesByPath) {
if (nodes.size() < 2) continue;
// Sort by NodeIndex
std::sort(nodes.begin(), nodes.end(),
[](const TaxiNode& a, const TaxiNode& b) { return a.nodeIndex < b.nodeIndex; });
// Skip flight-master paths (nodes on different maps are map teleports)
// Transport paths stay on the same map
bool sameMap = true;
uint32_t firstMap = nodes[0].mapId;
for (const auto& node : nodes) {
if (node.mapId != firstMap) { sameMap = false; break; }
}
// Calculate total path distance to identify transport routes (long water crossings)
float totalDist = 0.0f;
for (size_t i = 1; i < nodes.size(); i++) {
float dx = nodes[i].x - nodes[i-1].x;
float dy = nodes[i].y - nodes[i-1].y;
float dz = nodes[i].z - nodes[i-1].z;
totalDist += std::sqrt(dx*dx + dy*dy + dz*dz);
}
// Transport routes are typically >500 units long and stay on same map
// Flight paths can also be long, but we'll store all same-map paths
// and let the caller choose the right one by pathId
if (!sameMap) continue;
// Build timed points using distance-based timing (28 units/sec default boat speed)
const float transportSpeed = 28.0f; // units per second
std::vector<TimedPoint> timedPoints;
timedPoints.reserve(nodes.size() + 1);
uint32_t cumulativeMs = 0;
for (size_t i = 0; i < nodes.size(); i++) {
// Convert server coords to canonical
glm::vec3 serverPos(nodes[i].x, nodes[i].y, nodes[i].z);
glm::vec3 canonical = core::coords::serverToCanonical(serverPos);
timedPoints.push_back({cumulativeMs, canonical});
if (i + 1 < nodes.size()) {
float dx = nodes[i+1].x - nodes[i].x;
float dy = nodes[i+1].y - nodes[i].y;
float dz = nodes[i+1].z - nodes[i].z;
float segDist = std::sqrt(dx*dx + dy*dy + dz*dz);
uint32_t segMs = static_cast<uint32_t>((segDist / transportSpeed) * 1000.0f);
if (segMs < 100) segMs = 100; // Minimum 100ms per segment
cumulativeMs += segMs;
}
}
// Add wrap point (return to start) for looping
float wrapDx = nodes.front().x - nodes.back().x;
float wrapDy = nodes.front().y - nodes.back().y;
float wrapDz = nodes.front().z - nodes.back().z;
float wrapDist = std::sqrt(wrapDx*wrapDx + wrapDy*wrapDy + wrapDz*wrapDz);
uint32_t wrapMs = static_cast<uint32_t>((wrapDist / transportSpeed) * 1000.0f);
if (wrapMs < 100) wrapMs = 100;
cumulativeMs += wrapMs;
timedPoints.push_back({cumulativeMs, timedPoints[0].pos});
TransportPath path;
path.pathId = pathId;
path.points = timedPoints;
path.looping = false; // Explicit wrap point added
path.durationMs = cumulativeMs;
path.zOnly = false;
path.fromDBC = true;
path.worldCoords = true; // TaxiPathNode uses absolute world coordinates
taxiPaths_[pathId] = path;
pathsLoaded++;
}
LOG_INFO("Loaded ", pathsLoaded, " TaxiPathNode transport paths (", nodesByPath.size(), " total taxi paths)");
return pathsLoaded > 0;
}
bool TransportManager::hasTaxiPath(uint32_t taxiPathId) const {
return taxiPaths_.find(taxiPathId) != taxiPaths_.end();
}
bool TransportManager::assignTaxiPathToTransport(uint32_t entry, uint32_t taxiPathId) {
auto taxiIt = taxiPaths_.find(taxiPathId);
if (taxiIt == taxiPaths_.end()) {
LOG_WARNING("No TaxiPathNode path for taxiPathId=", taxiPathId);
return false;
}
// Find transport(s) with matching entry that are at (0,0,0)
for (auto& [guid, transport] : transports_) {
if (transport.entry != entry) continue;
if (glm::length(transport.position) > 1.0f) continue; // Already has real position
// Copy the taxi path into the main paths_ map (indexed by entry for this transport)
TransportPath path = taxiIt->second;
path.pathId = entry; // Index by GO entry
paths_[entry] = path;
// Update transport to use the new path
transport.pathId = entry;
transport.basePosition = glm::vec3(0.0f); // World-coordinate path, no base offset
if (!path.points.empty()) {
transport.position = evalTimedCatmullRom(path, 0);
}
transport.useClientAnimation = true; // Server won't send position updates
// Seed local clock to a deterministic phase
if (path.durationMs > 0) {
transport.localClockMs = static_cast<uint32_t>(elapsedTime_ * 1000.0f) % path.durationMs;
}
updateTransformMatrices(transport);
if (wmoRenderer_) {
wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform);
}
LOG_INFO("Assigned TaxiPathNode path to transport 0x", std::hex, guid, std::dec,
" entry=", entry, " taxiPathId=", taxiPathId,
" waypoints=", path.points.size(),
" duration=", path.durationMs, "ms",
" startPos=(", transport.position.x, ", ", transport.position.y, ", ", transport.position.z, ")");
return true;
}
LOG_DEBUG("No transport at (0,0,0) found for entry=", entry, " taxiPathId=", taxiPathId);
return false;
}
bool TransportManager::hasPathForEntry(uint32_t entry) const {
auto it = paths_.find(entry);
return it != paths_.end() && it->second.fromDBC;

View file

@ -1962,7 +1962,7 @@ bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQue
}
data.type = packet.readUInt32(); // GameObjectType
/*uint32_t displayId =*/ packet.readUInt32();
data.displayId = packet.readUInt32();
// 4 name strings (only first is usually populated)
data.name = packet.readString();
// name2, name3, name4
@ -1970,6 +1970,20 @@ bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQue
packet.readString();
packet.readString();
// WotLK: 3 extra strings before data[] (iconName, castBarCaption, unk1)
packet.readString(); // iconName
packet.readString(); // castBarCaption
packet.readString(); // unk1
// Read 24 type-specific data fields
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining >= 24 * 4) {
for (int i = 0; i < 24; i++) {
data.data[i] = packet.readUInt32();
}
data.hasData = true;
}
LOG_DEBUG("GameObject query response: ", data.name, " (type=", data.type, " entry=", data.entry, ")");
return true;
}