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 cbb3035313
commit bf31da8c13
14 changed files with 556 additions and 55 deletions

View file

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

View file

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

View file

@ -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<uint64_t, ActiveTransport> transports_;
std::unordered_map<uint32_t, TransportPath> paths_; // Indexed by transportEntry (pathId from TransportAnimation.dbc)
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;
bool clientSideAnimation_ = false; // DISABLED - use server positions instead of client prediction
float elapsedTime_ = 0.0f; // Total elapsed time (seconds)

View file

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

View file

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

View file

@ -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,

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;
}

View file

@ -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<float> 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<uint8_t> 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

View file

@ -1050,26 +1050,34 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.attachmentLookup = readArray<uint16_t>(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<size_t>(header.ofsParticleEmitters) +
static_cast<size_t>(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<size_t>(header.ofsParticleEmitters) +
static_cast<size_t>(header.nParticleEmitters) * emitterSize <= m2Data.size()) {
// Build sequence flags for parseAnimTrack (WotLK only)
std::vector<uint32_t> 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<int32_t>(m2Data, base + 0x00);
em.flags = readValue<uint32_t>(m2Data, base + 0x04);
em.position.x = readValue<float>(m2Data, base + 0x08);
@ -1085,32 +1093,97 @@ M2Model M2Loader::load(const std::vector<uint8_t>& 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<M2TrackDisk>(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<M2TrackDiskVanilla>(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<float>(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<uint32_t>(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<float>(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<M2TrackDisk>(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)

View file

@ -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()) {

View file

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