From 5afd1b65a86c5732b641d4c77e608924d6905f08 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Feb 2026 19:40:54 -0800 Subject: [PATCH] Fix Turtle/Classic parsing and online player textures --- Data/expansions/classic/update_fields.json | 3 + Data/expansions/tbc/update_fields.json | 3 + Data/expansions/turtle/update_fields.json | 3 + Data/expansions/wotlk/update_fields.json | 3 + include/core/application.hpp | 26 ++ include/game/game_handler.hpp | 16 ++ include/game/packet_parsers.hpp | 6 + include/game/update_field_table.hpp | 3 + include/rendering/character_renderer.hpp | 5 + src/core/application.cpp | 298 ++++++++++++++++++++- src/game/game_handler.cpp | 136 ++++++++-- src/game/update_field_table.cpp | 6 + src/rendering/character_renderer.cpp | 37 ++- 13 files changed, 518 insertions(+), 27 deletions(-) diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index cf7fe18e..04ee8abd 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -2,6 +2,7 @@ "OBJECT_FIELD_ENTRY": 3, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, + "UNIT_FIELD_BYTES_0": 36, "UNIT_FIELD_HEALTH": 22, "UNIT_FIELD_POWER1": 23, "UNIT_FIELD_MAXHEALTH": 28, @@ -15,6 +16,8 @@ "UNIT_DYNAMIC_FLAGS": 143, "UNIT_END": 188, "PLAYER_FLAGS": 190, + "PLAYER_BYTES": 191, + "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, "PLAYER_FIELD_COINAGE": 1176, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index 43aa7985..36d17a2c 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -2,6 +2,7 @@ "OBJECT_FIELD_ENTRY": 3, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, + "UNIT_FIELD_BYTES_0": 36, "UNIT_FIELD_HEALTH": 22, "UNIT_FIELD_POWER1": 23, "UNIT_FIELD_MAXHEALTH": 28, @@ -16,6 +17,8 @@ "UNIT_DYNAMIC_FLAGS": 164, "UNIT_END": 234, "PLAYER_FLAGS": 236, + "PLAYER_BYTES": 237, + "PLAYER_BYTES_2": 238, "PLAYER_XP": 926, "PLAYER_NEXT_LEVEL_XP": 927, "PLAYER_FIELD_COINAGE": 1441, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index cf7fe18e..04ee8abd 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -2,6 +2,7 @@ "OBJECT_FIELD_ENTRY": 3, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, + "UNIT_FIELD_BYTES_0": 36, "UNIT_FIELD_HEALTH": 22, "UNIT_FIELD_POWER1": 23, "UNIT_FIELD_MAXHEALTH": 28, @@ -15,6 +16,8 @@ "UNIT_DYNAMIC_FLAGS": 143, "UNIT_END": 188, "PLAYER_FLAGS": 190, + "PLAYER_BYTES": 191, + "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, "PLAYER_FIELD_COINAGE": 1176, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index c7e1316e..2b85031f 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -2,6 +2,7 @@ "OBJECT_FIELD_ENTRY": 3, "UNIT_FIELD_TARGET_LO": 6, "UNIT_FIELD_TARGET_HI": 7, + "UNIT_FIELD_BYTES_0": 56, "UNIT_FIELD_HEALTH": 24, "UNIT_FIELD_POWER1": 25, "UNIT_FIELD_MAXHEALTH": 32, @@ -16,6 +17,8 @@ "UNIT_DYNAMIC_FLAGS": 147, "UNIT_END": 148, "PLAYER_FLAGS": 150, + "PLAYER_BYTES": 151, + "PLAYER_BYTES_2": 152, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, "PLAYER_FIELD_COINAGE": 1170, diff --git a/include/core/application.hpp b/include/core/application.hpp index 9adfcba1..5c4d91cf 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -90,6 +90,13 @@ private: void buildFactionHostilityMap(uint8_t playerRace); void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation); void despawnOnlineCreature(uint64_t guid); + void spawnOnlinePlayer(uint64_t guid, + uint8_t raceId, + uint8_t genderId, + uint32_t appearanceBytes, + uint8_t facialFeatures, + float x, float y, float z, float orientation); + void despawnOnlinePlayer(uint64_t guid); void buildCreatureDisplayLookups(); std::string getModelPathForDisplayId(uint32_t displayId) const; void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation); @@ -218,6 +225,25 @@ private: std::unordered_set pendingCreatureSpawnGuids_; std::unordered_map creatureSpawnRetryCounts_; std::unordered_set nonRenderableCreatureDisplayIds_; + + // Online player instances (separate from creatures so we can apply per-player skin/hair textures). + std::unordered_map playerInstances_; // guid → render instanceId + // Cache base player model geometry by (raceId, genderId) + std::unordered_map playerModelCache_; // key=(race<<8)|gender → modelId + struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; }; + std::unordered_map playerTextureSlotsByModelId_; + uint32_t nextPlayerModelId_ = 60000; + struct PendingPlayerSpawn { + uint64_t guid; + uint8_t raceId; + uint8_t genderId; + uint32_t appearanceBytes; + uint8_t facialFeatures; + float x, y, z, orientation; + }; + std::vector pendingPlayerSpawns_; + std::unordered_set pendingPlayerSpawnGuids_; + void processPlayerSpawnQueue(); std::unordered_set creaturePermanentFailureGuids_; void processCreatureSpawnQueue(); diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index f2d43d1e..2b2f1574 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -480,6 +480,20 @@ public: using CreatureDespawnCallback = std::function; void setCreatureDespawnCallback(CreatureDespawnCallback cb) { creatureDespawnCallback_ = std::move(cb); } + // Player spawn callback (online mode - triggered when a player enters view). + // Players need appearance data so the renderer can build the right body/hair textures. + using PlayerSpawnCallback = std::function; + void setPlayerSpawnCallback(PlayerSpawnCallback cb) { playerSpawnCallback_ = std::move(cb); } + + using PlayerDespawnCallback = std::function; + void setPlayerDespawnCallback(PlayerDespawnCallback cb) { playerDespawnCallback_ = std::move(cb); } + // GameObject spawn callback (online mode - triggered when gameobject enters view) // Parameters: guid, entry, displayId, x, y, z (canonical), orientation using GameObjectSpawnCallback = std::function; @@ -1065,6 +1079,8 @@ private: BindPointCallback bindPointCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; + PlayerSpawnCallback playerSpawnCallback_; + PlayerDespawnCallback playerDespawnCallback_; CreatureMoveCallback creatureMoveCallback_; TransportMoveCallback transportMoveCallback_; TransportSpawnCallback transportSpawnCallback_; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 0f58f109..291bdfad 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -22,6 +22,10 @@ class PacketParsers { public: virtual ~PacketParsers() = default; + // Size of MovementInfo.flags2 in bytes for MSG_MOVE_* payloads. + // Classic: none, TBC: u8, WotLK: u16. + virtual uint8_t movementFlags2Size() const { return 2; } + // --- Movement --- /** Parse movement block from SMSG_UPDATE_OBJECT */ @@ -145,6 +149,7 @@ class WotlkPacketParsers : public PacketParsers { */ class TbcPacketParsers : public PacketParsers { public: + uint8_t movementFlags2Size() const override { return 1; } bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; void writeMovementPayload(network::Packet& packet, const MovementInfo& info) override; network::Packet buildMovementPacket(LogicalOpcode opcode, @@ -171,6 +176,7 @@ public: */ class ClassicPacketParsers : public TbcPacketParsers { public: + uint8_t movementFlags2Size() const override { return 0; } bool parseCharEnum(network::Packet& packet, CharEnumResponse& response) override; bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; void writeMovementPayload(network::Packet& packet, const MovementInfo& info) override; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index ff8532aa..975887d8 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -18,6 +18,7 @@ enum class UF : uint16_t { // Unit fields UNIT_FIELD_TARGET_LO, UNIT_FIELD_TARGET_HI, + UNIT_FIELD_BYTES_0, UNIT_FIELD_HEALTH, UNIT_FIELD_POWER1, UNIT_FIELD_MAXHEALTH, @@ -34,6 +35,8 @@ enum class UF : uint16_t { // Player fields PLAYER_FLAGS, + PLAYER_BYTES, + PLAYER_BYTES_2, PLAYER_XP, PLAYER_NEXT_LEVEL_XP, PLAYER_FIELD_COINAGE, diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 1986c885..f6090df3 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -66,6 +66,8 @@ public: const pipeline::M2Model* getModelData(uint32_t modelId) const; void setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets); void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId); + void setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId); + void clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot); void setInstanceVisible(uint32_t instanceId, bool visible); void removeInstance(uint32_t instanceId); bool getAnimationState(uint32_t instanceId, uint32_t& animationId, float& animationTimeMs, float& animationDurationMs) const; @@ -151,6 +153,9 @@ private: // Per-geoset-group texture overrides (group → GL texture ID) std::unordered_map groupTextureOverrides; + // Per-texture-slot overrides (slot → GL texture ID) + std::unordered_map textureSlotOverrides; + // Weapon attachments (weapons parented to this instance's bones) std::vector weaponAttachments; diff --git a/src/core/application.cpp b/src/core/application.cpp index 5944facb..eb741e22 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -590,6 +590,7 @@ void Application::update(float deltaTime) { worldTime += std::chrono::duration(w2 - w1).count(); auto cq1 = std::chrono::high_resolution_clock::now(); + processPlayerSpawnQueue(); // Process deferred online creature spawns (throttled) processCreatureSpawnQueue(); auto cq2 = std::chrono::high_resolution_clock::now(); @@ -1199,11 +1200,29 @@ void Application::setupUICallbacks() { pendingCreatureSpawnGuids_.insert(guid); }); + // Player spawn callback (online mode) - spawn player models with correct textures + gameHandler->setPlayerSpawnCallback([this](uint64_t guid, + uint32_t /*displayId*/, + uint8_t raceId, + uint8_t genderId, + uint32_t appearanceBytes, + uint8_t facialFeatures, + float x, float y, float z, float orientation) { + if (playerInstances_.count(guid)) return; + if (pendingPlayerSpawnGuids_.count(guid)) return; + pendingPlayerSpawns_.push_back({guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation}); + pendingPlayerSpawnGuids_.insert(guid); + }); + // Creature despawn callback (online mode) - remove creature models gameHandler->setCreatureDespawnCallback([this](uint64_t guid) { despawnOnlineCreature(guid); }); + gameHandler->setPlayerDespawnCallback([this](uint64_t guid) { + despawnOnlinePlayer(guid); + }); + // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation}); @@ -1309,11 +1328,18 @@ void Application::setupUICallbacks() { // Creature move callback (online mode) - update creature positions gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { - auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + else { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) instanceId = it->second; + } + if (instanceId != 0) { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float durationSec = static_cast(durationMs) / 1000.0f; - renderer->getCharacterRenderer()->moveInstanceTo(it->second, renderPos, durationSec); + renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); } }); @@ -2915,6 +2941,10 @@ bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, fl if (gameHandler && guid == gameHandler->getPlayerGuid()) { instanceId = renderer->getCharacterInstanceId(); } + if (instanceId == 0) { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } if (instanceId == 0) { auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; @@ -3488,6 +3518,240 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } +void Application::spawnOnlinePlayer(uint64_t guid, + uint8_t raceId, + uint8_t genderId, + uint32_t appearanceBytes, + uint8_t facialFeatures, + float x, float y, float z, float orientation) { + if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; + if (playerInstances_.count(guid)) return; + + auto* charRenderer = renderer->getCharacterRenderer(); + + // Base geometry model: cache by (race, gender) + uint32_t cacheKey = (static_cast(raceId) << 8) | static_cast(genderId & 0xFF); + uint32_t modelId = 0; + auto itCache = playerModelCache_.find(cacheKey); + if (itCache != playerModelCache_.end()) { + modelId = itCache->second; + } else { + game::Race race = static_cast(raceId); + game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE; + std::string m2Path = game::getPlayerModelPath(race, gender); + if (m2Path.empty()) { + LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec, + " race=", (int)raceId, " gender=", (int)genderId); + return; + } + + // Parse modelDir/baseName for skin/anim loading + std::string modelDir; + std::string baseName; + { + size_t slash = m2Path.rfind('\\'); + if (slash != std::string::npos) { + modelDir = m2Path.substr(0, slash + 1); + baseName = m2Path.substr(slash + 1); + } else { + baseName = m2Path; + } + size_t dot = baseName.rfind('.'); + if (dot != std::string::npos) baseName = baseName.substr(0, dot); + } + + auto m2Data = assetManager->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path); + return; + } + + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (!model.isValid() || model.vertices.empty()) { + LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); + return; + } + + // Skin file + std::string skinPath = modelDir + baseName + "00.skin"; + auto skinData = assetManager->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, model); + } + + // Load only core external animations (stand/walk/run) to avoid stalls + for (uint32_t si = 0; si < model.sequences.size(); si++) { + if (!(model.sequences[si].flags & 0x20)) { + uint32_t animId = model.sequences[si].id; + if (animId != 0 && animId != 4 && animId != 5) continue; + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), + "%s%s%04u-%02u.anim", + modelDir.c_str(), + baseName.c_str(), + animId, + model.sequences[si].variationIndex); + auto animData = assetManager->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + } + } + } + + modelId = nextPlayerModelId_++; + if (!charRenderer->loadModel(model, modelId)) { + LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path); + return; + } + + playerModelCache_[cacheKey] = modelId; + } + + // Determine texture slots once per model + if (!playerTextureSlotsByModelId_.count(modelId)) { + PlayerTextureSlots slots; + if (const auto* md = charRenderer->getModelData(modelId)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + uint32_t t = md->textures[ti].type; + if (t == 1 && slots.skin < 0) slots.skin = (int)ti; + else if (t == 6 && slots.hair < 0) slots.hair = (int)ti; + else if (t == 8 && slots.underwear < 0) slots.underwear = (int)ti; + } + } + playerTextureSlotsByModelId_[modelId] = slots; + } + + // Create instance at server position + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + float renderYaw = orientation + glm::radians(90.0f); + uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); + if (instanceId == 0) return; + + // Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides + const char* raceFolderName = "Human"; + switch (static_cast(raceId)) { + case game::Race::HUMAN: raceFolderName = "Human"; break; + case game::Race::ORC: raceFolderName = "Orc"; break; + case game::Race::DWARF: raceFolderName = "Dwarf"; break; + case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break; + case game::Race::UNDEAD: raceFolderName = "Scourge"; break; + case game::Race::TAUREN: raceFolderName = "Tauren"; break; + case game::Race::GNOME: raceFolderName = "Gnome"; break; + case game::Race::TROLL: raceFolderName = "Troll"; break; + case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break; + case game::Race::DRAENEI: raceFolderName = "Draenei"; break; + default: break; + } + const char* genderFolder = (genderId == 1) ? "Female" : "Male"; + std::string raceGender = std::string(raceFolderName) + genderFolder; + std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp"; + std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp"; + std::vector underwearPaths; + std::string hairTexturePath; + + uint8_t skinId = appearanceBytes & 0xFF; + uint8_t faceId = (appearanceBytes >> 8) & 0xFF; + uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF; + uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF; + + if (auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) { + const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; + uint32_t targetRaceId = raceId; + uint32_t targetSexId = genderId; + const uint32_t csTex1 = csL ? (*csL)["Texture1"] : 4; + + bool foundSkin = false; + bool foundUnderwear = false; + bool foundHair = false; + bool foundFaceLower = false; + (void)faceId; // face lower not yet applied as separate layer + + for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { + uint32_t rRace = charSectionsDbc->getUInt32(r, csL ? (*csL)["RaceID"] : 1); + uint32_t rSex = charSectionsDbc->getUInt32(r, csL ? (*csL)["SexID"] : 2); + uint32_t baseSection = charSectionsDbc->getUInt32(r, csL ? (*csL)["BaseSection"] : 3); + uint32_t variationIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 8); + uint32_t colorIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 9); + + if (rRace != targetRaceId || rSex != targetSexId) continue; + + if (baseSection == 0 && !foundSkin && colorIndex == skinId) { + std::string tex1 = charSectionsDbc->getString(r, csTex1); + if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; } + } else if (baseSection == 3 && !foundHair && + variationIndex == hairStyleId && colorIndex == hairColorId) { + hairTexturePath = charSectionsDbc->getString(r, csTex1); + if (!hairTexturePath.empty()) foundHair = true; + } else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) { + for (uint32_t f = csTex1; f <= csTex1 + 2; f++) { + std::string tex = charSectionsDbc->getString(r, f); + if (!tex.empty()) underwearPaths.push_back(tex); + } + foundUnderwear = true; + } else if (baseSection == 1 && !foundFaceLower && + variationIndex == faceId && colorIndex == skinId) { + foundFaceLower = true; + } + + if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break; + } + } + + // Composite base skin + underwear overlays (same as local character logic) + GLuint compositeTex = 0; + if (!underwearPaths.empty()) { + std::vector layers; + layers.push_back(bodySkinPath); + for (const auto& up : underwearPaths) layers.push_back(up); + compositeTex = charRenderer->compositeTextures(layers); + } else { + compositeTex = charRenderer->loadTexture(bodySkinPath); + } + + GLuint hairTex = 0; + if (!hairTexturePath.empty()) { + hairTex = charRenderer->loadTexture(hairTexturePath); + } + GLuint underwearTex = 0; + if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); + else underwearTex = charRenderer->loadTexture(pelvisPath); + + const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; + if (slots.skin >= 0 && compositeTex != 0) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); + } + if (slots.hair >= 0 && hairTex != 0) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); + } + if (slots.underwear >= 0 && underwearTex != 0) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); + } + + // Geosets: body + hair/facial hair selections + std::unordered_set activeGeosets; + for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i); + activeGeosets.insert(static_cast(100 + hairStyleId + 1)); + activeGeosets.insert(static_cast(200 + facialFeatures + 1)); + activeGeosets.insert(301); + activeGeosets.insert(401); + activeGeosets.insert(501); + activeGeosets.insert(701); + activeGeosets.insert(1301); + activeGeosets.insert(1501); + charRenderer->setActiveGeosets(instanceId, activeGeosets); + + charRenderer->playAnimation(instanceId, 0, true); + playerInstances_[guid] = instanceId; +} + +void Application::despawnOnlinePlayer(uint64_t guid) { + if (!renderer || !renderer->getCharacterRenderer()) return; + auto it = playerInstances_.find(guid); + if (it == playerInstances_.end()) return; + renderer->getCharacterRenderer()->removeInstance(it->second); + playerInstances_.erase(it); +} + void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { if (!renderer || !assetManager) return; @@ -3818,6 +4082,27 @@ void Application::processCreatureSpawnQueue() { } } +void Application::processPlayerSpawnQueue() { + if (pendingPlayerSpawns_.empty()) return; + if (!assetManager || !assetManager->isInitialized()) return; + + int processed = 0; + while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) { + PendingPlayerSpawn s = pendingPlayerSpawns_.front(); + pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin()); + pendingPlayerSpawnGuids_.erase(s.guid); + + // Skip if already spawned (could have been spawned by a previous update this frame) + if (playerInstances_.count(s.guid)) { + processed++; + continue; + } + + spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); + processed++; + } +} + void Application::processGameObjectSpawnQueue() { if (pendingGameObjectSpawns_.empty()) return; @@ -4083,6 +4368,13 @@ void Application::processPendingMount() { } void Application::despawnOnlineCreature(uint64_t guid) { + // If this guid is a PLAYER, it will be tracked in playerInstances_. + // Route to the correct despawn path so we don't leak instances. + if (playerInstances_.count(guid)) { + despawnOnlinePlayer(guid); + return; + } + pendingCreatureSpawnGuids_.erase(guid); creatureSpawnRetryCounts_.erase(guid); creaturePermanentFailureGuids_.erase(guid); diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0ea3e031..c1081ef6 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1558,14 +1558,10 @@ void GameHandler::handleCharEnum(network::Packet& packet) { LOG_INFO("Handling SMSG_CHAR_ENUM"); CharEnumResponse response; - bool parsed = false; - if (build <= 6005) { - // Vanilla 1.12.x format (different equipment layout, no customization flag) - ClassicPacketParsers classicParser; - parsed = classicParser.parseCharEnum(packet, response); - } else { - parsed = CharEnumParser::parse(packet, response); - } + // IMPORTANT: Do not infer packet formats from numeric build alone. + // Turtle WoW uses a "high" build but classic-era world packet formats. + bool parsed = packetParsers_ ? packetParsers_->parseCharEnum(packet, response) + : CharEnumParser::parse(packet, response); if (!parsed) { fail("Failed to parse SMSG_CHAR_ENUM"); return; @@ -2688,6 +2684,87 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { return; } + auto extractPlayerAppearance = [&](const std::map& fields, + uint8_t& outRace, + uint8_t& outGender, + uint32_t& outAppearanceBytes, + uint8_t& outFacial) -> bool { + outRace = 0; + outGender = 0; + outAppearanceBytes = 0; + outFacial = 0; + + auto readField = [&](uint16_t idx, uint32_t& out) -> bool { + if (idx == 0xFFFF) return false; + auto it = fields.find(idx); + if (it == fields.end()) return false; + out = it->second; + return true; + }; + + uint32_t bytes0 = 0; + uint32_t pbytes = 0; + uint32_t pbytes2 = 0; + + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES); + const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2); + + bool haveBytes0 = readField(ufBytes0, bytes0); + bool havePbytes = readField(ufPbytes, pbytes); + bool havePbytes2 = readField(ufPbytes2, pbytes2); + + // Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing, + // try to locate plausible packed fields by scanning. + if (!haveBytes0) { + for (const auto& [idx, v] : fields) { + uint8_t race = static_cast(v & 0xFF); + uint8_t cls = static_cast((v >> 8) & 0xFF); + uint8_t gender = static_cast((v >> 16) & 0xFF); + uint8_t power = static_cast((v >> 24) & 0xFF); + if (race >= 1 && race <= 20 && + cls >= 1 && cls <= 20 && + gender <= 1 && + power <= 10) { + bytes0 = v; + haveBytes0 = true; + break; + } + } + } + if (!havePbytes) { + for (const auto& [idx, v] : fields) { + uint8_t skin = static_cast(v & 0xFF); + uint8_t face = static_cast((v >> 8) & 0xFF); + uint8_t hair = static_cast((v >> 16) & 0xFF); + uint8_t color = static_cast((v >> 24) & 0xFF); + if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) { + pbytes = v; + havePbytes = true; + break; + } + } + } + if (!havePbytes2) { + for (const auto& [idx, v] : fields) { + uint8_t facial = static_cast(v & 0xFF); + if (facial <= 100) { + pbytes2 = v; + havePbytes2 = true; + break; + } + } + } + + if (!haveBytes0 || !havePbytes) return false; + + outRace = static_cast(bytes0 & 0xFF); + outGender = static_cast((bytes0 >> 16) & 0xFF); + outAppearanceBytes = pbytes; + outFacial = havePbytes2 ? static_cast(pbytes2 & 0xFF) : 0; + return true; + }; + // Process out-of-range objects first for (uint64_t guid : data.outOfRangeGuids) { if (entityManager.hasEntity(guid)) { @@ -2713,6 +2790,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (entity) { if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(guid); } @@ -2903,9 +2982,20 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (unit->getFactionTemplate() != 0) { unit->setHostile(isHostileFaction(unit->getFactionTemplate())); } - // Trigger creature spawn callback for units/players with displayId + // Trigger creature spawn callback for units/players with displayId if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { - if (creatureSpawnCallback_) { + if (block.objectType == ObjectType::PLAYER && block.guid != playerGuid) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } + } + } else if (creatureSpawnCallback_) { creatureSpawnCallback_(block.guid, unit->getDisplayId(), unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); } @@ -3238,7 +3328,18 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { displayIdChanged && unit->getDisplayId() != 0 && unit->getDisplayId() != oldDisplayId) { - if (creatureSpawnCallback_) { + if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } + } + } else if (creatureSpawnCallback_) { creatureSpawnCallback_(block.guid, unit->getDisplayId(), unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); } @@ -5332,14 +5433,11 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // For classic: moveFlags(u32) + time(u32) + pos(4xf32) + [transport] + [pitch] + fallTime(u32) + [jump] + [splineElev] MovementInfo info = {}; info.flags = packet.readUInt32(); - // WotLK has uint16 flags2, classic/TBC don't - if (build >= 8606) { // TBC+ - if (build >= 12340) { - info.flags2 = packet.readUInt16(); - } else { - info.flags2 = packet.readUInt8(); - } - } + // WotLK has u16 flags2, TBC has u8, Classic has none. + // Do NOT use build-number thresholds here (Turtle uses classic formats with a high build). + uint8_t flags2Size = packetParsers_ ? packetParsers_->movementFlags2Size() : 2; + if (flags2Size == 2) info.flags2 = packet.readUInt16(); + else if (flags2Size == 1) info.flags2 = packet.readUInt8(); info.time = packet.readUInt32(); info.x = packet.readFloat(); info.y = packet.readFloat(); diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 2cf76784..e219c524 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -21,6 +21,7 @@ static const UFNameEntry kUFNames[] = { {"OBJECT_FIELD_ENTRY", UF::OBJECT_FIELD_ENTRY}, {"UNIT_FIELD_TARGET_LO", UF::UNIT_FIELD_TARGET_LO}, {"UNIT_FIELD_TARGET_HI", UF::UNIT_FIELD_TARGET_HI}, + {"UNIT_FIELD_BYTES_0", UF::UNIT_FIELD_BYTES_0}, {"UNIT_FIELD_HEALTH", UF::UNIT_FIELD_HEALTH}, {"UNIT_FIELD_POWER1", UF::UNIT_FIELD_POWER1}, {"UNIT_FIELD_MAXHEALTH", UF::UNIT_FIELD_MAXHEALTH}, @@ -35,6 +36,8 @@ static const UFNameEntry kUFNames[] = { {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_END", UF::UNIT_END}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, + {"PLAYER_BYTES", UF::PLAYER_BYTES}, + {"PLAYER_BYTES_2", UF::PLAYER_BYTES_2}, {"PLAYER_XP", UF::PLAYER_XP}, {"PLAYER_NEXT_LEVEL_XP", UF::PLAYER_NEXT_LEVEL_XP}, {"PLAYER_FIELD_COINAGE", UF::PLAYER_FIELD_COINAGE}, @@ -55,6 +58,7 @@ void UpdateFieldTable::loadWotlkDefaults() { {UF::OBJECT_FIELD_ENTRY, 3}, {UF::UNIT_FIELD_TARGET_LO, 6}, {UF::UNIT_FIELD_TARGET_HI, 7}, + {UF::UNIT_FIELD_BYTES_0, 56}, {UF::UNIT_FIELD_HEALTH, 24}, {UF::UNIT_FIELD_POWER1, 25}, {UF::UNIT_FIELD_MAXHEALTH, 32}, @@ -69,6 +73,8 @@ void UpdateFieldTable::loadWotlkDefaults() { {UF::UNIT_DYNAMIC_FLAGS, 147}, {UF::UNIT_END, 148}, {UF::PLAYER_FLAGS, 150}, + {UF::PLAYER_BYTES, 151}, + {UF::PLAYER_BYTES_2, 152}, {UF::PLAYER_XP, 634}, {UF::PLAYER_NEXT_LEVEL_XP, 635}, {UF::PLAYER_FIELD_COINAGE, 1170}, diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 119bf1d0..7f87a18b 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1291,7 +1291,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons } } - auto resolveBatchTexture = [&](const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint { + auto resolveBatchTexture = [&](const CharacterInstance& inst, const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint { // A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex. // We currently bind only a single texture, so pick the most appropriate one. // @@ -1316,6 +1316,10 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons if (texSlot >= gm.textureIds.size()) continue; GLuint texId = gm.textureIds[texSlot]; + auto itO = inst.textureSlotOverrides.find(texSlot); + if (itO != inst.textureSlotOverrides.end() && itO->second != 0) { + texId = itO->second; + } uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; if (!hasFirst) { @@ -1353,7 +1357,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons (b.submeshId / 100 != 0) && instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end(); - GLuint resolvedTex = resolveBatchTexture(gpuModel, b); + GLuint resolvedTex = resolveBatchTexture(instance, gpuModel, b); std::string texInfo = "GL" + std::to_string(resolvedTex); if (filtered) skipped++; else rendered++; @@ -1383,7 +1387,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons } // Resolve texture for this batch (prefer hair textures for hair geosets). - GLuint texId = resolveBatchTexture(gpuModel, batch); + GLuint texId = resolveBatchTexture(instance, gpuModel, batch); // For body parts with white/fallback texture, use skin (type 1) texture // This handles humanoid models where some body parts use different texture slots @@ -1404,11 +1408,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons // Do NOT apply skin composite to hair (type 6) batches if (texType != 6) { for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) { - if (gpuModel.textureIds[ti] != whiteTexture && gpuModel.textureIds[ti] != 0) { + GLuint candidate = gpuModel.textureIds[ti]; + auto itO = instance.textureSlotOverrides.find(static_cast(ti)); + if (itO != instance.textureSlotOverrides.end() && itO->second != 0) { + candidate = itO->second; + } + if (candidate != whiteTexture && candidate != 0) { // Only use type 1 (skin) textures as fallback if (ti < gpuModel.data.textures.size() && (gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) { - texId = gpuModel.textureIds[ti]; + texId = candidate; break; } } @@ -1489,6 +1498,10 @@ void CharacterRenderer::renderShadow(const glm::mat4& lightSpaceMatrix) { uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex]; if (lookupIdx < gpuModel.textureIds.size()) { texId = gpuModel.textureIds[lookupIdx]; + auto itO = instance.textureSlotOverrides.find(lookupIdx); + if (itO != instance.textureSlotOverrides.end() && itO->second != 0) { + texId = itO->second; + } } } @@ -1613,6 +1626,20 @@ void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t ge } } +void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId) { + auto it = instances.find(instanceId); + if (it != instances.end()) { + it->second.textureSlotOverrides[textureSlot] = textureId; + } +} + +void CharacterRenderer::clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot) { + auto it = instances.find(instanceId); + if (it != instances.end()) { + it->second.textureSlotOverrides.erase(textureSlot); + } +} + void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) { auto it = instances.find(instanceId); if (it != instances.end()) {