From d3211f54930951653daed17f28373f7a778e5620 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Fri, 13 Feb 2026 20:10:19 -0800 Subject: [PATCH] Show online player equipment --- include/core/application.hpp | 16 +++ include/game/game_handler.hpp | 20 ++++ src/core/application.cpp | 181 ++++++++++++++++++++++++++++++++++ src/game/game_handler.cpp | 128 ++++++++++++++++++++++++ 4 files changed, 345 insertions(+) diff --git a/include/core/application.hpp b/include/core/application.hpp index 5c4d91cf..b9ced15a 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace wowee { @@ -96,6 +97,9 @@ private: uint32_t appearanceBytes, uint8_t facialFeatures, float x, float y, float z, float orientation); + void setOnlinePlayerEquipment(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes); void despawnOnlinePlayer(uint64_t guid); void buildCreatureDisplayLookups(); std::string getModelPathForDisplayId(uint32_t displayId) const; @@ -228,6 +232,18 @@ private: // Online player instances (separate from creatures so we can apply per-player skin/hair textures). std::unordered_map playerInstances_; // guid → render instanceId + struct OnlinePlayerAppearanceState { + uint32_t instanceId = 0; + uint32_t modelId = 0; + uint8_t raceId = 0; + uint8_t genderId = 0; + uint32_t appearanceBytes = 0; + uint8_t facialFeatures = 0; + std::string bodySkinPath; + std::vector underwearPaths; + }; + std::unordered_map onlinePlayerAppearance_; + std::unordered_map, std::array>> pendingOnlinePlayerEquipment_; // 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; }; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index ffd86619..04c8332e 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -494,6 +494,14 @@ public: using PlayerDespawnCallback = std::function; void setPlayerDespawnCallback(PlayerDespawnCallback cb) { playerDespawnCallback_ = std::move(cb); } + // Online player equipment visuals callback. + // Sends a best-effort view of equipped items for players in view using ItemDisplayInfo IDs. + // Arrays are indexed by EquipSlot (0..18). Values are 0 when unknown/unavailable. + using PlayerEquipmentCallback = std::function& displayInfoIds, + const std::array& inventoryTypes)>; + void setPlayerEquipmentCallback(PlayerEquipmentCallback cb) { playerEquipmentCallback_ = 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; @@ -831,6 +839,10 @@ private: void handleItemQueryResponse(network::Packet& packet); void queryItemInfo(uint32_t entry, uint64_t guid); void rebuildOnlineInventory(); + void maybeDetectVisibleItemLayout(); + void updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields); + void emitOtherPlayerEquipment(uint64_t guid); + void emitAllOtherPlayerEquipment(); void detectInventorySlotBases(const std::map& fields); bool applyInventoryFields(const std::map& fields); uint64_t resolveOnlineItemGuid(uint32_t itemId) const; @@ -1065,6 +1077,13 @@ private: std::map lastPlayerFields_; bool onlineEquipDirty_ = false; + // Visible equipment for other players: detect the update-field layout (base + stride) + // using the local player's own equipped items, then decode other players by index. + int visibleItemEntryBase_ = -1; + int visibleItemStride_ = 2; + std::unordered_map> otherPlayerVisibleItemEntries_; + std::unordered_set otherPlayerVisibleDirty_; + // ---- Phase 2: Combat ---- bool autoAttacking = false; uint64_t autoAttackTarget = 0; @@ -1081,6 +1100,7 @@ private: CreatureDespawnCallback creatureDespawnCallback_; PlayerSpawnCallback playerSpawnCallback_; PlayerDespawnCallback playerDespawnCallback_; + PlayerEquipmentCallback playerEquipmentCallback_; CreatureMoveCallback creatureMoveCallback_; TransportMoveCallback transportMoveCallback_; TransportSpawnCallback transportSpawnCallback_; diff --git a/src/core/application.cpp b/src/core/application.cpp index eb741e22..5865e74c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1214,6 +1214,13 @@ void Application::setupUICallbacks() { pendingPlayerSpawnGuids_.insert(guid); }); + // Online player equipment callback - apply armor geosets/skin overlays per player instance. + gameHandler->setPlayerEquipmentCallback([this](uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes) { + setOnlinePlayerEquipment(guid, displayInfoIds, inventoryTypes); + }); + // Creature despawn callback (online mode) - remove creature models gameHandler->setCreatureDespawnCallback([this](uint64_t guid) { despawnOnlineCreature(guid); @@ -3742,6 +3749,172 @@ void Application::spawnOnlinePlayer(uint64_t guid, charRenderer->playAnimation(instanceId, 0, true); playerInstances_[guid] = instanceId; + + OnlinePlayerAppearanceState st; + st.instanceId = instanceId; + st.modelId = modelId; + st.raceId = raceId; + st.genderId = genderId; + st.appearanceBytes = appearanceBytes; + st.facialFeatures = facialFeatures; + st.bodySkinPath = bodySkinPath; + st.underwearPaths = underwearPaths; + onlinePlayerAppearance_[guid] = std::move(st); +} + +void Application::setOnlinePlayerEquipment(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes) { + if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; + + // If the player isn't spawned yet, store equipment until spawn. + if (!playerInstances_.count(guid) || !onlinePlayerAppearance_.count(guid)) { + pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; + return; + } + + auto it = onlinePlayerAppearance_.find(guid); + if (it == onlinePlayerAppearance_.end()) return; + const OnlinePlayerAppearanceState& st = it->second; + + auto* charRenderer = renderer->getCharacterRenderer(); + if (!charRenderer) return; + if (st.instanceId == 0 || st.modelId == 0) return; + + if (st.bodySkinPath.empty()) return; + + auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return; + const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + + auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t { + if (displayInfoId == 0) return 0; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return 0; + return displayInfoDbc->getUInt32(static_cast(recIdx), fieldIdx); + }; + + auto findDisplayIdByInvType = [&](std::initializer_list types) -> uint32_t { + for (int s = 0; s < 19; s++) { + uint8_t inv = inventoryTypes[s]; + if (inv == 0 || displayInfoIds[s] == 0) continue; + for (uint8_t t : types) { + if (inv == t) return displayInfoIds[s]; + } + } + return 0; + }; + + auto hasInvType = [&](std::initializer_list types) -> bool { + for (int s = 0; s < 19; s++) { + uint8_t inv = inventoryTypes[s]; + if (inv == 0) continue; + for (uint8_t t : types) { + if (inv == t) return true; + } + } + return false; + }; + + // --- Geosets --- + std::unordered_set geosets; + for (uint16_t i = 0; i <= 18; i++) geosets.insert(i); + + uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); + geosets.insert(static_cast(100 + hairStyleId + 1)); + geosets.insert(static_cast(200 + st.facialFeatures + 1)); + geosets.insert(701); + + const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; + const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; + + // Chest/Shirt/Robe (invType 4,5,20) + { + uint32_t did = findDisplayIdByInvType({4, 5, 20}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + geosets.insert(static_cast(gg1 > 0 ? 501 + gg1 : 501)); + + uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); + if (gg3 > 0) geosets.insert(static_cast(1301 + gg3)); + } + + // Legs (invType 7) + { + uint32_t did = findDisplayIdByInvType({7}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { + geosets.insert(static_cast(gg1 > 0 ? 1301 + gg1 : 1301)); + } + } + + // Feet (invType 8) + { + uint32_t did = findDisplayIdByInvType({8}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + geosets.insert(static_cast(gg1 > 0 ? 401 + gg1 : 401)); + } + + // Hands (invType 10) + { + uint32_t did = findDisplayIdByInvType({10}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + geosets.insert(static_cast(gg1 > 0 ? 301 + gg1 : 301)); + } + + // Back/Cloak (invType 16) + geosets.insert(hasInvType({16}) ? 1502 : 1501); + // Tabard (invType 19) + if (hasInvType({19})) geosets.insert(1201); + + charRenderer->setActiveGeosets(st.instanceId, geosets); + + // --- Textures (skin atlas compositing) --- + static const char* componentDirs[] = { + "ArmUpperTexture", + "ArmLowerTexture", + "HandTexture", + "TorsoUpperTexture", + "TorsoLowerTexture", + "LegUpperTexture", + "LegLowerTexture", + "FootTexture", + }; + + std::vector> regionLayers; + const bool isFemale = (st.genderId == 1); + + for (int s = 0; s < 19; s++) { + uint32_t did = displayInfoIds[s]; + if (did == 0) continue; + int32_t recIdx = displayInfoDbc->findRecordById(did); + if (recIdx < 0) continue; + + for (int region = 0; region < 8; region++) { + std::string texName = displayInfoDbc->getString(static_cast(recIdx), 14 + region); + if (texName.empty()) texName = displayInfoDbc->getString(static_cast(recIdx), 15 + region); + if (texName.empty()) continue; + + std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; + std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); + std::string unisexPath = base + "_U.blp"; + std::string fullPath; + if (assetManager->fileExists(genderPath)) fullPath = genderPath; + else if (assetManager->fileExists(unisexPath)) fullPath = unisexPath; + else fullPath = base + ".blp"; + + regionLayers.emplace_back(region, fullPath); + } + } + + const auto slotsIt = playerTextureSlotsByModelId_.find(st.modelId); + if (slotsIt == playerTextureSlotsByModelId_.end()) return; + const PlayerTextureSlots& slots = slotsIt->second; + if (slots.skin < 0) return; + + GLuint newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); + if (newTex != 0) { + charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); + } } void Application::despawnOnlinePlayer(uint64_t guid) { @@ -3750,6 +3923,8 @@ void Application::despawnOnlinePlayer(uint64_t guid) { if (it == playerInstances_.end()) return; renderer->getCharacterRenderer()->removeInstance(it->second); playerInstances_.erase(it); + onlinePlayerAppearance_.erase(guid); + pendingOnlinePlayerEquipment_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -4099,6 +4274,12 @@ void Application::processPlayerSpawnQueue() { } spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); + // Apply any equipment updates that arrived before the player was spawned. + auto pit = pendingOnlinePlayerEquipment_.find(s.guid); + if (pit != pendingOnlinePlayerEquipment_.end()) { + setOnlinePlayerEquipment(s.guid, pit->second.first, pit->second.second); + pendingOnlinePlayerEquipment_.erase(pit); + } processed++; } } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c803286a..8eba31e9 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -2844,6 +2844,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { creatureDespawnCallback_(guid); } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { playerDespawnCallback_(guid); + otherPlayerVisibleItemEntries_.erase(guid); + otherPlayerVisibleDirty_.erase(guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(guid); } @@ -2949,6 +2951,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Auto-query names (Phase 1) if (block.objectType == ObjectType::PLAYER) { queryPlayerName(block.guid); + if (block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } } else if (block.objectType == ObjectType::UNIT) { auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); if (it != block.fields.end() && it->second != 0) { @@ -3287,6 +3292,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { entity->setField(field.first, field.second); } + if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + // Update cached health/mana/power values (Phase 2) — single pass if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { auto unit = std::static_pointer_cast(entity); @@ -3425,6 +3434,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { lastPlayerFields_[key] = val; } maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); + maybeDetectVisibleItemLayout(); detectInventorySlotBases(block.fields); bool slotsChanged = false; const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); @@ -4966,6 +4976,8 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { if (data.valid) { itemInfoCache_[data.entry] = data; rebuildOnlineInventory(); + maybeDetectVisibleItemLayout(); + emitAllOtherPlayerEquipment(); } } @@ -5180,6 +5192,122 @@ void GameHandler::rebuildOnlineInventory() { }()); } +void GameHandler::maybeDetectVisibleItemLayout() { + if (visibleItemEntryBase_ >= 0) return; + if (lastPlayerFields_.empty()) return; + + std::array equipEntries{}; + int nonZero = 0; + for (int i = 0; i < 19; i++) { + const auto& slot = inventory.getEquipSlot(static_cast(i)); + equipEntries[i] = slot.empty() ? 0u : slot.item.itemId; + if (equipEntries[i] != 0) nonZero++; + } + if (nonZero < 2) return; + + const uint16_t maxKey = lastPlayerFields_.rbegin()->first; + int bestBase = -1; + int bestStride = 0; + int bestMatches = 0; + + const int strides[] = {2, 3, 4, 1}; + for (int stride : strides) { + for (const auto& [baseIdxU16, _v] : lastPlayerFields_) { + const int base = static_cast(baseIdxU16); + if (base + 18 * stride > static_cast(maxKey)) continue; + + int matches = 0; + for (int s = 0; s < 19; s++) { + uint32_t want = equipEntries[s]; + if (want == 0) continue; + const uint16_t idx = static_cast(base + s * stride); + auto it = lastPlayerFields_.find(idx); + if (it != lastPlayerFields_.end() && it->second == want) matches++; + } + + if (matches > bestMatches || (matches == bestMatches && matches > 0 && base < bestBase)) { + bestMatches = matches; + bestBase = base; + bestStride = stride; + } + } + } + + if (bestMatches < 2 || bestBase < 0 || bestStride <= 0) return; + + visibleItemEntryBase_ = bestBase; + visibleItemStride_ = bestStride; + LOG_INFO("Detected PLAYER_VISIBLE_ITEM entry layout: base=", visibleItemEntryBase_, + " stride=", visibleItemStride_, " (matches=", bestMatches, ")"); + + // Backfill existing player entities already in view. + for (const auto& [guid, ent] : entityManager.getEntities()) { + if (!ent || ent->getType() != ObjectType::PLAYER) continue; + if (guid == playerGuid) continue; + updateOtherPlayerVisibleItems(guid, ent->getFields()); + } +} + +void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields) { + if (guid == 0 || guid == playerGuid) return; + if (visibleItemEntryBase_ < 0 || visibleItemStride_ <= 0) return; + + std::array newEntries{}; + for (int s = 0; s < 19; s++) { + uint16_t idx = static_cast(visibleItemEntryBase_ + s * visibleItemStride_); + auto it = fields.find(idx); + if (it != fields.end()) newEntries[s] = it->second; + } + + bool changed = false; + auto& old = otherPlayerVisibleItemEntries_[guid]; + if (old != newEntries) { + old = newEntries; + changed = true; + } + + // Request item templates for any new visible entries. + for (uint32_t entry : newEntries) { + if (entry == 0) continue; + if (!itemInfoCache_.count(entry) && !pendingItemQueries_.count(entry)) { + queryItemInfo(entry, 0); + } + } + + if (changed) { + otherPlayerVisibleDirty_.insert(guid); + emitOtherPlayerEquipment(guid); + } +} + +void GameHandler::emitOtherPlayerEquipment(uint64_t guid) { + if (!playerEquipmentCallback_) return; + auto it = otherPlayerVisibleItemEntries_.find(guid); + if (it == otherPlayerVisibleItemEntries_.end()) return; + + std::array displayIds{}; + std::array invTypes{}; + + for (int s = 0; s < 19; s++) { + uint32_t entry = it->second[s]; + if (entry == 0) continue; + auto infoIt = itemInfoCache_.find(entry); + if (infoIt == itemInfoCache_.end()) continue; + displayIds[s] = infoIt->second.displayInfoId; + invTypes[s] = static_cast(infoIt->second.inventoryType); + } + + playerEquipmentCallback_(guid, displayIds, invTypes); + otherPlayerVisibleDirty_.erase(guid); +} + +void GameHandler::emitAllOtherPlayerEquipment() { + if (!playerEquipmentCallback_) return; + for (const auto& [guid, _] : otherPlayerVisibleItemEntries_) { + emitOtherPlayerEquipment(guid); + } +} + // ============================================================ // Phase 2: Combat // ============================================================