From bf31da8c139aab20686dc3d5c35aa0abe34e5949 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sat, 14 Feb 2026 20:20:43 -0800 Subject: [PATCH] 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) --- include/game/game_handler.hpp | 4 + include/game/packet_parsers.hpp | 8 ++ include/game/transport_manager.hpp | 17 ++- include/game/world_packets.hpp | 5 +- include/pipeline/adt_loader.hpp | 2 + src/core/application.cpp | 21 ++- src/game/game_handler.cpp | 17 ++- src/game/packet_parsers_classic.cpp | 43 +++++++ src/game/transport_manager.cpp | 192 +++++++++++++++++++++++++++- src/game/world_packets.cpp | 16 ++- src/pipeline/adt_loader.cpp | 105 +++++++++++++++ src/pipeline/m2_loader.cpp | 141 +++++++++++++++----- src/rendering/character_preview.cpp | 9 ++ src/ui/character_screen.cpp | 31 +++-- 14 files changed, 556 insertions(+), 55 deletions(-) diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 9603b548..2738dab1 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -389,6 +389,10 @@ public: void queryPlayerName(uint64_t guid); void queryCreatureInfo(uint32_t entry, uint64_t guid); void queryGameObjectInfo(uint32_t entry, uint64_t guid); + const GameObjectQueryResponseData* getCachedGameObjectInfo(uint32_t entry) const { + auto it = gameObjectInfoCache_.find(entry); + return (it != gameObjectInfoCache_.end()) ? &it->second : nullptr; + } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 3a0d46af..d793d9d5 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -117,6 +117,13 @@ public: return NameQueryResponseParser::parse(packet, data); } + // --- GameObject Query --- + + /** Parse SMSG_GAMEOBJECT_QUERY_RESPONSE */ + virtual bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) { + return GameObjectQueryResponseParser::parse(packet, data); + } + // --- Gossip --- /** Parse SMSG_GOSSIP_MESSAGE */ @@ -233,6 +240,7 @@ public: network::Packet buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) override; bool parseCastFailed(network::Packet& packet, CastFailedData& data) override; bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; + bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override; diff --git a/include/game/transport_manager.hpp b/include/game/transport_manager.hpp index f4ab5ef8..d3b80729 100644 --- a/include/game/transport_manager.hpp +++ b/include/game/transport_manager.hpp @@ -29,12 +29,14 @@ struct TransportPath { 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 bool fromDBC; // True if loaded from TransportAnimation.dbc, false for runtime fallback/custom paths + bool worldCoords = false; // True if points are absolute world coords (TaxiPathNode), not local offsets }; struct ActiveTransport { uint64_t guid; // Entity GUID uint32_t wmoInstanceId; // WMO renderer instance ID uint32_t pathId; // Current path + uint32_t entry = 0; // GameObject entry (for MO_TRANSPORT path updates) glm::vec3 basePosition; // Spawn position (base offset for path) glm::vec3 position; // Current world position glm::quat rotation; // Current world rotation @@ -79,7 +81,7 @@ public: void setWMORenderer(rendering::WMORenderer* renderer) { wmoRenderer_ = renderer; } void update(float deltaTime); - void registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos); + 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); @@ -92,6 +94,16 @@ public: // Load transport paths from TransportAnimation.dbc bool loadTransportAnimationDBC(pipeline::AssetManager* assetMgr); + // Load transport paths from TaxiPathNode.dbc (world-coordinate paths for MO_TRANSPORT) + bool loadTaxiPathNodeDBC(pipeline::AssetManager* assetMgr); + + // Check if a TaxiPathNode path exists for a given taxiPathId + bool hasTaxiPath(uint32_t taxiPathId) const; + + // Assign a TaxiPathNode path to an existing transport (called when GO query response arrives) + // Returns true if the transport was updated + bool assignTaxiPathToTransport(uint32_t entry, uint32_t taxiPathId); + // Check if a path exists for a given GameObject entry bool hasPathForEntry(uint32_t entry) const; // Check if a path has meaningful XY travel (used to reject near-stationary false positives). @@ -126,7 +138,8 @@ private: void updateTransformMatrices(ActiveTransport& transport); std::unordered_map transports_; - std::unordered_map paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc) + 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; bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction float elapsedTime_ = 0.0f; // Total elapsed time (seconds) diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 8e70b36c..9c28f43a 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1356,7 +1356,10 @@ public: struct GameObjectQueryResponseData { uint32_t entry = 0; std::string name; - uint32_t type = 0; // GameObjectType (e.g. 3=chest, 2=questgiver) + uint32_t type = 0; // GameObjectType (e.g. 3=chest, 2=questgiver, 15=MO_TRANSPORT) + uint32_t displayId = 0; + uint32_t data[24] = {}; // Type-specific data fields (e.g. data[0]=taxiPathId for MO_TRANSPORT) + bool hasData = false; // Whether data[] was parsed bool isValid() const { return entry != 0 && !name.empty(); } }; diff --git a/include/pipeline/adt_loader.hpp b/include/pipeline/adt_loader.hpp index 75ca5f53..d107b46c 100644 --- a/include/pipeline/adt_loader.hpp +++ b/include/pipeline/adt_loader.hpp @@ -206,6 +206,8 @@ private: static void parseMCLY(const uint8_t* data, size_t size, MapChunk& chunk); static void parseMCAL(const uint8_t* data, size_t size, MapChunk& chunk); static void parseMH2O(const uint8_t* data, size_t size, ADTTerrain& terrain); + static void parseMCLQ(const uint8_t* data, size_t size, int chunkIndex, + uint32_t mcnkFlags, ADTTerrain& terrain); }; } // namespace pipeline diff --git a/src/core/application.cpp b/src/core/application.cpp index 6fd873de..a5f788b1 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -205,9 +205,10 @@ bool Application::initialize() { renderer->getCharacterRenderer()->setAssetManager(assetManager.get()); } - // Load transport paths from TransportAnimation.dbc + // Load transport paths from TransportAnimation.dbc and TaxiPathNode.dbc if (gameHandler && gameHandler->getTransportManager()) { gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get()); + gameHandler->getTransportManager()->loadTaxiPathNodeDBC(assetManager.get()); } // Initialize HD texture packs @@ -1487,7 +1488,7 @@ void Application::setupUICallbacks() { } // Register the transport with spawn position (prevents rendering at origin until server update) - transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos); + transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); // Server-authoritative movement - set initial position from spawn data glm::vec3 canonicalPos(x, y, z); @@ -1502,6 +1503,20 @@ void Application::setupUICallbacks() { " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ") orientation=", pending.orientation); pendingTransportMoves_.erase(pendingIt); } + + // For MO_TRANSPORT at (0,0,0): check if GO data is already cached with a taxiPathId + if (glm::length(canonicalSpawnPos) < 1.0f && gameHandler) { + auto goData = gameHandler->getCachedGameObjectInfo(entry); + if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { + uint32_t taxiPathId = goData->data[0]; + if (transportManager->hasTaxiPath(taxiPathId)) { + transportManager->assignTaxiPathToTransport(entry, taxiPathId); + LOG_INFO("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", entry, + " taxiPathId=", taxiPathId); + } + } + } + if (auto* tr = transportManager->getTransport(guid); tr) { LOG_INFO("Transport registered: guid=0x", std::hex, guid, std::dec, " entry=", entry, " displayId=", displayId, @@ -1598,7 +1613,7 @@ void Application::setupUICallbacks() { " displayId=", displayId, " wmoInstance=", wmoInstanceId); } - transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos); + transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); } else { pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; LOG_WARNING("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 43db7a92..72b1bffd 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -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"); + } + } } } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 23326398..458b3fe9 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -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 // ============================================================================ diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 9c3907f9..f134b5c0 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -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> 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 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((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((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(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; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8dd86db1..91588488 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -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; } diff --git a/src/pipeline/adt_loader.cpp b/src/pipeline/adt_loader.cpp index 6368bf2f..fff4d07c 100644 --- a/src/pipeline/adt_loader.cpp +++ b/src/pipeline/adt_loader.cpp @@ -374,6 +374,17 @@ void ADTLoader::parseMCNK(const uint8_t* data, size_t size, int chunkIndex, ADTT uint32_t skip = (possibleMagic == MCAL) ? 8 : 0; parseMCAL(data + ofsAlpha + skip, sizeAlpha - skip, chunk); } + + // Liquid (MCLQ) - vanilla/TBC per-chunk water (no MH2O in these expansions) + // ofsLiquid at MCNK header offset 0x60, sizeLiquid at 0x64 + uint32_t ofsLiquid = readUInt32(data, 0x60); + uint32_t sizeLiquid = readUInt32(data, 0x64); + if (ofsLiquid > 0 && sizeLiquid > 8 && ofsLiquid + sizeLiquid <= size) { + uint32_t possibleMagic = readUInt32(data, ofsLiquid); + uint32_t skip = (possibleMagic == MCLQ) ? 8 : 0; + parseMCLQ(data + ofsLiquid + skip, sizeLiquid - skip, + chunkIndex, chunk.flags, terrain); + } } void ADTLoader::parseMCVT(const uint8_t* data, size_t size, MapChunk& chunk) { @@ -453,6 +464,100 @@ void ADTLoader::parseMCAL(const uint8_t* data, size_t size, MapChunk& chunk) { std::memcpy(chunk.alphaMap.data(), data, size); } +void ADTLoader::parseMCLQ(const uint8_t* data, size_t size, int chunkIndex, + uint32_t mcnkFlags, ADTTerrain& terrain) { + // MCLQ: Vanilla/TBC per-chunk liquid data (inside MCNK) + // Layout: + // float minHeight, maxHeight (8 bytes) + // SLiquidVertex[9*9] (81 * 8 = 648 bytes) + // water: uint8 depth, flow0, flow1, filler, float height + // magma: uint16 s, uint16 t, float height + // uint8 tiles[8*8] (64 bytes) + // Total minimum: 720 bytes + + if (size < 720) { + return; // Not enough data for a valid MCLQ + } + + float minHeight = readFloat(data, 0); + float maxHeight = readFloat(data, 4); + + // Determine liquid type from MCNK flags + // 0x04 = has liquid (river/lake), 0x08 = ocean, 0x10 = magma, 0x20 = slime + uint16_t liquidType = 0; // water + if (mcnkFlags & 0x08) liquidType = 1; // ocean + else if (mcnkFlags & 0x10) liquidType = 2; // magma + else if (mcnkFlags & 0x20) liquidType = 3; // slime + + // Read 9x9 height values (skip depth/flow bytes, just read the float height) + const uint8_t* vertData = data + 8; + std::vector heights(81); + for (int i = 0; i < 81; i++) { + heights[i] = readFloat(vertData, i * 8 + 4); // float at offset 4 within each 8-byte vertex + } + + // Read 8x8 tile flags + const uint8_t* tileData = data + 8 + 648; + std::vector tileMask(64); + bool anyVisible = false; + for (int i = 0; i < 64; i++) { + uint8_t tileFlag = tileData[i]; + // Bit 0x0F = liquid type, bit 0x40 = fatigue, bit 0x80 = hidden + // A tile is visible if NOT hidden (0x80 not set) and type is non-zero or has base flag + bool hidden = (tileFlag & 0x80) != 0; + tileMask[i] = hidden ? 0 : 1; + if (!hidden) anyVisible = true; + } + + if (!anyVisible) { + return; // All tiles hidden, no visible water + } + + // Validate heights - if all heights are 0 or unreasonable, skip + bool validHeights = false; + for (float h : heights) { + if (h != 0.0f && std::isfinite(h)) { + validHeights = true; + break; + } + } + // If heights are all zero, use maxHeight as flat water level + if (!validHeights) { + for (float& h : heights) h = maxHeight; + } + + // Build a WaterLayer matching the MH2O format + ADTTerrain::WaterLayer layer; + layer.liquidType = liquidType; + layer.flags = 0; + layer.minHeight = minHeight; + layer.maxHeight = maxHeight; + layer.x = 0; + layer.y = 0; + layer.width = 8; // 8 tiles = 9 vertices per axis + layer.height = 8; + layer.heights = std::move(heights); + layer.mask.resize(8); // 8 bytes = 64 bits for 8x8 tiles + for (int row = 0; row < 8; row++) { + uint8_t rowBits = 0; + for (int col = 0; col < 8; col++) { + if (tileMask[row * 8 + col]) { + rowBits |= (1 << col); + } + } + layer.mask[row] = rowBits; + } + + terrain.waterData[chunkIndex].layers.push_back(std::move(layer)); + + static int mclqLogCount = 0; + if (mclqLogCount < 5) { + LOG_INFO("MCLQ[", chunkIndex, "]: type=", liquidType, + " height=[", minHeight, ",", maxHeight, "]"); + mclqLogCount++; + } +} + void ADTLoader::parseMH2O(const uint8_t* data, size_t size, ADTTerrain& terrain) { // MH2O contains water/liquid data for all 256 map chunks // Structure: 256 SMLiquidChunk headers followed by instance data diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 55d8186b..0590ee44 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1050,26 +1050,34 @@ M2Model M2Loader::load(const std::vector& m2Data) { model.attachmentLookup = readArray(m2Data, header.ofsAttachmentLookup, header.nAttachmentLookup); } - // Parse particle emitters (WotLK M2ParticleOld: 0x1DC = 476 bytes per emitter) - // Skip for vanilla — emitter struct size differs - static constexpr uint32_t EMITTER_STRUCT_SIZE = 0x1DC; - if (header.version >= 264 && - header.nParticleEmitters > 0 && header.ofsParticleEmitters > 0 && - header.nParticleEmitters < 256 && - static_cast(header.ofsParticleEmitters) + - static_cast(header.nParticleEmitters) * EMITTER_STRUCT_SIZE <= m2Data.size()) { + // Parse particle emitters — struct size differs between versions: + // WotLK (version >= 264): M2ParticleOld = 0x1DC (476) bytes, M2TrackDisk (20 bytes), FBlocks + // Vanilla (version < 264): 0x1F8 (504) bytes, M2TrackDiskVanilla (28 bytes), static lifecycle arrays + if (header.nParticleEmitters > 0 && header.ofsParticleEmitters > 0 && + header.nParticleEmitters < 256) { - // Build sequence flags for parseAnimTrack + const bool isVanilla = (header.version < 264); + static constexpr uint32_t EMITTER_SIZE_WOTLK = 0x1DC; // 476 + static constexpr uint32_t EMITTER_SIZE_VANILLA = 0x1F8; // 504 + const uint32_t emitterSize = isVanilla ? EMITTER_SIZE_VANILLA : EMITTER_SIZE_WOTLK; + + if (static_cast(header.ofsParticleEmitters) + + static_cast(header.nParticleEmitters) * emitterSize <= m2Data.size()) { + + // Build sequence flags for parseAnimTrack (WotLK only) std::vector emSeqFlags; - emSeqFlags.reserve(model.sequences.size()); - for (const auto& seq : model.sequences) { - emSeqFlags.push_back(seq.flags); + if (!isVanilla) { + emSeqFlags.reserve(model.sequences.size()); + for (const auto& seq : model.sequences) { + emSeqFlags.push_back(seq.flags); + } } for (uint32_t ei = 0; ei < header.nParticleEmitters; ei++) { - uint32_t base = header.ofsParticleEmitters + ei * EMITTER_STRUCT_SIZE; + uint32_t base = header.ofsParticleEmitters + ei * emitterSize; M2ParticleEmitter em; + // Header fields (0x00-0x33) are the same for both versions em.particleId = readValue(m2Data, base + 0x00); em.flags = readValue(m2Data, base + 0x04); em.position.x = readValue(m2Data, base + 0x08); @@ -1085,32 +1093,97 @@ M2Model M2Loader::load(const std::vector& m2Data) { if (em.textureRows == 0) em.textureRows = 1; if (em.textureCols == 0) em.textureCols = 1; - // Parse animated tracks (M2TrackDisk at known offsets) - auto parseTrack = [&](uint32_t off, M2AnimationTrack& track) { - if (base + off + sizeof(M2TrackDisk) <= m2Data.size()) { - M2TrackDisk disk = readValue(m2Data, base + off); - parseAnimTrack(m2Data, disk, track, TrackType::FLOAT, emSeqFlags); - } - }; - parseTrack(0x34, em.emissionSpeed); - parseTrack(0x48, em.speedVariation); - parseTrack(0x5C, em.verticalRange); - parseTrack(0x70, em.horizontalRange); - parseTrack(0x84, em.gravity); - parseTrack(0x98, em.lifespan); - parseTrack(0xB0, em.emissionRate); - parseTrack(0xC8, em.emissionAreaLength); - parseTrack(0xDC, em.emissionAreaWidth); - parseTrack(0xF0, em.deceleration); + if (isVanilla) { + // Vanilla: 10 contiguous M2TrackDiskVanilla tracks (28 bytes each) at 0x34 + auto parseTrackV = [&](uint32_t off, M2AnimationTrack& track) { + if (base + off + sizeof(M2TrackDiskVanilla) <= m2Data.size()) { + M2TrackDiskVanilla disk = readValue(m2Data, base + off); + parseAnimTrackVanilla(m2Data, disk, track, TrackType::FLOAT); + } + }; + parseTrackV(0x34, em.emissionSpeed); // +28 = 0x50 + parseTrackV(0x50, em.speedVariation); // +28 = 0x6C + parseTrackV(0x6C, em.verticalRange); // +28 = 0x88 + parseTrackV(0x88, em.horizontalRange); // +28 = 0xA4 + parseTrackV(0xA4, em.gravity); // +28 = 0xC0 + parseTrackV(0xC0, em.lifespan); // +28 = 0xDC + parseTrackV(0xDC, em.emissionRate); // +28 = 0xF8 + parseTrackV(0xF8, em.emissionAreaLength); // +28 = 0x114 + parseTrackV(0x114, em.emissionAreaWidth); // +28 = 0x130 + parseTrackV(0x130, em.deceleration); // +28 = 0x14C - // Parse FBlocks (color, alpha, scale) — FBlocks are 16 bytes each - parseFBlock(m2Data, base + 0x104, em.particleColor, 0); - parseFBlock(m2Data, base + 0x114, em.particleAlpha, 1); - parseFBlock(m2Data, base + 0x124, em.particleScale, 2); + // Vanilla: NO FBlocks — color/alpha/scale are static inline values + // Layout (empirically confirmed from real vanilla M2 files): + // +0x14C: float midpoint (lifecycle split: 0→mid→1) + // +0x150: uint32 colorValues[3] (BGRA, A channel = opacity) + // +0x15C: float scaleValues[3] (1D particle scale) + float midpoint = readValue(m2Data, base + 0x14C); + if (midpoint < 0.0f || midpoint > 1.0f) midpoint = 0.5f; + + // Synthesize color FBlock from static BGRA values + { + em.particleColor.timestamps = {0.0f, midpoint, 1.0f}; + em.particleColor.vec3Values.resize(3); + em.particleAlpha.timestamps = {0.0f, midpoint, 1.0f}; + em.particleAlpha.floatValues.resize(3); + for (int c = 0; c < 3; c++) { + uint32_t bgra = readValue(m2Data, base + 0x150 + c * 4); + float b = ((bgra >> 0) & 0xFF) / 255.0f; + float g = ((bgra >> 8) & 0xFF) / 255.0f; + float r = ((bgra >> 16) & 0xFF) / 255.0f; + float a = ((bgra >> 24) & 0xFF) / 255.0f; + em.particleColor.vec3Values[c] = glm::vec3(r, g, b); + em.particleAlpha.floatValues[c] = a; + } + // If all alpha zero, use sensible default (fade out) + bool allZero = true; + for (auto v : em.particleAlpha.floatValues) { + if (v > 0.01f) { allZero = false; break; } + } + if (allZero) { + em.particleAlpha.floatValues = {1.0f, 1.0f, 0.0f}; + } + } + + // Synthesize scale FBlock from static float values + { + em.particleScale.timestamps = {0.0f, midpoint, 1.0f}; + em.particleScale.floatValues.resize(3); + for (int s = 0; s < 3; s++) { + float scale = readValue(m2Data, base + 0x15C + s * 4); + if (scale < 0.001f || scale > 100.0f) scale = 1.0f; + em.particleScale.floatValues[s] = scale; + } + } + } else { + // WotLK: M2TrackDisk (20 bytes) at known offsets with vary floats interspersed + auto parseTrack = [&](uint32_t off, M2AnimationTrack& track) { + if (base + off + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + off); + parseAnimTrack(m2Data, disk, track, TrackType::FLOAT, emSeqFlags); + } + }; + parseTrack(0x34, em.emissionSpeed); + parseTrack(0x48, em.speedVariation); + parseTrack(0x5C, em.verticalRange); + parseTrack(0x70, em.horizontalRange); + parseTrack(0x84, em.gravity); + parseTrack(0x98, em.lifespan); + parseTrack(0xB0, em.emissionRate); + parseTrack(0xC8, em.emissionAreaLength); + parseTrack(0xDC, em.emissionAreaWidth); + parseTrack(0xF0, em.deceleration); + + // Parse FBlocks (color, alpha, scale) — FBlocks are 16 bytes each + parseFBlock(m2Data, base + 0x104, em.particleColor, 0); + parseFBlock(m2Data, base + 0x114, em.particleAlpha, 1); + parseFBlock(m2Data, base + 0x124, em.particleScale, 2); + } model.particleEmitters.push_back(std::move(em)); } core::Logger::getInstance().debug(" Particle emitters: ", model.particleEmitters.size()); + } // end size check } // Read collision mesh (bounding triangles/vertices/normals) diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index d8275480..6c7956a3 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -223,11 +223,20 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, foundUnderwear = true; } } + + LOG_INFO("CharSections lookup: skin=", foundSkin ? bodySkinPath_ : "(not found)", + " face=", foundFace ? (faceLowerPath.empty() ? "(empty)" : faceLowerPath) : "(not found)", + " hair=", foundHair ? (hairScalpPath.empty() ? "(empty)" : hairScalpPath) : "(not found)", + " underwear=", foundUnderwear, " (", underwearPaths.size(), " textures)"); + } else { + LOG_WARNING("CharSections.dbc not loaded — no character textures"); } // Assign texture filenames on model before GPU upload for (size_t ti = 0; ti < model.textures.size(); ti++) { auto& tex = model.textures[ti]; + LOG_INFO(" Model texture[", ti, "]: type=", tex.type, + " filename='", tex.filename, "'"); if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) { tex.filename = bodySkinPath_; } else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) { diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 74bd25ca..5872b880 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -59,18 +59,6 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { // Get character list const auto& characters = gameHandler.getCharacters(); - // Handle disconnected state (e.g. Warden kicked us) - if (gameHandler.getState() == game::WorldState::DISCONNECTED || - gameHandler.getState() == game::WorldState::FAILED) { - ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Disconnected from server."); - ImGui::TextWrapped("The server closed the connection. This may be caused by " - "anti-cheat (Warden) verification failure."); - ImGui::Spacing(); - if (ImGui::Button("Back", ImVec2(120, 36))) { if (onBack) onBack(); } - ImGui::End(); - return; - } - // Request character list if not available. // Also show a loading state while CHAR_LIST_REQUESTED is in-flight (characters may be cleared to avoid stale UI). if (characters.empty() && @@ -84,6 +72,18 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { return; } + // Handle disconnected state with no characters received + if (characters.empty() && + (gameHandler.getState() == game::WorldState::DISCONNECTED || + gameHandler.getState() == game::WorldState::FAILED)) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Disconnected from server."); + ImGui::TextWrapped("The server closed the connection before sending the character list."); + ImGui::Spacing(); + if (ImGui::Button("Back", ImVec2(120, 36))) { if (onBack) onBack(); } + ImGui::End(); + return; + } + if (characters.empty()) { ImGui::Text("No characters available."); // Bottom buttons even when empty @@ -343,6 +343,12 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { // Enter World button — full width float btnW = ImGui::GetContentRegionAvail().x; + bool disconnected = (gameHandler.getState() == game::WorldState::DISCONNECTED || + gameHandler.getState() == game::WorldState::FAILED); + if (disconnected) { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.3f, 1.0f), "Connection lost — click Back to reconnect"); + } + if (disconnected) ImGui::BeginDisabled(); if (ImGui::Button("Enter World", ImVec2(btnW, 44))) { characterSelected = true; saveLastCharacter(character.guid); @@ -352,6 +358,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { gameHandler.selectCharacter(character.guid); if (onCharacterSelected) onCharacterSelected(character.guid); } + if (disconnected) ImGui::EndDisabled(); ImGui::EndChild(); }