diff --git a/CMakeLists.txt b/CMakeLists.txt
index 432c0f65..101a92cf 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -487,6 +487,7 @@ set(WOWEE_SOURCES
# Core
src/core/application.cpp
src/core/entity_spawner.cpp
+ src/core/appearance_composer.cpp
src/core/window.cpp
src/core/input.cpp
src/core/logger.cpp
diff --git a/README.md b/README.md
index 4aeffc18..69e0a6dd 100644
--- a/README.md
+++ b/README.md
@@ -347,3 +347,13 @@ This project does not include any Blizzard Entertainment proprietary data, asset
## Known Issues
MANY issues this is actively under development
+
+## Star History
+
+
+
+
+
+
+
+
diff --git a/include/core/appearance_composer.hpp b/include/core/appearance_composer.hpp
new file mode 100644
index 00000000..138fd800
--- /dev/null
+++ b/include/core/appearance_composer.hpp
@@ -0,0 +1,101 @@
+#pragma once
+
+#include "game/character.hpp"
+#include
+#include
+#include
+#include
+
+namespace wowee {
+
+namespace rendering { class Renderer; }
+namespace pipeline { class AssetManager; class DBCLayout; struct M2Model; }
+namespace game { class GameHandler; }
+
+namespace core {
+
+class EntitySpawner;
+
+// Default (bare) geoset IDs per equipment group.
+// Each group's base is groupNumber * 100; variant 01 is typically bare/default.
+constexpr uint16_t kGeosetDefaultConnector = 101; // Group 1: default hair connector
+constexpr uint16_t kGeosetBareForearms = 401; // Group 4: no gloves
+constexpr uint16_t kGeosetBareShins = 503; // Group 5: no boots
+constexpr uint16_t kGeosetDefaultEars = 702; // Group 7: ears
+constexpr uint16_t kGeosetBareSleeves = 801; // Group 8: no chest armor sleeves
+constexpr uint16_t kGeosetDefaultKneepads = 902; // Group 9: kneepads
+constexpr uint16_t kGeosetDefaultTabard = 1201; // Group 12: tabard base
+constexpr uint16_t kGeosetBarePants = 1301; // Group 13: no leggings
+constexpr uint16_t kGeosetNoCape = 1501; // Group 15: no cape
+constexpr uint16_t kGeosetWithCape = 1502; // Group 15: with cape
+constexpr uint16_t kGeosetBareFeet = 2002; // Group 20: bare feet
+
+/// Resolved texture paths from CharSections.dbc for player character compositing.
+struct PlayerTextureInfo {
+ std::string bodySkinPath;
+ std::string faceLowerPath;
+ std::string faceUpperPath;
+ std::string hairTexturePath;
+ std::vector underwearPaths;
+};
+
+/// Handles player character visual appearance: skin compositing, geoset selection,
+/// texture path lookups, and equipment weapon rendering.
+class AppearanceComposer {
+public:
+ AppearanceComposer(rendering::Renderer* renderer,
+ pipeline::AssetManager* assetManager,
+ game::GameHandler* gameHandler,
+ pipeline::DBCLayout* dbcLayout,
+ EntitySpawner* entitySpawner);
+
+ // Player model path resolution
+ std::string getPlayerModelPath(game::Race race, game::Gender gender) const;
+
+ // Phase 1: Resolve texture paths from CharSections.dbc and fill model texture slots.
+ // Call BEFORE charRenderer->loadModel().
+ PlayerTextureInfo resolvePlayerTextures(pipeline::M2Model& model,
+ game::Race race, game::Gender gender,
+ uint32_t appearanceBytes);
+
+ // Phase 2: Apply composited textures to loaded model instance.
+ // Call AFTER charRenderer->loadModel(). Saves skin state for re-compositing.
+ void compositePlayerSkin(uint32_t modelSlotId, const PlayerTextureInfo& texInfo);
+
+ // Build default active geosets for player character
+ std::unordered_set buildDefaultPlayerGeosets(uint8_t hairStyleId, uint8_t facialId);
+
+ // Equipment weapon loading (reads inventory, attaches weapon M2 models)
+ void loadEquippedWeapons();
+
+ // Weapon sheathe state
+ void setWeaponsSheathed(bool sheathed) { weaponsSheathed_ = sheathed; }
+ bool isWeaponsSheathed() const { return weaponsSheathed_; }
+ void toggleWeaponsSheathed() { weaponsSheathed_ = !weaponsSheathed_; }
+
+ // Saved skin state accessors (used by game_screen.cpp for equipment re-compositing)
+ const std::string& getBodySkinPath() const { return bodySkinPath_; }
+ const std::vector& getUnderwearPaths() const { return underwearPaths_; }
+ uint32_t getSkinTextureSlotIndex() const { return skinTextureSlotIndex_; }
+ uint32_t getCloakTextureSlotIndex() const { return cloakTextureSlotIndex_; }
+
+private:
+ bool loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel);
+
+ rendering::Renderer* renderer_;
+ pipeline::AssetManager* assetManager_;
+ game::GameHandler* gameHandler_;
+ pipeline::DBCLayout* dbcLayout_;
+ EntitySpawner* entitySpawner_;
+
+ // Saved at spawn for skin re-compositing on equipment changes
+ std::string bodySkinPath_;
+ std::vector underwearPaths_;
+ uint32_t skinTextureSlotIndex_ = 0;
+ uint32_t cloakTextureSlotIndex_ = 0;
+
+ bool weaponsSheathed_ = false;
+};
+
+} // namespace core
+} // namespace wowee
diff --git a/include/core/application.hpp b/include/core/application.hpp
index a3f20984..1865aabf 100644
--- a/include/core/application.hpp
+++ b/include/core/application.hpp
@@ -3,6 +3,7 @@
#include "core/window.hpp"
#include "core/input.hpp"
#include "core/entity_spawner.hpp"
+#include "core/appearance_composer.hpp"
#include "game/character.hpp"
#include "game/game_services.hpp"
#include "pipeline/blp_loader.hpp"
@@ -73,9 +74,7 @@ public:
// Singleton access
static Application& getInstance() { return *instance; }
- // Weapon loading (called at spawn and on equipment change)
- void loadEquippedWeapons();
- bool loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel);
+
// Logout to login screen
void logoutToLogin();
@@ -85,23 +84,25 @@ public:
bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const;
bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const;
- // Character skin composite state (saved at spawn for re-compositing on equipment change)
- const std::string& getBodySkinPath() const { return bodySkinPath_; }
- const std::vector& getUnderwearPaths() const { return underwearPaths_; }
- uint32_t getSkinTextureSlotIndex() const { return skinTextureSlotIndex_; }
- uint32_t getCloakTextureSlotIndex() const { return cloakTextureSlotIndex_; }
+ // Character skin composite state — delegated to AppearanceComposer
+ const std::string& getBodySkinPath() const { return appearanceComposer_ ? appearanceComposer_->getBodySkinPath() : emptyString_; }
+ const std::vector& getUnderwearPaths() const { return appearanceComposer_ ? appearanceComposer_->getUnderwearPaths() : emptyStringVec_; }
+ uint32_t getSkinTextureSlotIndex() const { return appearanceComposer_ ? appearanceComposer_->getSkinTextureSlotIndex() : 0; }
+ uint32_t getCloakTextureSlotIndex() const { return appearanceComposer_ ? appearanceComposer_->getCloakTextureSlotIndex() : 0; }
uint32_t getGryphonDisplayId() const { return entitySpawner_ ? entitySpawner_->getGryphonDisplayId() : 0; }
uint32_t getWyvernDisplayId() const { return entitySpawner_ ? entitySpawner_->getWyvernDisplayId() : 0; }
// Entity spawner access
EntitySpawner* getEntitySpawner() { return entitySpawner_.get(); }
+ // Appearance composer access
+ AppearanceComposer* getAppearanceComposer() { return appearanceComposer_.get(); }
+
private:
void update(float deltaTime);
void render();
void setupUICallbacks();
void spawnPlayerCharacter();
- std::string getPlayerModelPath() const;
static const char* mapIdToName(uint32_t mapId);
static const char* mapDisplayName(uint32_t mapId);
void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z);
@@ -123,6 +124,7 @@ private:
std::unique_ptr expansionRegistry_;
std::unique_ptr dbcLayout_;
std::unique_ptr entitySpawner_;
+ std::unique_ptr appearanceComposer_;
AppState state = AppState::AUTHENTICATION;
bool running = false;
@@ -140,11 +142,9 @@ private:
uint32_t spawnedAppearanceBytes_ = 0;
uint8_t spawnedFacialFeatures_ = 0;
- // Saved at spawn for skin re-compositing
- std::string bodySkinPath_;
- std::vector underwearPaths_;
- uint32_t skinTextureSlotIndex_ = 0;
- uint32_t cloakTextureSlotIndex_ = 0;
+ // Static empty values for null-safe delegation
+ static inline const std::string emptyString_;
+ static inline const std::vector emptyStringVec_;
bool lastTaxiFlight_ = false;
uint32_t loadedMapId_ = 0xFFFFFFFF; // Map ID of currently loaded terrain (0xFFFFFFFF = none)
@@ -174,7 +174,6 @@ private:
glm::vec3 chargeEndPos_{0.0f}; // Render coordinates
uint64_t chargeTargetGuid_ = 0;
- bool weaponsSheathed_ = false;
bool wasAutoAttacking_ = false;
bool mapNameCacheLoaded_ = false;
std::unordered_map mapNameById_;
diff --git a/src/core/appearance_composer.cpp b/src/core/appearance_composer.cpp
new file mode 100644
index 00000000..cedd2c43
--- /dev/null
+++ b/src/core/appearance_composer.cpp
@@ -0,0 +1,383 @@
+#include "core/appearance_composer.hpp"
+#include "core/entity_spawner.hpp"
+#include "core/logger.hpp"
+#include "rendering/renderer.hpp"
+#include "rendering/character_renderer.hpp"
+#include "pipeline/asset_manager.hpp"
+#include "pipeline/m2_loader.hpp"
+#include "pipeline/dbc_loader.hpp"
+#include "pipeline/dbc_layout.hpp"
+#include "game/game_handler.hpp"
+
+namespace wowee {
+namespace core {
+
+AppearanceComposer::AppearanceComposer(rendering::Renderer* renderer,
+ pipeline::AssetManager* assetManager,
+ game::GameHandler* gameHandler,
+ pipeline::DBCLayout* dbcLayout,
+ EntitySpawner* entitySpawner)
+ : renderer_(renderer)
+ , assetManager_(assetManager)
+ , gameHandler_(gameHandler)
+ , dbcLayout_(dbcLayout)
+ , entitySpawner_(entitySpawner)
+{
+}
+
+std::string AppearanceComposer::getPlayerModelPath(game::Race race, game::Gender gender) const {
+ return game::getPlayerModelPath(race, gender);
+}
+
+PlayerTextureInfo AppearanceComposer::resolvePlayerTextures(pipeline::M2Model& model,
+ game::Race race, game::Gender gender,
+ uint32_t appearanceBytes) {
+ PlayerTextureInfo result;
+
+ uint32_t targetRaceId = static_cast(race);
+ uint32_t targetSexId = (gender == game::Gender::FEMALE) ? 1u : 0u;
+
+ // Race name for fallback texture paths
+ const char* raceFolderName = "Human";
+ switch (race) {
+ 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 = (gender == game::Gender::FEMALE) ? "Female" : "Male";
+ std::string raceGender = std::string(raceFolderName) + genderFolder;
+ result.bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp";
+ std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp";
+
+ // Extract appearance bytes for texture lookups
+ uint8_t charSkinId = appearanceBytes & 0xFF;
+ uint8_t charFaceId = (appearanceBytes >> 8) & 0xFF;
+ uint8_t charHairStyleId = (appearanceBytes >> 16) & 0xFF;
+ uint8_t charHairColorId = (appearanceBytes >> 24) & 0xFF;
+ LOG_INFO("Appearance: skin=", static_cast(charSkinId), " face=", static_cast(charFaceId),
+ " hairStyle=", static_cast(charHairStyleId), " hairColor=", static_cast(charHairColorId));
+
+ // Parse CharSections.dbc for skin/face/hair/underwear texture paths
+ auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc");
+ if (charSectionsDbc) {
+ LOG_INFO("CharSections.dbc loaded: ", charSectionsDbc->getRecordCount(), " records");
+ const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr;
+ auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL);
+ bool foundSkin = false;
+ bool foundUnderwear = false;
+ bool foundFaceLower = false;
+ bool foundHair = false;
+ for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
+ uint32_t raceId = charSectionsDbc->getUInt32(r, csF.raceId);
+ uint32_t sexId = charSectionsDbc->getUInt32(r, csF.sexId);
+ uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection);
+ uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex);
+ uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex);
+
+ if (raceId != targetRaceId || sexId != targetSexId) continue;
+
+ // Section 0 = skin: match by colorIndex = skin byte
+ if (baseSection == 0 && !foundSkin && colorIndex == charSkinId) {
+ std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
+ if (!tex1.empty()) {
+ result.bodySkinPath = tex1;
+ foundSkin = true;
+ LOG_INFO(" DBC body skin: ", result.bodySkinPath, " (skin=", static_cast(charSkinId), ")");
+ }
+ }
+ // Section 3 = hair: match variation=hairStyle, color=hairColor
+ else if (baseSection == 3 && !foundHair &&
+ variationIndex == charHairStyleId && colorIndex == charHairColorId) {
+ result.hairTexturePath = charSectionsDbc->getString(r, csF.texture1);
+ if (!result.hairTexturePath.empty()) {
+ foundHair = true;
+ LOG_INFO(" DBC hair texture: ", result.hairTexturePath,
+ " (style=", static_cast(charHairStyleId), " color=", static_cast(charHairColorId), ")");
+ }
+ }
+ // Section 1 = face: match variation=faceId, colorIndex=skinId
+ // Texture1 = face lower, Texture2 = face upper
+ else if (baseSection == 1 && !foundFaceLower &&
+ variationIndex == charFaceId && colorIndex == charSkinId) {
+ std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
+ std::string tex2 = charSectionsDbc->getString(r, csF.texture2);
+ if (!tex1.empty()) {
+ result.faceLowerPath = tex1;
+ LOG_INFO(" DBC face lower: ", result.faceLowerPath);
+ }
+ if (!tex2.empty()) {
+ result.faceUpperPath = tex2;
+ LOG_INFO(" DBC face upper: ", result.faceUpperPath);
+ }
+ foundFaceLower = true;
+ }
+ // Section 4 = underwear
+ else if (baseSection == 4 && !foundUnderwear && colorIndex == charSkinId) {
+ for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) {
+ std::string tex = charSectionsDbc->getString(r, f);
+ if (!tex.empty()) {
+ result.underwearPaths.push_back(tex);
+ LOG_INFO(" DBC underwear texture: ", tex);
+ }
+ }
+ foundUnderwear = true;
+ }
+
+ if (foundSkin && foundHair && foundFaceLower && foundUnderwear) break;
+ }
+
+ if (!foundHair) {
+ LOG_WARNING("No DBC hair match for style=", static_cast(charHairStyleId),
+ " color=", static_cast(charHairColorId),
+ " race=", targetRaceId, " sex=", targetSexId);
+ }
+ } else {
+ LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures");
+ }
+
+ // Fill model texture slots with resolved paths
+ for (auto& tex : model.textures) {
+ if (tex.type == 1 && tex.filename.empty()) {
+ tex.filename = result.bodySkinPath;
+ } else if (tex.type == 6) {
+ if (!result.hairTexturePath.empty()) {
+ tex.filename = result.hairTexturePath;
+ } else if (tex.filename.empty()) {
+ tex.filename = std::string("Character\\") + raceFolderName + "\\Hair00_00.blp";
+ }
+ } else if (tex.type == 8 && tex.filename.empty()) {
+ if (!result.underwearPaths.empty()) {
+ tex.filename = result.underwearPaths[0];
+ } else {
+ tex.filename = pelvisPath;
+ }
+ }
+ }
+
+ return result;
+}
+
+void AppearanceComposer::compositePlayerSkin(uint32_t modelSlotId, const PlayerTextureInfo& texInfo) {
+ if (!renderer_) return;
+ auto* charRenderer = renderer_->getCharacterRenderer();
+ if (!charRenderer) return;
+
+ // Save skin composite state for re-compositing on equipment changes
+ // Include face textures so compositeWithRegions can rebuild the full base
+ bodySkinPath_ = texInfo.bodySkinPath;
+ underwearPaths_.clear();
+ if (!texInfo.faceLowerPath.empty()) underwearPaths_.push_back(texInfo.faceLowerPath);
+ if (!texInfo.faceUpperPath.empty()) underwearPaths_.push_back(texInfo.faceUpperPath);
+ for (const auto& up : texInfo.underwearPaths) underwearPaths_.push_back(up);
+
+ // Composite body skin + face + underwear overlays
+ {
+ std::vector layers;
+ layers.push_back(texInfo.bodySkinPath);
+ if (!texInfo.faceLowerPath.empty()) layers.push_back(texInfo.faceLowerPath);
+ if (!texInfo.faceUpperPath.empty()) layers.push_back(texInfo.faceUpperPath);
+ for (const auto& up : texInfo.underwearPaths) {
+ layers.push_back(up);
+ }
+ if (layers.size() > 1) {
+ rendering::VkTexture* compositeTex = charRenderer->compositeTextures(layers);
+ if (compositeTex != 0) {
+ // Find type-1 (skin) texture slot and replace with composite
+ // We need model texture info — walk slots via charRenderer
+ // Use the model slot ID to find the right texture index
+ auto* modelData = charRenderer->getModelData(modelSlotId);
+ if (modelData) {
+ for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
+ if (modelData->textures[ti].type == 1) {
+ charRenderer->setModelTexture(modelSlotId, static_cast(ti), compositeTex);
+ skinTextureSlotIndex_ = static_cast(ti);
+ LOG_INFO("Replaced type-1 texture slot ", ti, " with composited body+face+underwear");
+ break;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Override hair texture on GPU (type-6 slot) after model load
+ if (!texInfo.hairTexturePath.empty()) {
+ rendering::VkTexture* hairTex = charRenderer->loadTexture(texInfo.hairTexturePath);
+ if (hairTex) {
+ auto* modelData = charRenderer->getModelData(modelSlotId);
+ if (modelData) {
+ for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
+ if (modelData->textures[ti].type == 6) {
+ charRenderer->setModelTexture(modelSlotId, static_cast(ti), hairTex);
+ LOG_INFO("Applied DBC hair texture to slot ", ti, ": ", texInfo.hairTexturePath);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Find cloak (type-2, Object Skin) texture slot index
+ {
+ auto* modelData = charRenderer->getModelData(modelSlotId);
+ if (modelData) {
+ for (size_t ti = 0; ti < modelData->textures.size(); ti++) {
+ if (modelData->textures[ti].type == 2) {
+ cloakTextureSlotIndex_ = static_cast(ti);
+ LOG_INFO("Cloak texture slot: ", ti);
+ break;
+ }
+ }
+ }
+ }
+}
+
+std::unordered_set AppearanceComposer::buildDefaultPlayerGeosets(uint8_t hairStyleId, uint8_t facialId) {
+ std::unordered_set activeGeosets;
+ // Body parts (group 0: IDs 0-99, some models use up to 27)
+ for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i);
+
+ // 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(kGeosetBareForearms);
+ activeGeosets.insert(kGeosetBareShins);
+ activeGeosets.insert(kGeosetDefaultEars);
+ activeGeosets.insert(kGeosetBareSleeves);
+ activeGeosets.insert(kGeosetDefaultKneepads);
+ activeGeosets.insert(kGeosetBarePants);
+ activeGeosets.insert(kGeosetWithCape);
+ activeGeosets.insert(kGeosetBareFeet);
+ // 1703 = DK eye glow mesh — skip for normal characters
+ // Normal eyes are part of the face texture on the body mesh
+ return activeGeosets;
+}
+
+bool AppearanceComposer::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) {
+ auto m2Data = assetManager_->readFile(m2Path);
+ if (m2Data.empty()) return false;
+ outModel = pipeline::M2Loader::load(m2Data);
+ // Load skin (WotLK+ M2 format): strip .m2, append 00.skin
+ std::string skinPath = m2Path;
+ size_t dotPos = skinPath.rfind('.');
+ if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos);
+ skinPath += "00.skin";
+ auto skinData = assetManager_->readFile(skinPath);
+ if (!skinData.empty() && outModel.version >= 264)
+ pipeline::M2Loader::loadSkin(skinData, outModel);
+ return outModel.isValid();
+}
+
+void AppearanceComposer::loadEquippedWeapons() {
+ if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized())
+ return;
+ if (!gameHandler_) return;
+
+ auto* charRenderer = renderer_->getCharacterRenderer();
+ uint32_t charInstanceId = renderer_->getCharacterInstanceId();
+ if (charInstanceId == 0) return;
+
+ auto& inventory = gameHandler_->getInventory();
+
+ // Load ItemDisplayInfo.dbc
+ auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
+ if (!displayInfoDbc) {
+ LOG_WARNING("loadEquippedWeapons: failed to load ItemDisplayInfo.dbc");
+ return;
+ }
+ // Mapping: EquipSlot → attachment ID (1=RightHand, 2=LeftHand)
+ struct WeaponSlot {
+ game::EquipSlot slot;
+ uint32_t attachmentId;
+ };
+ WeaponSlot weaponSlots[] = {
+ { game::EquipSlot::MAIN_HAND, 1 },
+ { game::EquipSlot::OFF_HAND, 2 },
+ };
+
+ if (weaponsSheathed_) {
+ for (const auto& ws : weaponSlots) {
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ }
+ return;
+ }
+
+ for (const auto& ws : weaponSlots) {
+ const auto& equipSlot = inventory.getEquipSlot(ws.slot);
+
+ // If slot is empty or has no displayInfoId, detach any existing weapon
+ if (equipSlot.empty() || equipSlot.item.displayInfoId == 0) {
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+
+ uint32_t displayInfoId = equipSlot.item.displayInfoId;
+ int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
+ if (recIdx < 0) {
+ LOG_WARNING("loadEquippedWeapons: displayInfoId ", displayInfoId, " not found in DBC");
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+
+ const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
+ std::string modelName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModel"] : 1);
+ std::string textureName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3);
+
+ if (modelName.empty()) {
+ LOG_WARNING("loadEquippedWeapons: empty model name for displayInfoId ", displayInfoId);
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+
+ // Convert .mdx → .m2
+ std::string modelFile = modelName;
+ {
+ size_t dotPos = modelFile.rfind('.');
+ if (dotPos != std::string::npos) {
+ modelFile = modelFile.substr(0, dotPos) + ".m2";
+ } else {
+ modelFile += ".m2";
+ }
+ }
+
+ // Try Weapon directory first, then Shield
+ std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile;
+ pipeline::M2Model weaponModel;
+ if (!loadWeaponM2(m2Path, weaponModel)) {
+ m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile;
+ if (!loadWeaponM2(m2Path, weaponModel)) {
+ LOG_WARNING("loadEquippedWeapons: failed to load ", modelFile);
+ charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
+ continue;
+ }
+ }
+
+ // Build texture path
+ std::string texturePath;
+ if (!textureName.empty()) {
+ texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp";
+ if (!assetManager_->fileExists(texturePath)) {
+ texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp";
+ }
+ }
+
+ uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId();
+ bool ok = charRenderer->attachWeapon(charInstanceId, ws.attachmentId,
+ weaponModel, weaponModelId, texturePath);
+ if (ok) {
+ LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId);
+ }
+ }
+}
+
+} // namespace core
+} // namespace wowee
diff --git a/src/core/application.cpp b/src/core/application.cpp
index eb6cee00..433bc288 100644
--- a/src/core/application.cpp
+++ b/src/core/application.cpp
@@ -84,19 +84,6 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) {
raw[0] == 'n' || raw[0] == 'N');
}
-// Default (bare) geoset IDs per equipment group.
-// Each group's base is groupNumber * 100; variant 01 is typically bare/default.
-constexpr uint16_t kGeosetDefaultConnector = 101; // Group 1: default hair connector
-constexpr uint16_t kGeosetBareForearms = 401; // Group 4: no gloves
-constexpr uint16_t kGeosetBareShins = 503; // Group 5: no boots
-constexpr uint16_t kGeosetDefaultEars = 702; // Group 7: ears
-constexpr uint16_t kGeosetBareSleeves = 801; // Group 8: no chest armor sleeves
-constexpr uint16_t kGeosetDefaultKneepads = 902; // Group 9: kneepads
-constexpr uint16_t kGeosetDefaultTabard = 1201; // Group 12: tabard base
-constexpr uint16_t kGeosetBarePants = 1301; // Group 13: no leggings
-constexpr uint16_t kGeosetNoCape = 1501; // Group 15: no cape
-constexpr uint16_t kGeosetWithCape = 1502; // Group 15: with cape
-constexpr uint16_t kGeosetBareFeet = 2002; // Group 20: bare feet
} // namespace
@@ -204,10 +191,6 @@ const char* Application::mapIdToName(uint32_t mapId) {
}
}
-std::string Application::getPlayerModelPath() const {
- return game::getPlayerModelPath(playerRace_, playerGender_);
-}
-
Application* Application::instance = nullptr;
@@ -342,6 +325,10 @@ bool Application::initialize() {
dbcLayout_.get(), &gameServices_);
entitySpawner_->initialize();
+ appearanceComposer_ = std::make_unique(
+ renderer.get(), assetManager.get(), gameHandler.get(),
+ dbcLayout_.get(), entitySpawner_.get());
+
// 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).
@@ -970,7 +957,7 @@ void Application::setState(AppState newState) {
npcsSpawned = false;
playerCharacterSpawned = false;
addonsLoaded_ = false;
- weaponsSheathed_ = false;
+ if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
wasAutoAttacking_ = false;
loadedMapId_ = 0xFFFFFFFF;
spawnedPlayerGuid_ = 0;
@@ -1105,7 +1092,7 @@ void Application::logoutToLogin() {
// --- Per-session flags ---
npcsSpawned = false;
playerCharacterSpawned = false;
- weaponsSheathed_ = false;
+ if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
wasAutoAttacking_ = false;
loadedMapId_ = 0xFFFFFFFF;
lastTaxiFlight_ = false;
@@ -1241,9 +1228,9 @@ void Application::update(float deltaTime) {
updateCheckpoint = "in_game: auto-unsheathe";
if (gameHandler) {
const bool autoAttacking = gameHandler->isAutoAttacking();
- if (autoAttacking && !wasAutoAttacking_ && weaponsSheathed_) {
- weaponsSheathed_ = false;
- loadEquippedWeapons();
+ if (autoAttacking && !wasAutoAttacking_ && appearanceComposer_ && appearanceComposer_->isWeaponsSheathed()) {
+ appearanceComposer_->setWeaponsSheathed(false);
+ appearanceComposer_->loadEquippedWeapons();
}
wasAutoAttacking_ = autoAttacking;
}
@@ -1254,9 +1241,9 @@ void Application::update(float deltaTime) {
{
const bool uiWantsKeyboard = ImGui::GetIO().WantCaptureKeyboard;
auto& input = Input::getInstance();
- if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z)) {
- weaponsSheathed_ = !weaponsSheathed_;
- loadEquippedWeapons();
+ if (!uiWantsKeyboard && input.isKeyJustPressed(SDL_SCANCODE_Z) && appearanceComposer_) {
+ appearanceComposer_->toggleWeaponsSheathed();
+ appearanceComposer_->loadEquippedWeapons();
}
}
@@ -3606,7 +3593,7 @@ void Application::spawnPlayerCharacter() {
auto* charRenderer = renderer->getCharacterRenderer();
auto* camera = renderer->getCamera();
bool loaded = false;
- std::string m2Path = getPlayerModelPath();
+ std::string m2Path = appearanceComposer_->getPlayerModelPath(playerRace_, playerGender_);
std::string modelDir;
std::string baseName;
{
@@ -3643,144 +3630,18 @@ void Application::spawnPlayerCharacter() {
LOG_INFO(" Texture ", ti, ": type=", tex.type, " name='", tex.filename, "'");
}
- // Look up textures from CharSections.dbc for all races
+ // Resolve textures from CharSections.dbc via AppearanceComposer
+ PlayerTextureInfo texInfo;
bool useCharSections = true;
- uint32_t targetRaceId = static_cast(playerRace_);
- uint32_t targetSexId = (playerGender_ == game::Gender::FEMALE) ? 1u : 0u;
-
- // Race name for fallback texture paths
- const char* raceFolderName = "Human";
- switch (playerRace_) {
- 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 = (playerGender_ == game::Gender::FEMALE) ? "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::string faceLowerTexturePath;
- std::string faceUpperTexturePath;
- std::vector underwearPaths;
-
- // Extract appearance bytes for texture lookups
- uint8_t charSkinId = 0, charFaceId = 0, charHairStyleId = 0, charHairColorId = 0;
- if (gameHandler) {
- const game::Character* activeChar = gameHandler->getActiveCharacter();
- if (activeChar) {
- charSkinId = activeChar->appearanceBytes & 0xFF;
- charFaceId = (activeChar->appearanceBytes >> 8) & 0xFF;
- charHairStyleId = (activeChar->appearanceBytes >> 16) & 0xFF;
- charHairColorId = (activeChar->appearanceBytes >> 24) & 0xFF;
- LOG_INFO("Appearance: skin=", static_cast(charSkinId), " face=", static_cast(charFaceId),
- " hairStyle=", static_cast(charHairStyleId), " hairColor=", static_cast(charHairColorId));
- }
- }
-
- std::string hairTexturePath;
- if (useCharSections) {
- auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc");
- if (charSectionsDbc) {
- LOG_INFO("CharSections.dbc loaded: ", charSectionsDbc->getRecordCount(), " records");
- const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr;
- auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL);
- bool foundSkin = false;
- bool foundUnderwear = false;
- bool foundFaceLower = false;
- bool foundHair = false;
- for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
- uint32_t raceId = charSectionsDbc->getUInt32(r, csF.raceId);
- uint32_t sexId = charSectionsDbc->getUInt32(r, csF.sexId);
- uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection);
- uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex);
- uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex);
-
- if (raceId != targetRaceId || sexId != targetSexId) continue;
-
- // Section 0 = skin: match by colorIndex = skin byte
- if (baseSection == 0 && !foundSkin && colorIndex == charSkinId) {
- std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
- if (!tex1.empty()) {
- bodySkinPath = tex1;
- foundSkin = true;
- LOG_INFO(" DBC body skin: ", bodySkinPath, " (skin=", static_cast(charSkinId), ")");
- }
- }
- // Section 3 = hair: match variation=hairStyle, color=hairColor
- else if (baseSection == 3 && !foundHair &&
- variationIndex == charHairStyleId && colorIndex == charHairColorId) {
- hairTexturePath = charSectionsDbc->getString(r, csF.texture1);
- if (!hairTexturePath.empty()) {
- foundHair = true;
- LOG_INFO(" DBC hair texture: ", hairTexturePath,
- " (style=", static_cast(charHairStyleId), " color=", static_cast(charHairColorId), ")");
- }
- }
- // Section 1 = face: match variation=faceId, colorIndex=skinId
- // Texture1 = face lower, Texture2 = face upper
- else if (baseSection == 1 && !foundFaceLower &&
- variationIndex == charFaceId && colorIndex == charSkinId) {
- std::string tex1 = charSectionsDbc->getString(r, csF.texture1);
- std::string tex2 = charSectionsDbc->getString(r, csF.texture2);
- if (!tex1.empty()) {
- faceLowerTexturePath = tex1;
- LOG_INFO(" DBC face lower: ", faceLowerTexturePath);
- }
- if (!tex2.empty()) {
- faceUpperTexturePath = tex2;
- LOG_INFO(" DBC face upper: ", faceUpperTexturePath);
- }
- foundFaceLower = true;
- }
- // Section 4 = underwear
- else if (baseSection == 4 && !foundUnderwear && colorIndex == charSkinId) {
- for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) {
- std::string tex = charSectionsDbc->getString(r, f);
- if (!tex.empty()) {
- underwearPaths.push_back(tex);
- LOG_INFO(" DBC underwear texture: ", tex);
- }
- }
- foundUnderwear = true;
- }
-
- if (foundSkin && foundHair && foundFaceLower && foundUnderwear) break;
- }
-
- if (!foundHair) {
- LOG_WARNING("No DBC hair match for style=", static_cast(charHairStyleId),
- " color=", static_cast(charHairColorId),
- " race=", targetRaceId, " sex=", targetSexId);
- }
- } else {
- LOG_WARNING("Failed to load CharSections.dbc, using hardcoded textures");
- }
-
- for (auto& tex : model.textures) {
- if (tex.type == 1 && tex.filename.empty()) {
- tex.filename = bodySkinPath;
- } else if (tex.type == 6) {
- if (!hairTexturePath.empty()) {
- tex.filename = hairTexturePath;
- } else if (tex.filename.empty()) {
- tex.filename = std::string("Character\\") + raceFolderName + "\\Hair00_00.blp";
- }
- } else if (tex.type == 8 && tex.filename.empty()) {
- if (!underwearPaths.empty()) {
- tex.filename = underwearPaths[0];
- } else {
- tex.filename = pelvisPath;
- }
+ if (appearanceComposer_) {
+ uint32_t appearanceBytes = 0;
+ if (gameHandler) {
+ const game::Character* activeChar = gameHandler->getActiveCharacter();
+ if (activeChar) {
+ appearanceBytes = activeChar->appearanceBytes;
}
}
+ texInfo = appearanceComposer_->resolvePlayerTextures(model, playerRace_, playerGender_, appearanceBytes);
}
// Load external .anim files for sequences with external data.
@@ -3806,62 +3667,9 @@ void Application::spawnPlayerCharacter() {
charRenderer->loadModel(model, 1);
- if (useCharSections) {
- // Save skin composite state for re-compositing on equipment changes
- // Include face textures so compositeWithRegions can rebuild the full base
- bodySkinPath_ = bodySkinPath;
- underwearPaths_.clear();
- if (!faceLowerTexturePath.empty()) underwearPaths_.push_back(faceLowerTexturePath);
- if (!faceUpperTexturePath.empty()) underwearPaths_.push_back(faceUpperTexturePath);
- for (const auto& up : underwearPaths) underwearPaths_.push_back(up);
-
- // Composite body skin + face + underwear overlays
- {
- std::vector layers;
- layers.push_back(bodySkinPath);
- if (!faceLowerTexturePath.empty()) layers.push_back(faceLowerTexturePath);
- if (!faceUpperTexturePath.empty()) layers.push_back(faceUpperTexturePath);
- for (const auto& up : underwearPaths) {
- layers.push_back(up);
- }
- if (layers.size() > 1) {
- rendering::VkTexture* compositeTex = charRenderer->compositeTextures(layers);
- if (compositeTex != 0) {
- for (size_t ti = 0; ti < model.textures.size(); ti++) {
- if (model.textures[ti].type == 1) {
- charRenderer->setModelTexture(1, static_cast(ti), compositeTex);
- skinTextureSlotIndex_ = static_cast(ti);
- LOG_INFO("Replaced type-1 texture slot ", ti, " with composited body+face+underwear");
- break;
- }
- }
- }
- }
- }
- // Override hair texture on GPU (type-6 slot) after model load
- if (!hairTexturePath.empty()) {
- rendering::VkTexture* hairTex = charRenderer->loadTexture(hairTexturePath);
- if (hairTex) {
- for (size_t ti = 0; ti < model.textures.size(); ti++) {
- if (model.textures[ti].type == 6) {
- charRenderer->setModelTexture(1, static_cast(ti), hairTex);
- LOG_INFO("Applied DBC hair texture to slot ", ti, ": ", hairTexturePath);
- break;
- }
- }
- }
- }
- } else {
- bodySkinPath_.clear();
- underwearPaths_.clear();
- }
- // Find cloak (type-2, Object Skin) texture slot index
- for (size_t ti = 0; ti < model.textures.size(); ti++) {
- if (model.textures[ti].type == 2) {
- cloakTextureSlotIndex_ = static_cast(ti);
- LOG_INFO("Cloak texture slot: ", ti);
- break;
- }
+ // Apply composited textures via AppearanceComposer (saves skin state for re-compositing)
+ if (useCharSections && appearanceComposer_) {
+ appearanceComposer_->compositePlayerSkin(1, texInfo);
}
loaded = true;
@@ -3942,12 +3750,7 @@ void Application::spawnPlayerCharacter() {
renderer->getCharacterPosition() = spawnPos;
renderer->setCharacterFollow(instanceId);
- // 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: IDs 0-99, some models use up to 27)
- for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i);
-
+ // Build default geosets for the active character via AppearanceComposer
uint8_t hairStyleId = 0;
uint8_t facialId = 0;
if (gameHandler) {
@@ -3956,20 +3759,9 @@ void Application::spawnPlayerCharacter() {
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(kGeosetBareForearms);
- activeGeosets.insert(kGeosetBareShins);
- activeGeosets.insert(kGeosetDefaultEars);
- activeGeosets.insert(kGeosetBareSleeves);
- activeGeosets.insert(kGeosetDefaultKneepads);
- activeGeosets.insert(kGeosetBarePants);
- activeGeosets.insert(kGeosetWithCape);
- activeGeosets.insert(kGeosetBareFeet);
- // 1703 = DK eye glow mesh — skip for normal characters
- // Normal eyes are part of the face texture on the body mesh
+ auto activeGeosets = appearanceComposer_
+ ? appearanceComposer_->buildDefaultPlayerGeosets(hairStyleId, facialId)
+ : std::unordered_set{};
charRenderer->setActiveGeosets(instanceId, activeGeosets);
// Play idle animation (Stand = animation ID 0)
@@ -4024,124 +3816,7 @@ void Application::spawnPlayerCharacter() {
}
// Load equipped weapons (sword + shield)
- loadEquippedWeapons();
- }
-}
-
-bool Application::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) {
- auto m2Data = assetManager->readFile(m2Path);
- if (m2Data.empty()) return false;
- outModel = pipeline::M2Loader::load(m2Data);
- // Load skin (WotLK+ M2 format): strip .m2, append 00.skin
- std::string skinPath = m2Path;
- size_t dotPos = skinPath.rfind('.');
- if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos);
- skinPath += "00.skin";
- auto skinData = assetManager->readFile(skinPath);
- if (!skinData.empty() && outModel.version >= 264)
- pipeline::M2Loader::loadSkin(skinData, outModel);
- return outModel.isValid();
-}
-
-void Application::loadEquippedWeapons() {
- if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized())
- return;
- if (!gameHandler) return;
-
- auto* charRenderer = renderer->getCharacterRenderer();
- uint32_t charInstanceId = renderer->getCharacterInstanceId();
- if (charInstanceId == 0) return;
-
- auto& inventory = gameHandler->getInventory();
-
- // Load ItemDisplayInfo.dbc
- auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
- if (!displayInfoDbc) {
- LOG_WARNING("loadEquippedWeapons: failed to load ItemDisplayInfo.dbc");
- return;
- }
- // Mapping: EquipSlot → attachment ID (1=RightHand, 2=LeftHand)
- struct WeaponSlot {
- game::EquipSlot slot;
- uint32_t attachmentId;
- };
- WeaponSlot weaponSlots[] = {
- { game::EquipSlot::MAIN_HAND, 1 },
- { game::EquipSlot::OFF_HAND, 2 },
- };
-
- if (weaponsSheathed_) {
- for (const auto& ws : weaponSlots) {
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- }
- return;
- }
-
- for (const auto& ws : weaponSlots) {
- const auto& equipSlot = inventory.getEquipSlot(ws.slot);
-
- // If slot is empty or has no displayInfoId, detach any existing weapon
- if (equipSlot.empty() || equipSlot.item.displayInfoId == 0) {
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
-
- uint32_t displayInfoId = equipSlot.item.displayInfoId;
- int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
- if (recIdx < 0) {
- LOG_WARNING("loadEquippedWeapons: displayInfoId ", displayInfoId, " not found in DBC");
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
-
- const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
- std::string modelName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModel"] : 1);
- std::string textureName = displayInfoDbc->getString(static_cast(recIdx), idiL ? (*idiL)["LeftModelTexture"] : 3);
-
- if (modelName.empty()) {
- LOG_WARNING("loadEquippedWeapons: empty model name for displayInfoId ", displayInfoId);
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
-
- // Convert .mdx → .m2
- std::string modelFile = modelName;
- {
- size_t dotPos = modelFile.rfind('.');
- if (dotPos != std::string::npos) {
- modelFile = modelFile.substr(0, dotPos) + ".m2";
- } else {
- modelFile += ".m2";
- }
- }
-
- // Try Weapon directory first, then Shield
- std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile;
- pipeline::M2Model weaponModel;
- if (!loadWeaponM2(m2Path, weaponModel)) {
- m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile;
- if (!loadWeaponM2(m2Path, weaponModel)) {
- LOG_WARNING("loadEquippedWeapons: failed to load ", modelFile);
- charRenderer->detachWeapon(charInstanceId, ws.attachmentId);
- continue;
- }
- }
-
- // Build texture path
- std::string texturePath;
- if (!textureName.empty()) {
- texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp";
- if (!assetManager->fileExists(texturePath)) {
- texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp";
- }
- }
-
- uint32_t weaponModelId = entitySpawner_->allocateWeaponModelId();
- bool ok = charRenderer->attachWeapon(charInstanceId, ws.attachmentId,
- weaponModel, weaponModelId, texturePath);
- if (ok) {
- LOG_INFO("Equipped weapon: ", m2Path, " at attachment ", ws.attachmentId);
- }
+ if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
}
}
@@ -4514,8 +4189,8 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
playerGender_ = activeChar->gender;
playerClass_ = activeChar->characterClass;
spawnSnapToGround = false;
- weaponsSheathed_ = false;
- loadEquippedWeapons(); // will no-op until instance exists
+ if (appearanceComposer_) appearanceComposer_->setWeaponsSheathed(false);
+ if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons(); // will no-op until instance exists
spawnPlayerCharacter();
}
renderer->getCharacterPosition() = spawnRender;
@@ -4568,7 +4243,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// Spawn player character now that renderers are initialized
if (!playerCharacterSpawned) {
spawnPlayerCharacter();
- loadEquippedWeapons();
+ if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
}
// Load the root WMO
@@ -4810,7 +4485,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// player model now that the renderer actually exists.
if (!playerCharacterSpawned) {
spawnPlayerCharacter();
- loadEquippedWeapons();
+ if (appearanceComposer_) appearanceComposer_->loadEquippedWeapons();
}
showProgress("Streaming terrain tiles...", 0.35f);
diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp
index 12937d62..e7a2e271 100644
--- a/src/ui/game_screen.cpp
+++ b/src/ui/game_screen.cpp
@@ -631,7 +631,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
if (inventoryScreen.consumeEquipmentDirty() || gameHandler.consumeOnlineEquipmentDirty()) {
updateCharacterGeosets(gameHandler.getInventory());
updateCharacterTextures(gameHandler.getInventory());
- core::Application::getInstance().loadEquippedWeapons();
+ if (auto* ac = core::Application::getInstance().getAppearanceComposer()) ac->loadEquippedWeapons();
inventoryScreen.markPreviewDirty();
// Update renderer weapon type for animation selection
auto* r = core::Application::getInstance().getRenderer();