From 275914b4db38e945ce9fa61638c2be4b73128478 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Thu, 12 Feb 2026 14:55:27 -0800 Subject: [PATCH] Fix character appearance, previews, mount seat, and online unequip --- include/core/application.hpp | 3 + include/game/game_handler.hpp | 1 + include/game/opcodes.hpp | 2 + include/game/world_packets.hpp | 15 ++ include/rendering/character_preview.hpp | 15 ++ include/ui/character_create_screen.hpp | 5 + include/ui/character_screen.hpp | 24 +++- src/core/application.cpp | 122 +++++++++++++--- src/game/game_handler.cpp | 30 ++++ src/game/world_packets.cpp | 16 +++ src/network/world_socket.cpp | 59 ++++++++ src/rendering/character_preview.cpp | 179 +++++++++++++++++++++++- src/rendering/character_renderer.cpp | 128 ++++++++++------- src/rendering/renderer.cpp | 7 +- src/ui/character_create_screen.cpp | 32 ++++- src/ui/character_screen.cpp | 157 +++++++++++++++++++-- src/ui/game_screen.cpp | 16 ++- src/ui/inventory_screen.cpp | 31 ++-- src/ui/realm_screen.cpp | 14 +- 19 files changed, 743 insertions(+), 113 deletions(-) diff --git a/include/core/application.hpp b/include/core/application.hpp index 67482afa..8467e79c 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -117,6 +117,9 @@ private: game::Race playerRace_ = game::Race::HUMAN; game::Gender playerGender_ = game::Gender::MALE; game::Class playerClass_ = game::Class::WARRIOR; + uint64_t spawnedPlayerGuid_ = 0; + uint32_t spawnedAppearanceBytes_ = 0; + uint8_t spawnedFacialFeatures_ = 0; // Weapon model ID counter (starting high to avoid collision with character model IDs) uint32_t nextWeaponModelId_ = 1000; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 5283e0c8..9918c581 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -236,6 +236,7 @@ public: Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } bool consumeOnlineEquipmentDirty() { bool d = onlineEquipDirty_; onlineEquipDirty_ = false; return d; } + void unequipToBackpack(EquipSlot equipSlot); // Targeting void setTarget(uint64_t guid); diff --git a/include/game/opcodes.hpp b/include/game/opcodes.hpp index 9691a7de..72ed1ef3 100644 --- a/include/game/opcodes.hpp +++ b/include/game/opcodes.hpp @@ -261,6 +261,8 @@ enum class Opcode : uint16_t { SMSG_ITEM_QUERY_SINGLE_RESPONSE = 0x058, CMSG_USE_ITEM = 0x00AB, CMSG_AUTOEQUIP_ITEM = 0x10A, + CMSG_SWAP_ITEM = 0x10C, + CMSG_SWAP_INV_ITEM = 0x10D, SMSG_INVENTORY_CHANGE_FAILURE = 0x112, CMSG_INSPECT = 0x114, SMSG_INSPECT_RESULTS = 0x115, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 296abb9d..040395aa 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1495,6 +1495,21 @@ public: static network::Packet build(uint8_t srcBag, uint8_t srcSlot); }; +/** CMSG_SWAP_ITEM packet builder */ +class SwapItemPacket { +public: + // Order matches AzerothCore handler: destBag, destSlot, srcBag, srcSlot. + static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot); +}; + +/** CMSG_SWAP_INV_ITEM packet builder */ +class SwapInvItemPacket { +public: + // WoW inventory: slots are in the "inventory" range (equipment 0-18, bags 19-22, backpack 23-38). + // This swaps two inventory slots directly. + static network::Packet build(uint8_t srcSlot, uint8_t dstSlot); +}; + /** CMSG_LOOT_MONEY packet builder (empty body) */ class LootMoneyPacket { public: diff --git a/include/rendering/character_preview.hpp b/include/rendering/character_preview.hpp index eaf088cc..14ca587f 100644 --- a/include/rendering/character_preview.hpp +++ b/include/rendering/character_preview.hpp @@ -4,6 +4,8 @@ #include #include #include +#include +#include namespace wowee { namespace pipeline { class AssetManager; } @@ -25,6 +27,9 @@ public: uint8_t hairStyle, uint8_t hairColor, uint8_t facialHair, bool useFemaleModel = false); + // Apply equipment overlays/geosets using SMSG_CHAR_ENUM equipment data (ItemDisplayInfo.dbc). + bool applyEquipment(const std::vector& equipment); + void update(float deltaTime); void render(); void rotate(float yawDelta); @@ -56,6 +61,16 @@ private: uint32_t instanceId_ = 0; bool modelLoaded_ = false; float modelYaw_ = 180.0f; + + // Cached info from loadCharacter() for later recompositing. + game::Race race_ = game::Race::HUMAN; + game::Gender gender_ = game::Gender::MALE; + bool useFemaleModel_ = false; + uint8_t hairStyle_ = 0; + uint8_t facialHair_ = 0; + std::string bodySkinPath_; + std::vector baseLayers_; // face + underwear, etc. + uint32_t skinTextureSlotIndex_ = 0; }; } // namespace rendering diff --git a/include/ui/character_create_screen.hpp b/include/ui/character_create_screen.hpp index e5757c34..5525477f 100644 --- a/include/ui/character_create_screen.hpp +++ b/include/ui/character_create_screen.hpp @@ -7,6 +7,7 @@ #include #include #include +#include namespace wowee { namespace game { class GameHandler; } @@ -39,6 +40,10 @@ private: std::string statusMessage; bool statusIsError = false; + // For many races/styles, CharSections hair color IDs are not guaranteed to be contiguous. + // We expose an index (hairColor) in the UI and map it to the actual DBC hairColorId here. + std::vector hairColorIds_; + std::vector availableClasses; void updateAvailableClasses(); diff --git a/include/ui/character_screen.hpp b/include/ui/character_screen.hpp index a8bc21c1..f9487525 100644 --- a/include/ui/character_screen.hpp +++ b/include/ui/character_screen.hpp @@ -4,8 +4,12 @@ #include #include #include +#include -namespace wowee { namespace ui { +namespace wowee { +namespace pipeline { class AssetManager; } +namespace rendering { class CharacterPreview; } +namespace ui { /** * Character selection screen UI @@ -22,6 +26,11 @@ public: */ void render(game::GameHandler& gameHandler); + void setAssetManager(pipeline::AssetManager* am) { + assetManager_ = am; + previewInitialized_ = false; + } + /** * Set callback for character selection * @param callback Function to call when character is selected (receives character GUID) @@ -83,6 +92,17 @@ private: static std::string getConfigDir(); void saveLastCharacter(uint64_t guid); uint64_t loadLastCharacter(); + + // Preview (3D character portrait) + pipeline::AssetManager* assetManager_ = nullptr; + std::unique_ptr preview_; + bool previewInitialized_ = false; + uint64_t previewGuid_ = 0; + uint32_t previewAppearanceBytes_ = 0; + uint8_t previewFacialFeatures_ = 0; + bool previewUseFemaleModel_ = false; + uint64_t previewEquipHash_ = 0; }; -}} // namespace wowee::ui +} // namespace ui +} // namespace wowee diff --git a/src/core/application.cpp b/src/core/application.cpp index 540d648c..aa629645 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -131,6 +131,13 @@ bool Application::initialize() { // Eagerly load creature display DBC lookups so first spawn doesn't stall buildCreatureDisplayLookups(); + // Ensure the main in-world CharacterRenderer can load textures immediately. + // Previously this was only wired during terrain initialization, which meant early spawns + // (before terrain load) would render with white fallback textures (notably hair). + if (renderer && renderer->getCharacterRenderer()) { + renderer->getCharacterRenderer()->setAssetManager(assetManager.get()); + } + // Load transport paths from TransportAnimation.dbc if (gameHandler && gameHandler->getTransportManager()) { gameHandler->getTransportManager()->loadTransportAnimationDBC(assetManager.get()); @@ -314,6 +321,26 @@ void Application::setState(AppState newState) { break; case AppState::CHARACTER_SELECTION: // Show character screen + if (uiManager && assetManager) { + uiManager->getCharacterScreen().setAssetManager(assetManager.get()); + } + // Ensure no stale in-world player model leaks into the next login attempt. + // If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync. + npcsSpawned = false; + playerCharacterSpawned = false; + weaponsSheathed_ = false; + wasAutoAttacking_ = false; + spawnedPlayerGuid_ = 0; + spawnedAppearanceBytes_ = 0; + spawnedFacialFeatures_ = 0; + if (renderer && renderer->getCharacterRenderer()) { + uint32_t oldInst = renderer->getCharacterInstanceId(); + if (oldInst > 0) { + renderer->setCharacterFollow(0); + renderer->clearMount(); + renderer->getCharacterRenderer()->removeInstance(oldInst); + } + } break; case AppState::IN_GAME: { // Wire up movement opcodes from camera controller @@ -1829,29 +1856,37 @@ void Application::spawnPlayerCharacter() { glm::vec3(0.0f), 1.0f); // Scale 1.0 = normal WoW character size if (instanceId > 0) { - // Set up third-person follow - renderer->getCharacterPosition() = spawnPos; - renderer->setCharacterFollow(instanceId); + // Set up third-person follow + renderer->getCharacterPosition() = spawnPos; + renderer->setCharacterFollow(instanceId); - // Default geosets for naked human male - // Use actual submesh IDs from the model (logged at load time) - std::unordered_set activeGeosets; - // Body parts (group 0: IDs 0-99) - humanoid models may have many body submeshes - for (uint16_t i = 0; i < 100; i++) { - activeGeosets.insert(i); - } - // Equipment groups: "01" = bare skin, "02" = first equipped variant - activeGeosets.insert(101); // Hair style 1 - activeGeosets.insert(201); // Facial hair: none - activeGeosets.insert(301); // Gloves: bare hands - activeGeosets.insert(401); // Boots: bare feet - activeGeosets.insert(501); // Chest: bare - activeGeosets.insert(701); // Ears: default - activeGeosets.insert(1301); // Trousers: bare legs - activeGeosets.insert(1501); // Back body (cloak=none) - // 1703 = DK eye glow mesh — skip for normal characters - // Normal eyes are part of the face texture on the body mesh - charRenderer->setActiveGeosets(instanceId, activeGeosets); + // Default geosets for the active character (match CharacterPreview logic). + // Previous hardcoded values (notably always inserting 101) caused wrong hair meshes in-world. + std::unordered_set activeGeosets; + // Body parts (group 0) + for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i); + + uint8_t hairStyleId = 0; + uint8_t facialId = 0; + if (gameHandler) { + if (const game::Character* ch = gameHandler->getActiveCharacter()) { + hairStyleId = static_cast((ch->appearanceBytes >> 16) & 0xFF); + facialId = ch->facialFeatures; + } + } + // Hair style geoset: group 1 = 100 + variation + 1 + activeGeosets.insert(static_cast(100 + hairStyleId + 1)); + // Facial hair geoset: group 2 = 200 + variation + 1 + activeGeosets.insert(static_cast(200 + facialId + 1)); + activeGeosets.insert(301); // Gloves: bare hands + activeGeosets.insert(401); // Boots: bare feet + activeGeosets.insert(501); // Chest: bare + activeGeosets.insert(701); // Ears: default + activeGeosets.insert(1301); // Trousers: bare legs + activeGeosets.insert(1501); // Back body (cloak=none) + // 1703 = DK eye glow mesh — skip for normal characters + // Normal eyes are part of the face texture on the body mesh + charRenderer->setActiveGeosets(instanceId, activeGeosets); // Play idle animation (Stand = animation ID 0) charRenderer->playAnimation(instanceId, 0, true); @@ -1861,6 +1896,18 @@ void Application::spawnPlayerCharacter() { static_cast(spawnPos.z), ")"); playerCharacterSpawned = true; + // Track which character's appearance this instance represents so we can + // respawn if the user logs into a different character without restarting. + spawnedPlayerGuid_ = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; + spawnedAppearanceBytes_ = 0; + spawnedFacialFeatures_ = 0; + if (gameHandler) { + if (const game::Character* ch = gameHandler->getActiveCharacter()) { + spawnedAppearanceBytes_ = ch->appearanceBytes; + spawnedFacialFeatures_ = ch->facialFeatures; + } + } + // Set up camera controller for first-person player hiding if (renderer->getCameraController()) { renderer->getCameraController()->setCharacterRenderer(charRenderer, instanceId); @@ -2235,11 +2282,40 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (gameHandler) { const game::Character* activeChar = gameHandler->getActiveCharacter(); if (activeChar) { - if (!playerCharacterSpawned) { + const uint64_t activeGuid = gameHandler->getActiveCharacterGuid(); + const bool appearanceChanged = + (activeGuid != spawnedPlayerGuid_) || + (activeChar->appearanceBytes != spawnedAppearanceBytes_) || + (activeChar->facialFeatures != spawnedFacialFeatures_) || + (activeChar->race != playerRace_) || + (activeChar->gender != playerGender_) || + (activeChar->characterClass != playerClass_); + + if (!playerCharacterSpawned || appearanceChanged) { + if (appearanceChanged) { + LOG_INFO("Respawning player model for new/changed character: guid=0x", + std::hex, activeGuid, std::dec); + } + // Remove old instance so we don't keep stale visuals. + if (renderer && renderer->getCharacterRenderer()) { + uint32_t oldInst = renderer->getCharacterInstanceId(); + if (oldInst > 0) { + renderer->setCharacterFollow(0); + renderer->clearMount(); + renderer->getCharacterRenderer()->removeInstance(oldInst); + } + } + playerCharacterSpawned = false; + spawnedPlayerGuid_ = 0; + spawnedAppearanceBytes_ = 0; + spawnedFacialFeatures_ = 0; + playerRace_ = activeChar->race; playerGender_ = activeChar->gender; playerClass_ = activeChar->characterClass; spawnSnapToGround = false; + weaponsSheathed_ = false; + loadEquippedWeapons(); // will no-op until instance exists spawnPlayerCharacter(); } renderer->getCharacterPosition() = spawnRender; diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 12c4d171..cf19b224 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1485,6 +1485,10 @@ void GameHandler::requestCharacterList() { LOG_INFO("Requesting character list from server..."); + // Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM. + // This matters after character create/delete where the old list can linger for a few frames. + characters.clear(); + // Build CMSG_CHAR_ENUM packet (no body, just opcode) auto packet = CharEnumPacket::build(); @@ -1663,6 +1667,10 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { return; } + // Make the selected character authoritative in GameHandler. + // This avoids relying on UI/Application ordering for appearance-dependent logic. + activeCharacterGuid_ = characterGuid; + LOG_INFO("========================================"); LOG_INFO(" ENTERING WORLD"); LOG_INFO("========================================"); @@ -5980,6 +5988,28 @@ void GameHandler::autoEquipItemBySlot(int backpackIndex) { } } +void GameHandler::unequipToBackpack(EquipSlot equipSlot) { + if (state != WorldState::IN_WORLD || !socket) return; + + int freeSlot = inventory.findFreeBackpackSlot(); + if (freeSlot < 0) { + addSystemChatMessage("Cannot unequip: no free backpack slots."); + return; + } + + // Use SWAP_ITEM for cross-container moves. For inventory slots we address bag as 0xFF. + uint8_t srcBag = 0xFF; + uint8_t srcSlot = static_cast(equipSlot); + uint8_t dstBag = 0xFF; + uint8_t dstSlot = static_cast(23 + freeSlot); + + LOG_INFO("UnequipToBackpack: equipSlot=", (int)srcSlot, + " -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")"); + + auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot); + socket->send(packet); +} + void GameHandler::useItemBySlot(int backpackIndex) { if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; const auto& slot = inventory.getBackpackSlot(backpackIndex); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index bb1b6616..61f8ff98 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2398,6 +2398,22 @@ network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { return packet; } +network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot) { + network::Packet packet(static_cast(Opcode::CMSG_SWAP_ITEM)); + packet.writeUInt8(dstBag); + packet.writeUInt8(dstSlot); + packet.writeUInt8(srcBag); + packet.writeUInt8(srcSlot); + return packet; +} + +network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) { + network::Packet packet(static_cast(Opcode::CMSG_SWAP_INV_ITEM)); + packet.writeUInt8(srcSlot); + packet.writeUInt8(dstSlot); + return packet; +} + network::Packet LootMoneyPacket::build() { network::Packet packet(static_cast(Opcode::CMSG_LOOT_MONEY)); return packet; diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index b1de11ef..4604d62f 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace { constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024; @@ -130,6 +131,64 @@ void WorldSocket::send(const Packet& packet) { uint16_t opcode = packet.getOpcode(); uint16_t payloadLen = static_cast(data.size()); + // Debug: parse and log character-create payload fields (helps diagnose appearance issues). + if (opcode == 0x036) { // CMSG_CHAR_CREATE + size_t pos = 0; + std::string name; + while (pos < data.size()) { + uint8_t c = data[pos++]; + if (c == 0) break; + name.push_back(static_cast(c)); + } + auto rd8 = [&](uint8_t& out) -> bool { + if (pos >= data.size()) return false; + out = data[pos++]; + return true; + }; + uint8_t race = 0, cls = 0, gender = 0; + uint8_t skin = 0, face = 0, hairStyle = 0, hairColor = 0, facial = 0, outfit = 0; + bool ok = + rd8(race) && rd8(cls) && rd8(gender) && + rd8(skin) && rd8(face) && rd8(hairStyle) && rd8(hairColor) && rd8(facial) && rd8(outfit); + if (ok) { + LOG_INFO("CMSG_CHAR_CREATE payload: name='", name, + "' race=", (int)race, " class=", (int)cls, " gender=", (int)gender, + " skin=", (int)skin, " face=", (int)face, + " hairStyle=", (int)hairStyle, " hairColor=", (int)hairColor, + " facial=", (int)facial, " outfit=", (int)outfit, + " payloadLen=", payloadLen); + // Persist to disk so we can compare TX vs DB even if the console scrolls away. + std::ofstream f("charcreate_payload.log", std::ios::app); + if (f.is_open()) { + f << "name='" << name << "'" + << " race=" << (int)race + << " class=" << (int)cls + << " gender=" << (int)gender + << " skin=" << (int)skin + << " face=" << (int)face + << " hairStyle=" << (int)hairStyle + << " hairColor=" << (int)hairColor + << " facial=" << (int)facial + << " outfit=" << (int)outfit + << " payloadLen=" << payloadLen + << "\n"; + } + } else { + LOG_WARNING("CMSG_CHAR_CREATE payload too short to parse (name='", name, + "' payloadLen=", payloadLen, " pos=", pos, ")"); + } + } + + if (opcode == 0x10C || opcode == 0x10D) { // CMSG_SWAP_ITEM / CMSG_SWAP_INV_ITEM + std::string hex; + for (size_t i = 0; i < data.size(); i++) { + char buf[4]; + snprintf(buf, sizeof(buf), "%02x ", data[i]); + hex += buf; + } + LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]"); + } + // WotLK 3.3.5 CMSG header (6 bytes total): // - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG) // - opcode (4 bytes, little-endian) diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 13e828ef..7cecd5e0 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -150,11 +150,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, uint32_t targetRaceId = static_cast(race); uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u; - std::string bodySkinPath; std::string faceLowerPath; std::string faceUpperPath; std::string hairScalpPath; std::vector underwearPaths; + bodySkinPath_.clear(); + baseLayers_.clear(); auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc"); if (charSectionsDbc) { @@ -177,7 +178,7 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, variationIndex == 0 && colorIndex == static_cast(skin)) { std::string tex1 = charSectionsDbc->getString(r, 4); if (!tex1.empty()) { - bodySkinPath = tex1; + bodySkinPath_ = tex1; foundSkin = true; } } @@ -217,8 +218,8 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, // Assign texture filenames on model before GPU upload for (auto& tex : model.textures) { - if (tex.type == 1 && tex.filename.empty() && !bodySkinPath.empty()) { - tex.filename = bodySkinPath; + if (tex.type == 1 && tex.filename.empty() && !bodySkinPath_.empty()) { + tex.filename = bodySkinPath_; } else if (tex.type == 6 && tex.filename.empty() && !hairScalpPath.empty()) { tex.filename = hairScalpPath; } @@ -247,9 +248,9 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, } // Composite body skin + face + underwear overlays - if (!bodySkinPath.empty()) { + if (!bodySkinPath_.empty()) { std::vector layers; - layers.push_back(bodySkinPath); + layers.push_back(bodySkinPath_); // Face lower texture composited onto body at the face region if (!faceLowerPath.empty()) { layers.push_back(faceLowerPath); @@ -261,6 +262,12 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, layers.push_back(up); } + // Cache for later equipment compositing. + // Keep baseLayers_ without the base skin (compositeWithRegions takes basePath separately). + if (!faceLowerPath.empty()) baseLayers_.push_back(faceLowerPath); + if (!faceUpperPath.empty()) baseLayers_.push_back(faceUpperPath); + for (const auto& up : underwearPaths) baseLayers_.push_back(up); + if (layers.size() > 1) { GLuint compositeTex = charRenderer_->compositeTextures(layers); if (compositeTex != 0) { @@ -319,6 +326,22 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, // Play idle animation (Stand = animation ID 0) charRenderer_->playAnimation(instanceId_, 0, true); + // Cache core appearance for later equipment geosets. + race_ = race; + gender_ = gender; + useFemaleModel_ = useFemaleModel; + hairStyle_ = hairStyle; + facialHair_ = facialHair; + + // Cache the type-1 texture slot index so applyEquipment can update it. + skinTextureSlotIndex_ = 0; + for (size_t ti = 0; ti < model.textures.size(); ti++) { + if (model.textures[ti].type == 1) { + skinTextureSlotIndex_ = static_cast(ti); + break; + } + } + modelLoaded_ = true; LOG_INFO("CharacterPreview: loaded ", m2Path, " skin=", (int)skin, " face=", (int)face, @@ -327,6 +350,150 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, return true; } +bool CharacterPreview::applyEquipment(const std::vector& equipment) { + if (!modelLoaded_ || instanceId_ == 0 || !charRenderer_ || !assetManager_ || !assetManager_->isInitialized()) { + return false; + } + + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc || !displayInfoDbc->isLoaded()) { + return false; + } + + auto hasInvType = [&](std::initializer_list types) -> bool { + for (const auto& it : equipment) { + if (it.displayModel == 0) continue; + for (uint8_t t : types) { + if (it.inventoryType == t) return true; + } + } + return false; + }; + + auto findDisplayId = [&](std::initializer_list types) -> uint32_t { + for (const auto& it : equipment) { + if (it.displayModel == 0) continue; + for (uint8_t t : types) { + if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum) + } + } + return 0; + }; + + auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t { + if (displayInfoId == 0) return 0; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return 0; + return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + }; + + // --- Geosets --- + std::unordered_set geosets; + for (uint16_t i = 0; i <= 18; i++) geosets.insert(i); + geosets.insert(static_cast(100 + hairStyle_ + 1)); // Hair style + geosets.insert(static_cast(200 + facialHair_ + 1)); // Facial hair + geosets.insert(701); // Ears + + // Default naked geosets + uint16_t geosetGloves = 301; + uint16_t geosetBoots = 401; + uint16_t geosetChest = 501; + uint16_t geosetPants = 1301; + + // Chest/Shirt/Robe + { + uint32_t did = findDisplayId({4, 5, 20}); + uint32_t gg = getGeosetGroup(did, 0); + if (gg > 0) geosetChest = static_cast(501 + gg); + // Robe kilt legs + uint32_t gg3 = getGeosetGroup(did, 2); + if (gg3 > 0) geosetPants = static_cast(1301 + gg3); + } + // Legs + { + uint32_t did = findDisplayId({7}); + uint32_t gg = getGeosetGroup(did, 0); + if (gg > 0) geosetPants = static_cast(1301 + gg); + } + // Feet + { + uint32_t did = findDisplayId({8}); + uint32_t gg = getGeosetGroup(did, 0); + if (gg > 0) geosetBoots = static_cast(401 + gg); + } + // Hands + { + uint32_t did = findDisplayId({10}); + uint32_t gg = getGeosetGroup(did, 0); + if (gg > 0) geosetGloves = static_cast(301 + gg); + } + + geosets.insert(geosetGloves); + geosets.insert(geosetBoots); + geosets.insert(geosetChest); + geosets.insert(geosetPants); + geosets.insert(hasInvType({16}) ? 1502 : 1501); // Cloak mesh toggle (visual may still be limited) + if (hasInvType({19})) geosets.insert(1201); // Tabard mesh toggle + + // Hide hair under helmets (helmets are separate models; this still avoids hair clipping) + if (hasInvType({1})) { + geosets.erase(static_cast(100 + hairStyle_ + 1)); + geosets.insert(1); // Bald scalp cap + geosets.insert(101); // Default group-1 connector + } + + charRenderer_->setActiveGeosets(instanceId_, geosets); + + // --- Textures (equipment overlays onto body skin) --- + if (bodySkinPath_.empty()) return true; // geosets applied, but can't composite + + static const char* componentDirs[] = { + "ArmUpperTexture", "ArmLowerTexture", "HandTexture", + "TorsoUpperTexture", "TorsoLowerTexture", + "LegUpperTexture", "LegLowerTexture", "FootTexture", + }; + + std::vector> regionLayers; + regionLayers.reserve(32); + + for (const auto& it : equipment) { + if (it.displayModel == 0) continue; + int32_t recIdx = displayInfoDbc->findRecordById(it.displayModel); + if (recIdx < 0) continue; + + for (int region = 0; region < 8; region++) { + uint32_t fieldIdx = 15 + region; // texture_1..texture_8 + std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); + if (texName.empty()) continue; + + std::string base = "Item\\TextureComponents\\" + + std::string(componentDirs[region]) + "\\" + texName; + + std::string genderSuffix = (gender_ == game::Gender::FEMALE) ? "_F.blp" : "_M.blp"; + std::string genderPath = base + genderSuffix; + 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); + } + } + + if (!regionLayers.empty()) { + GLuint newTex = charRenderer_->compositeWithRegions(bodySkinPath_, baseLayers_, regionLayers); + if (newTex != 0) { + charRenderer_->setModelTexture(PREVIEW_MODEL_ID, skinTextureSlotIndex_, newTex); + } + } + + return true; +} + void CharacterPreview::update(float deltaTime) { if (charRenderer_ && modelLoaded_) { charRenderer_->update(deltaTime); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 25787897..455b0b1c 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1257,16 +1257,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons // Bind VAO and draw glBindVertexArray(gpuModel.vao); - if (!gpuModel.data.batches.empty()) { - bool applyGeosetFilter = !instance.activeGeosets.empty(); - if (applyGeosetFilter) { - bool hasRenderableGeoset = false; - for (const auto& batch : gpuModel.data.batches) { - if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) { - hasRenderableGeoset = true; - break; - } - } + if (!gpuModel.data.batches.empty()) { + bool applyGeosetFilter = !instance.activeGeosets.empty(); + if (applyGeosetFilter) { + bool hasRenderableGeoset = false; + for (const auto& batch : gpuModel.data.batches) { + if (instance.activeGeosets.find(batch.submeshId) != instance.activeGeosets.end()) { + hasRenderableGeoset = true; + break; + } + } if (!hasRenderableGeoset) { static std::unordered_set loggedGeosetFallback; if (loggedGeosetFallback.insert(instance.id).second) { @@ -1274,13 +1274,64 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons instance.id, " (model ", instance.modelId, "); rendering all batches as fallback"); } - applyGeosetFilter = false; - } - } + applyGeosetFilter = false; + } + } - // One-time debug dump of rendered batches per model - static std::unordered_set dumpedModels; - if (dumpedModels.find(instance.modelId) == dumpedModels.end()) { + auto resolveBatchTexture = [&](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. + // + // This matters for hair: the first texture in the combo can be a mask/empty slot, + // causing the hair to render as solid white. + if (b.textureIndex == 0xFFFF) return whiteTexture; + if (gm.data.textureLookup.empty() || gm.textureIds.empty()) return whiteTexture; + + uint32_t comboCount = b.textureCount ? static_cast(b.textureCount) : 1u; + comboCount = std::min(comboCount, 8u); + + struct Candidate { GLuint id; uint32_t type; }; + Candidate first{whiteTexture, 0}; + bool hasFirst = false; + Candidate firstNonWhite{whiteTexture, 0}; + bool hasFirstNonWhite = false; + + for (uint32_t i = 0; i < comboCount; i++) { + uint32_t lookupPos = static_cast(b.textureIndex) + i; + if (lookupPos >= gm.data.textureLookup.size()) break; + uint16_t texSlot = gm.data.textureLookup[lookupPos]; + if (texSlot >= gm.textureIds.size()) continue; + + GLuint texId = gm.textureIds[texSlot]; + uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; + + if (!hasFirst) { + first = {texId, texType}; + hasFirst = true; + } + + if (texId == 0 || texId == whiteTexture) continue; + + // Prefer the hair texture slot (type 6) whenever present in the combo. + // Humanoid scalp meshes can live in group 0, so group-based checks are insufficient. + if (texType == 6) { + return texId; + } + + if (!hasFirstNonWhite) { + firstNonWhite = {texId, texType}; + hasFirstNonWhite = true; + } + } + + if (hasFirstNonWhite) return firstNonWhite.id; + if (hasFirst && first.id != 0) return first.id; + return whiteTexture; + }; + + // One-time debug dump of rendered batches per model + static std::unordered_set dumpedModels; + if (dumpedModels.find(instance.modelId) == dumpedModels.end()) { dumpedModels.insert(instance.modelId); int bIdx = 0; int rendered = 0, skipped = 0; @@ -1289,24 +1340,11 @@ 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 = whiteTexture; - std::string texInfo = "white(fallback)"; - if (b.textureIndex != 0xFFFF && b.textureIndex < gpuModel.data.textureLookup.size()) { - uint16_t lk = gpuModel.data.textureLookup[b.textureIndex]; - if (lk < gpuModel.textureIds.size()) { - resolvedTex = gpuModel.textureIds[lk]; - texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->tex[" + std::to_string(lk) + "]=GL" + std::to_string(resolvedTex); - } else { - texInfo = "lookup[" + std::to_string(b.textureIndex) + "]->OOB(" + std::to_string(lk) + ")"; - } - } else if (b.textureIndex == 0xFFFF) { - texInfo = "texIdx=FFFF"; - } else { - texInfo = "texIdx=" + std::to_string(b.textureIndex) + " OOB(lookupSz=" + std::to_string(gpuModel.data.textureLookup.size()) + ")"; - } + GLuint resolvedTex = resolveBatchTexture(gpuModel, b); + std::string texInfo = "GL" + std::to_string(resolvedTex); - if (filtered) skipped++; else rendered++; - LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId, + if (filtered) skipped++; else rendered++; + LOG_DEBUG("Batch ", bIdx, ": submesh=", b.submeshId, " level=", b.submeshLevel, " idxStart=", b.indexStart, " idxCount=", b.indexCount, " tex=", texInfo, @@ -1317,28 +1355,22 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons gpuModel.textureIds.size(), " textures loaded, ", gpuModel.data.textureLookup.size(), " in lookup table"); for (size_t t = 0; t < gpuModel.data.textures.size(); t++) { - } - } + } + } - // Draw batches (submeshes) with per-batch textures - // Geoset filtering: skip batches whose submeshId is not in activeGeosets. - // For character models, group 0 (body/scalp) is also filtered so that only - // the correct scalp mesh renders (not all overlapping variants). - for (const auto& batch : gpuModel.data.batches) { + // Draw batches (submeshes) with per-batch textures + // Geoset filtering: skip batches whose submeshId is not in activeGeosets. + // For character models, group 0 (body/scalp) is also filtered so that only + // the correct scalp mesh renders (not all overlapping variants). + for (const auto& batch : gpuModel.data.batches) { if (applyGeosetFilter) { if (instance.activeGeosets.find(batch.submeshId) == instance.activeGeosets.end()) { continue; } } - // Resolve texture for this batch - GLuint texId = whiteTexture; - if (batch.textureIndex < gpuModel.data.textureLookup.size()) { - uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex]; - if (lookupIdx < gpuModel.textureIds.size()) { - texId = gpuModel.textureIds[lookupIdx]; - } - } + // Resolve texture for this batch (prefer hair textures for hair geosets). + GLuint texId = resolveBatchTexture(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 diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index bf436188..b7279cde 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -1231,7 +1231,12 @@ void Renderer::updateCharacterAnimation() { // Keep seat offset minimal; large offsets amplify visible bobble. glm::vec3 seatOffset = glm::vec3(0.0f, 0.0f, taxiFlight_ ? 0.04f : 0.08f); glm::vec3 targetRiderPos = mountSeatPos + seatOffset; - if (!mountSeatSmoothingInit_) { + // When moving, smoothing the seat position produces visible lag that looks like + // the rider sliding toward the rump. Anchor rigidly while moving. + if (moving) { + mountSeatSmoothingInit_ = false; + smoothedMountSeatPos_ = targetRiderPos; + } else if (!mountSeatSmoothingInit_) { smoothedMountSeatPos_ = targetRiderPos; mountSeatSmoothingInit_ = true; } else { diff --git a/src/ui/character_create_screen.cpp b/src/ui/character_create_screen.cpp index 769826bf..217b6ef7 100644 --- a/src/ui/character_create_screen.cpp +++ b/src/ui/character_create_screen.cpp @@ -49,6 +49,7 @@ void CharacterCreateScreen::reset() { maxFacialHair = 8; statusMessage.clear(); statusIsError = false; + hairColorIds_.clear(); updateAvailableClasses(); // Reset preview tracking to force model reload on next render @@ -114,13 +115,19 @@ void CharacterCreateScreen::updatePreviewIfNeeded() { if (changed) { bool useFemaleModel = (genderIndex == 2 && bodyTypeIndex == 1); // Nonbinary + Feminine + uint8_t hairColorId = 0; + if (!hairColorIds_.empty() && hairColor >= 0 && hairColor < static_cast(hairColorIds_.size())) { + hairColorId = hairColorIds_[hairColor]; + } else { + hairColorId = static_cast(hairColor); + } preview_->loadCharacter( allRaces[raceIndex], static_cast(genderIndex), static_cast(skin), static_cast(face), static_cast(hairStyle), - static_cast(hairColor), + hairColorId, static_cast(facialHair), useFemaleModel); @@ -153,6 +160,7 @@ void CharacterCreateScreen::updateAppearanceRanges() { maxHairStyle = 11; maxHairColor = 9; maxFacialHair = 8; + hairColorIds_.clear(); if (!assetManager_) return; auto dbc = assetManager_->loadDBC("CharSections.dbc"); @@ -189,7 +197,7 @@ void CharacterCreateScreen::updateAppearanceRanges() { } int faceMax = -1; - int hairColorMax = -1; + std::vector hairColorIds; for (uint32_t r = 0; r < dbc->getRecordCount(); r++) { uint32_t raceId = dbc->getUInt32(r, 1); uint32_t sexId = dbc->getUInt32(r, 2); @@ -202,7 +210,9 @@ void CharacterCreateScreen::updateAppearanceRanges() { if (baseSection == 1 && colorIndex == static_cast(skin)) { faceMax = std::max(faceMax, static_cast(variationIndex)); } else if (baseSection == 3 && variationIndex == static_cast(hairStyle)) { - hairColorMax = std::max(hairColorMax, static_cast(colorIndex)); + if (colorIndex <= 255) { + hairColorIds.push_back(static_cast(colorIndex)); + } } } @@ -210,9 +220,15 @@ void CharacterCreateScreen::updateAppearanceRanges() { maxFace = faceMax; if (face > maxFace) face = maxFace; } - if (hairColorMax >= 0) { - maxHairColor = hairColorMax; + + // Hair colors: use actual available DBC IDs (not "0..maxId"), since IDs may be sparse. + if (!hairColorIds.empty()) { + std::sort(hairColorIds.begin(), hairColorIds.end()); + hairColorIds.erase(std::unique(hairColorIds.begin(), hairColorIds.end()), hairColorIds.end()); + hairColorIds_ = std::move(hairColorIds); + maxHairColor = std::max(0, static_cast(hairColorIds_.size()) - 1); if (hairColor > maxHairColor) hairColor = maxHairColor; + if (hairColor < 0) hairColor = 0; } int facialMax = -1; auto facialDbc = assetManager_->loadDBC("CharacterFacialHairStyles.dbc"); @@ -450,7 +466,11 @@ void CharacterCreateScreen::render(game::GameHandler& /*gameHandler*/) { data.skin = static_cast(skin); data.face = static_cast(face); data.hairStyle = static_cast(hairStyle); - data.hairColor = static_cast(hairColor); + if (!hairColorIds_.empty() && hairColor >= 0 && hairColor < static_cast(hairColorIds_.size())) { + data.hairColor = hairColorIds_[hairColor]; + } else { + data.hairColor = static_cast(hairColor); + } data.facialHair = static_cast(facialHair); if (onCreate) { onCreate(data); diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 5efb3c70..60384cd6 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -1,4 +1,8 @@ #include "ui/character_screen.hpp" +#include "rendering/character_preview.hpp" +#include "pipeline/asset_manager.hpp" +#include "core/application.hpp" +#include "core/logger.hpp" #include #include #include @@ -11,26 +15,59 @@ namespace wowee { namespace ui { CharacterScreen::CharacterScreen() { } -void CharacterScreen::render(game::GameHandler& gameHandler) { - // Size the window to fill most of the viewport - ImVec2 vpSize = ImGui::GetMainViewport()->Size; - ImVec2 winSize(vpSize.x * 0.6f, vpSize.y * 0.7f); - if (winSize.x < 700.0f) winSize.x = 700.0f; - if (winSize.y < 500.0f) winSize.y = 500.0f; - ImGui::SetNextWindowSize(winSize, ImGuiCond_FirstUseEver); - ImGui::SetNextWindowPos( - ImVec2(vpSize.x * 0.5f, vpSize.y * 0.5f), - ImGuiCond_FirstUseEver, ImVec2(0.5f, 0.5f)); +static uint64_t hashEquipment(const std::vector& eq) { + // FNV-1a 64-bit over (displayModel, inventoryType, enchantment) + uint64_t h = 1469598103934665603ull; + auto mix8 = [&](uint8_t b) { + h ^= b; + h *= 1099511628211ull; + }; + auto mix32 = [&](uint32_t v) { + mix8(static_cast(v & 0xFF)); + mix8(static_cast((v >> 8) & 0xFF)); + mix8(static_cast((v >> 16) & 0xFF)); + mix8(static_cast((v >> 24) & 0xFF)); + }; + for (const auto& it : eq) { + mix32(it.displayModel); + mix8(it.inventoryType); + mix32(it.enchantment); + } + return h; +} - ImGui::Begin("Character Selection", nullptr, ImGuiWindowFlags_NoCollapse); +void CharacterScreen::render(game::GameHandler& gameHandler) { + ImGuiViewport* vp = ImGui::GetMainViewport(); + const ImVec2 pad(24.0f, 24.0f); + ImVec2 winSize(vp->Size.x - pad.x * 2.0f, vp->Size.y - pad.y * 2.0f); + if (winSize.x < 860.0f) winSize.x = 860.0f; + if (winSize.y < 620.0f) winSize.y = 620.0f; + + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + (vp->Size.x - winSize.x) * 0.5f, + vp->Pos.y + (vp->Size.y - winSize.y) * 0.5f), + ImGuiCond_Always); + ImGui::SetNextWindowSize(winSize, ImGuiCond_Always); + + ImGui::Begin("Character Selection", nullptr, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove); + + // Ensure we can render a preview even if the state transition hook didn't inject the AssetManager. + if (!assetManager_) { + assetManager_ = core::Application::getInstance().getAssetManager(); + } // Get character list const auto& characters = gameHandler.getCharacters(); - // Request character list if not available - if (characters.empty() && gameHandler.getState() == game::WorldState::READY) { + // 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() && + (gameHandler.getState() == game::WorldState::READY || + gameHandler.getState() == game::WorldState::CHAR_LIST_REQUESTED)) { ImGui::Text("Loading characters..."); - gameHandler.requestCharacterList(); + if (gameHandler.getState() == game::WorldState::READY) { + gameHandler.requestCharacterList(); + } ImGui::End(); return; } @@ -54,6 +91,22 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { return; } + // If the list refreshed, keep selection stable by GUID. + if (selectedCharacterGuid != 0) { + const bool needReselect = + (selectedCharacterIndex < 0) || + (selectedCharacterIndex >= static_cast(characters.size())) || + (characters[static_cast(selectedCharacterIndex)].guid != selectedCharacterGuid); + if (needReselect) { + for (size_t i = 0; i < characters.size(); ++i) { + if (characters[i].guid == selectedCharacterGuid) { + selectedCharacterIndex = static_cast(i); + break; + } + } + } + } + // Restore last-selected character (once per screen visit) if (!restoredLastCharacter) { // Priority 1: Select newly created character if set @@ -99,7 +152,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { // ── Two-column layout: character list (left) | details (right) ── float availW = ImGui::GetContentRegionAvail().x; - float detailPanelW = 260.0f; + float detailPanelW = 360.0f; float listW = availW - detailPanelW - ImGui::GetStyle().ItemSpacing.x; if (listW < 300.0f) { listW = availW; detailPanelW = 0.0f; } @@ -174,9 +227,83 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { const auto& character = characters[selectedCharacterIndex]; + // Keep the 3D preview in sync with the selected character. + if (assetManager_ && assetManager_->isInitialized()) { + if (!preview_) { + preview_ = std::make_unique(); + } + if (!previewInitialized_) { + previewInitialized_ = preview_->initialize(assetManager_); + if (!previewInitialized_) { + LOG_WARNING("CharacterScreen: failed to init CharacterPreview"); + preview_.reset(); + } + } + if (preview_) { + const uint64_t equipHash = hashEquipment(character.equipment); + const bool changed = + (previewGuid_ != character.guid) || + (previewAppearanceBytes_ != character.appearanceBytes) || + (previewFacialFeatures_ != character.facialFeatures) || + (previewUseFemaleModel_ != character.useFemaleModel) || + (previewEquipHash_ != equipHash) || + (!preview_->isModelLoaded()); + + if (changed) { + uint8_t skin = character.appearanceBytes & 0xFF; + uint8_t face = (character.appearanceBytes >> 8) & 0xFF; + uint8_t hairStyle = (character.appearanceBytes >> 16) & 0xFF; + uint8_t hairColor = (character.appearanceBytes >> 24) & 0xFF; + + if (preview_->loadCharacter(character.race, character.gender, + skin, face, hairStyle, hairColor, + character.facialFeatures, character.useFemaleModel)) { + preview_->applyEquipment(character.equipment); + } + + previewGuid_ = character.guid; + previewAppearanceBytes_ = character.appearanceBytes; + previewFacialFeatures_ = character.facialFeatures; + previewUseFemaleModel_ = character.useFemaleModel; + previewEquipHash_ = equipHash; + } + + // Drive preview animation and render to its FBO. + preview_->update(ImGui::GetIO().DeltaTime); + preview_->render(); + } + } + ImGui::SameLine(); ImGui::BeginChild("CharDetails", ImVec2(detailPanelW, listH), true); + // 3D preview portrait + if (preview_ && preview_->getTextureId() != 0) { + float imgW = ImGui::GetContentRegionAvail().x; + float imgH = imgW * (static_cast(preview_->getHeight()) / + static_cast(preview_->getWidth())); + // Clamp to avoid taking the entire panel + float maxH = 320.0f; + if (imgH > maxH) { + imgH = maxH; + imgW = imgH * (static_cast(preview_->getWidth()) / + static_cast(preview_->getHeight())); + } + ImGui::Image( + static_cast(preview_->getTextureId()), + ImVec2(imgW, imgH), + ImVec2(0.0f, 1.0f), // flip Y for OpenGL + ImVec2(1.0f, 0.0f)); + + if (ImGui::IsItemHovered() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + preview_->rotate(ImGui::GetIO().MouseDelta.x * 0.2f); + } + ImGui::Spacing(); + } else if (!assetManager_ || !assetManager_->isInitialized()) { + ImGui::TextDisabled("Preview unavailable (assets not loaded)"); + ImGui::Spacing(); + } + ImGui::TextColored(getFactionColor(character.race), "%s", character.name.c_str()); ImGui::Separator(); ImGui::Spacing(); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f4b3db29..dfc19292 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2201,8 +2201,20 @@ void GameScreen::updateCharacterGeosets(game::Inventory& inventory) { for (uint16_t i = 0; i <= 18; i++) { geosets.insert(i); } - geosets.insert(101); // Hair - geosets.insert(201); // Facial + // Hair/facial geosets must match the active character's appearance, otherwise + // we end up forcing a default hair mesh (often perceived as "wrong hair"). + { + uint8_t hairStyleId = 0; + uint8_t facialId = 0; + if (auto* gh = app.getGameHandler()) { + if (const auto* ch = gh->getActiveCharacter()) { + hairStyleId = static_cast((ch->appearanceBytes >> 16) & 0xFF); + facialId = ch->facialFeatures; + } + } + geosets.insert(static_cast(100 + hairStyleId + 1)); // Group 1 hair + geosets.insert(static_cast(200 + facialId + 1)); // Group 2 facial + } geosets.insert(701); // Ears // Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe) diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8fb471b6..3addb533 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1105,7 +1105,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { pickupFromBackpack(inventory, backpackIndex); } else if (kind == SlotKind::EQUIPMENT) { - pickupFromEquipment(inventory, equipSlot); + if (gameHandler_) { + // Online mode: don't mutate local equipment state. + game::MessageChatData msg{}; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Moving equipped items not supported yet (online mode)."; + gameHandler_->addLocalChatMessage(msg); + } else { + pickupFromEquipment(inventory, equipSlot); + } } } else { if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { @@ -1126,13 +1135,19 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite // Sell to vendor gameHandler_->sellItemBySlot(backpackIndex); } else if (kind == SlotKind::EQUIPMENT) { - // Unequip: move to free backpack slot - int freeSlot = inventory.findFreeBackpackSlot(); - if (freeSlot >= 0) { - inventory.setBackpackSlot(freeSlot, item); - inventory.clearEquipSlot(equipSlot); - equipmentDirty = true; - inventoryDirty = true; + if (gameHandler_) { + // Online mode: request server-side unequip (move to first free backpack slot). + LOG_INFO("UI unequip request: equipSlot=", (int)equipSlot); + gameHandler_->unequipToBackpack(equipSlot); + } else { + // Offline mode: Unequip: move to free backpack slot + int freeSlot = inventory.findFreeBackpackSlot(); + if (freeSlot >= 0) { + inventory.setBackpackSlot(freeSlot, item); + inventory.clearEquipSlot(equipSlot); + equipmentDirty = true; + inventoryDirty = true; + } } } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { if (gameHandler_) { diff --git a/src/ui/realm_screen.cpp b/src/ui/realm_screen.cpp index 9d6549bc..e2899a88 100644 --- a/src/ui/realm_screen.cpp +++ b/src/ui/realm_screen.cpp @@ -7,8 +7,18 @@ RealmScreen::RealmScreen() { } void RealmScreen::render(auth::AuthHandler& authHandler) { - ImGui::SetNextWindowSize(ImVec2(700, 500), ImGuiCond_FirstUseEver); - ImGui::Begin("Realm Selection", nullptr, ImGuiWindowFlags_NoCollapse); + ImGuiViewport* vp = ImGui::GetMainViewport(); + const ImVec2 pad(24.0f, 24.0f); + ImVec2 winSize(vp->Size.x - pad.x * 2.0f, vp->Size.y - pad.y * 2.0f); + if (winSize.x < 720.0f) winSize.x = 720.0f; + if (winSize.y < 540.0f) winSize.y = 540.0f; + + ImGui::SetNextWindowPos(ImVec2(vp->Pos.x + (vp->Size.x - winSize.x) * 0.5f, + vp->Pos.y + (vp->Size.y - winSize.y) * 0.5f), + ImGuiCond_Always); + ImGui::SetNextWindowSize(winSize, ImGuiCond_Always); + ImGui::Begin("Realm Selection", nullptr, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove); ImGui::Text("Select a Realm"); ImGui::Separator();