From ed6b305158c07e70072f575b5c2e19e49c582d89 Mon Sep 17 00:00:00 2001 From: Kelsi Date: Sun, 15 Feb 2026 20:53:01 -0800 Subject: [PATCH] Fix character geoset mapping and texture corruption on equipment change Corrected CharGeosets group assignments verified via vertex bounding boxes: - Group 4 (401+) = gloves/forearms, Group 5 (501+) = boots/shins, Group 8 (801+) = sleeves (chest-controlled), Group 9 = kneepads, Group 13 (1301+) = pants/trousers, Group 20 (2002) = bare feet - Changed bare shin default from 501 to 502 for better width match with thigh mesh (0.39 vs 0.32, thighs are 0.42) - Added clearCompositeCache() to prevent stale composite textures from being reused across equipment changes - Fixed character preview geoset defaults to match corrected mapping --- Data/expansions/wotlk/dbc_layouts.json | 5 +- include/pipeline/m2_loader.hpp | 3 + include/rendering/character_renderer.hpp | 6 +- src/core/application.cpp | 219 +++++++++++++++++------ src/game/game_handler.cpp | 8 +- src/pipeline/m2_loader.cpp | 6 + src/rendering/character_preview.cpp | 41 +++-- src/rendering/character_renderer.cpp | 208 +++++++++++++++------ src/ui/game_screen.cpp | 94 ++++++---- src/ui/inventory_screen.cpp | 3 +- 10 files changed, 424 insertions(+), 169 deletions(-) diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 0ce13fca..5cbcf2eb 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -12,8 +12,9 @@ }, "CharSections": { "RaceID": 1, "SexID": 2, "BaseSection": 3, - "Texture1": 4, "Texture2": 5, "Texture3": 6, - "Flags": 7, "VariationIndex": 8, "ColorIndex": 9 + "VariationIndex": 4, "ColorIndex": 5, + "Texture1": 6, "Texture2": 7, "Texture3": 8, + "Flags": 9 }, "SpellIcon": { "ID": 0, "Path": 1 }, "FactionTemplate": { diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index 346b8c1d..1b559d17 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -183,6 +183,9 @@ struct M2Model { std::vector sequences; std::vector globalSequenceDurations; // Per-global-sequence loop durations (ms) + // Bone lookup table (vertex bone indices reference this to get global bone index) + std::vector boneLookupTable; + // Rendering std::vector batches; std::vector textures; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 668819fa..b5ae1954 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -210,6 +210,9 @@ public: const std::vector& baseLayers, const std::vector>& regionLayers); + /** Clear the composite texture cache (forces re-compositing on next call). */ + void clearCompositeCache(); + /** Load a BLP texture from MPQ and return the GL texture ID (cached). */ GLuint loadTexture(const std::string& path); @@ -259,7 +262,8 @@ private: uint32_t nextInstanceId = 1; // Maximum bones supported (GPU uniform limit) - static constexpr int MAX_BONES = 200; + // WoW character models can have 210+ bones; GPU reports 4096 components (~256 mat4) + static constexpr int MAX_BONES = 240; }; } // namespace rendering diff --git a/src/core/application.cpp b/src/core/application.cpp index ac1a954d..a0ec45b4 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1191,6 +1191,14 @@ void Application::setupUICallbacks() { uint32_t appearanceBytes, uint8_t facialFeatures, float x, float y, float z, float orientation) { + // Skip local player — already spawned as the main character + uint64_t localGuid = gameHandler ? gameHandler->getPlayerGuid() : 0; + uint64_t activeGuid = gameHandler ? gameHandler->getActiveCharacterGuid() : 0; + if ((localGuid != 0 && guid == localGuid) || + (activeGuid != 0 && guid == activeGuid) || + (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { + return; + } if (playerInstances_.count(guid)) return; if (pendingPlayerSpawnGuids_.count(guid)) return; pendingPlayerSpawns_.push_back({guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation}); @@ -1947,8 +1955,12 @@ void Application::spawnPlayerCharacter() { 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_ = underwearPaths; + 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 { @@ -2080,8 +2092,8 @@ void Application::spawnPlayerCharacter() { // 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); + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); uint8_t hairStyleId = 0; uint8_t facialId = 0; @@ -2095,13 +2107,14 @@ void Application::spawnPlayerCharacter() { activeGeosets.insert(static_cast(100 + hairStyleId + 1)); // Facial hair geoset: group 2 = 200 + variation + 1 activeGeosets.insert(static_cast(200 + facialId + 1)); - activeGeosets.insert(302); // Gloves: bare hands - activeGeosets.insert(401); // Boots: bare feet - activeGeosets.insert(501); // Chest: bare + activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 + activeGeosets.insert(502); // Bare shins (no boots) — group 5 (wider mesh matches thighs) activeGeosets.insert(702); // Ears: default - activeGeosets.insert(802); // Wristbands: default - activeGeosets.insert(1301); // Trousers: bare legs - activeGeosets.insert(1502); // Back body (cloak=none) + activeGeosets.insert(801); // Bare wrists (no chest armor sleeves) — group 8 + activeGeosets.insert(902); // Kneepads: default — group 9 + activeGeosets.insert(1301); // Bare legs (no pants) — group 13 + activeGeosets.insert(1502); // No cloak — group 15 + activeGeosets.insert(2002); // Bare feet — group 20 // 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); @@ -3105,15 +3118,10 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x " hands=", extra.equipDisplayId[8], " tabard=", extra.equipDisplayId[9], " cape=", extra.equipDisplayId[10]); - // Use baked texture for body skin only (types 1, 2) - // Type 6 (hair) needs its own texture from CharSections.dbc - if (!extra.bakeName.empty()) { - std::string bakePath = "Textures\\BakedNpcTextures\\" + extra.bakeName; - - // Build equipment texture region layers from NPC equipment display IDs - // (texture-only compositing — no geoset changes to avoid invisibility bugs) - std::vector> npcRegionLayers; - auto npcItemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + // Build equipment texture region layers from NPC equipment display IDs + // (texture-only compositing — no geoset changes to avoid invisibility bugs) + std::vector> npcRegionLayers; + auto npcItemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); if (npcItemDisplayDbc) { static const char* npcComponentDirs[] = { "ArmUpperTexture", "ArmLowerTexture", "HandTexture", @@ -3162,6 +3170,11 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } + // Use baked texture for body skin (types 1, 2) + // Type 6 (hair) needs its own texture from CharSections.dbc + if (!extra.bakeName.empty()) { + std::string bakePath = "Textures\\BakedNpcTextures\\" + extra.bakeName; + // Composite equipment textures over baked NPC texture, or just load baked texture GLuint finalTex = 0; if (!npcRegionLayers.empty()) { @@ -3188,6 +3201,79 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } else { LOG_DEBUG(" Humanoid extra has empty bakeName, trying CharSections fallback"); + + // Build skin texture from CharSections.dbc (same as player character) + auto csFallbackDbc = assetManager->loadDBC("CharSections.dbc"); + if (csFallbackDbc) { + const auto* csFL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; + uint32_t npcRace = static_cast(extra.raceId); + uint32_t npcSex = static_cast(extra.sexId); + uint32_t npcSkin = static_cast(extra.skinId); + uint32_t npcFace = static_cast(extra.faceId); + std::string npcSkinPath, npcFaceLower, npcFaceUpper; + std::vector npcUnderwear; + + for (uint32_t r = 0; r < csFallbackDbc->getRecordCount(); r++) { + uint32_t rId = csFallbackDbc->getUInt32(r, csFL ? (*csFL)["RaceID"] : 1); + uint32_t sId = csFallbackDbc->getUInt32(r, csFL ? (*csFL)["SexID"] : 2); + if (rId != npcRace || sId != npcSex) continue; + + uint32_t section = csFallbackDbc->getUInt32(r, csFL ? (*csFL)["BaseSection"] : 3); + uint32_t variation = csFallbackDbc->getUInt32(r, csFL ? (*csFL)["VariationIndex"] : 8); + uint32_t color = csFallbackDbc->getUInt32(r, csFL ? (*csFL)["ColorIndex"] : 9); + uint32_t tex1F = csFL ? (*csFL)["Texture1"] : 4; + + // Section 0 = skin: match colorIndex = skinId + if (section == 0 && npcSkinPath.empty() && color == npcSkin) { + npcSkinPath = csFallbackDbc->getString(r, tex1F); + } + // Section 1 = face: match variation=faceId, color=skinId + else if (section == 1 && npcFaceLower.empty() && + variation == npcFace && color == npcSkin) { + npcFaceLower = csFallbackDbc->getString(r, tex1F); + npcFaceUpper = csFallbackDbc->getString(r, tex1F + 1); + } + // Section 4 = underwear: match color=skinId + else if (section == 4 && npcUnderwear.empty() && color == npcSkin) { + for (uint32_t f = tex1F; f <= tex1F + 2; f++) { + std::string tex = csFallbackDbc->getString(r, f); + if (!tex.empty()) npcUnderwear.push_back(tex); + } + } + } + + if (!npcSkinPath.empty()) { + // Composite skin + face + underwear + std::vector skinLayers; + skinLayers.push_back(npcSkinPath); + if (!npcFaceLower.empty()) skinLayers.push_back(npcFaceLower); + if (!npcFaceUpper.empty()) skinLayers.push_back(npcFaceUpper); + for (const auto& uw : npcUnderwear) skinLayers.push_back(uw); + + GLuint npcSkinTex = 0; + if (!npcRegionLayers.empty()) { + npcSkinTex = charRenderer->compositeWithRegions(npcSkinPath, + std::vector(skinLayers.begin() + 1, skinLayers.end()), + npcRegionLayers); + } else if (skinLayers.size() > 1) { + npcSkinTex = charRenderer->compositeTextures(skinLayers); + } else { + npcSkinTex = charRenderer->loadTexture(npcSkinPath); + } + + if (npcSkinTex != 0 && modelData) { + for (size_t ti = 0; ti < modelData->textures.size(); ti++) { + uint32_t texType = modelData->textures[ti].type; + if (texType == 1 || texType == 2 || texType == 11 || texType == 12 || texType == 13) { + charRenderer->setModelTexture(modelId, static_cast(ti), npcSkinTex); + hasHumanoidTexture = true; + } + } + LOG_DEBUG("Applied CharSections skin to NPC: ", npcSkinPath); + } + } + } } // Load hair texture from CharSections.dbc (section 3) @@ -3329,12 +3415,13 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } // Default equipment geosets (bare/no armor) - uint16_t geosetGloves = 302; // Bare hands - uint16_t geosetBoots = 401; // Bare feet - uint16_t geosetChest = 501; // Bare chest - uint16_t geosetPants = 1301; // Bare legs - uint16_t geosetCape = 1502; // No cape - uint16_t geosetTabard = 1201; // No tabard + // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 9=kneepads, 13=pants + uint16_t geosetGloves = 401; // Bare forearms (group 4) + uint16_t geosetBoots = 502; // Bare shins (group 5, wider mesh) + uint16_t geosetSleeves = 801; // Bare wrists (group 8, controlled by chest) + uint16_t geosetPants = 1301; // Bare legs (group 13) + uint16_t geosetCape = 1502; // No cape (group 15) + uint16_t geosetTabard = 1201; // No tabard (group 12) // Load equipment geosets from ItemDisplayInfo.dbc // DBC columns: 7=GeosetGroup[0], 8=GeosetGroup[1], 9=GeosetGroup[2] @@ -3345,21 +3432,19 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x const uint32_t fGG1 = idiL ? (*idiL)["GeosetGroup1"] : 7; const uint32_t fGG3 = idiL ? (*idiL)["GeosetGroup3"] : 9; - // Helm (slot 0) - noted for helmet model attachment below - - // Chest (slot 3) - geoset group 5xx + // Chest (slot 3) → group 8 (sleeves/wristbands) if (extra.equipDisplayId[3] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[3]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetChest = static_cast(501 + gg); + if (gg > 0) geosetSleeves = static_cast(801 + gg); // Robes: GeosetGroup[2] > 0 shows kilt legs uint32_t gg3 = itemDisplayDbc->getUInt32(static_cast(idx), fGG3); if (gg3 > 0) geosetPants = static_cast(1301 + gg3); } } - // Legs (slot 5) - geoset group 13xx + // Legs (slot 5) → group 13 (trousers) if (extra.equipDisplayId[5] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[5]); if (idx >= 0) { @@ -3368,37 +3453,37 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } - // Feet (slot 6) - geoset group 4xx + // Feet (slot 6) → group 5 (boots/shins) if (extra.equipDisplayId[6] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[6]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetBoots = static_cast(401 + gg); + if (gg > 0) geosetBoots = static_cast(501 + gg); } } - // Hands (slot 8) - geoset group 3xx + // Hands (slot 8) → group 4 (gloves/forearms) if (extra.equipDisplayId[8] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[8]); if (idx >= 0) { uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - if (gg > 0) geosetGloves = static_cast(301 + gg); + if (gg > 0) geosetGloves = static_cast(401 + gg); } } - // Tabard (slot 9) - geoset group 12xx + // Tabard (slot 9) → group 12 if (extra.equipDisplayId[9] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[9]); if (idx >= 0) { - geosetTabard = 1202; // Show tabard mesh + geosetTabard = 1202; } } - // Cape (slot 10) - geoset group 15xx + // Cape (slot 10) → group 15 if (extra.equipDisplayId[10] != 0) { int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[10]); if (idx >= 0) { - geosetCape = 1502; // Show cloak mesh + geosetCape = 1502; } } } @@ -3406,12 +3491,13 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Apply equipment geosets activeGeosets.insert(geosetGloves); activeGeosets.insert(geosetBoots); - activeGeosets.insert(geosetChest); + activeGeosets.insert(geosetSleeves); activeGeosets.insert(geosetPants); activeGeosets.insert(geosetCape); activeGeosets.insert(geosetTabard); activeGeosets.insert(702); // Ears: default - activeGeosets.insert(802); // Wristbands: default + activeGeosets.insert(902); // Kneepads: default + activeGeosets.insert(2002); // Bare feet mesh // Hide hair under helmets: replace style-specific scalp with bald scalp if (extra.equipDisplayId[0] != 0 && hairGeoset > 1) { @@ -3440,7 +3526,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]"); charRenderer->setActiveGeosets(instanceId, activeGeosets); LOG_DEBUG("Set humanoid geosets: hair=", (int)hairGeoset, - " chest=", geosetChest, " pants=", geosetPants, + " sleeves=", geosetSleeves, " pants=", geosetPants, " boots=", geosetBoots, " gloves=", geosetGloves); // Load and attach helmet model if equipped @@ -3617,6 +3703,16 @@ void Application::spawnOnlinePlayer(uint64_t guid, if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; if (playerInstances_.count(guid)) return; + // Skip local player — already spawned as the main character + if (gameHandler) { + uint64_t localGuid = gameHandler->getPlayerGuid(); + uint64_t activeGuid = gameHandler->getActiveCharacterGuid(); + if ((localGuid != 0 && guid == localGuid) || + (activeGuid != 0 && guid == activeGuid) || + (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { + return; + } + } auto* charRenderer = renderer->getCharacterRenderer(); // Base geometry model: cache by (race, gender) @@ -3828,16 +3924,18 @@ void Application::spawnOnlinePlayer(uint64_t guid, // Geosets: body + hair/facial hair selections std::unordered_set activeGeosets; - for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i); + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); activeGeosets.insert(static_cast(100 + hairStyleId + 1)); activeGeosets.insert(static_cast(200 + facialFeatures + 1)); - activeGeosets.insert(302); - activeGeosets.insert(401); - activeGeosets.insert(501); - activeGeosets.insert(702); - activeGeosets.insert(802); - activeGeosets.insert(1301); - activeGeosets.insert(1502); + activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 + activeGeosets.insert(502); // Bare shins (no boots) — group 5 (wider mesh) + activeGeosets.insert(702); // Ears + activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8 + activeGeosets.insert(902); // Kneepads — group 9 + activeGeosets.insert(1301); // Bare legs — group 13 + activeGeosets.insert(1502); // No cloak — group 15 + activeGeosets.insert(2002); // Bare feet — group 20 charRenderer->setActiveGeosets(instanceId, activeGeosets); charRenderer->playAnimation(instanceId, 0, true); @@ -3851,7 +3949,10 @@ void Application::spawnOnlinePlayer(uint64_t guid, st.appearanceBytes = appearanceBytes; st.facialFeatures = facialFeatures; st.bodySkinPath = bodySkinPath; - st.underwearPaths = underwearPaths; + // Include face textures so compositeWithRegions can rebuild the full base + if (!faceLowerPath.empty()) st.underwearPaths.push_back(faceLowerPath); + if (!faceUpperPath.empty()) st.underwearPaths.push_back(faceUpperPath); + for (const auto& up : underwearPaths) st.underwearPaths.push_back(up); onlinePlayerAppearance_[guid] = std::move(st); } @@ -3860,6 +3961,13 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, const std::array& inventoryTypes) { if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; + // Skip local player — equipment handled by GameScreen::updateCharacterGeosets/Textures + // via consumeOnlineEquipmentDirty(), which fires on the same server update. + if (gameHandler) { + uint64_t localGuid = gameHandler->getPlayerGuid(); + if (localGuid != 0 && guid == localGuid) return; + } + // If the player isn't spawned yet, store equipment until spawn. if (!playerInstances_.count(guid) || !onlinePlayerAppearance_.count(guid)) { pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; @@ -3911,12 +4019,17 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, // --- Geosets --- std::unordered_set geosets; - for (uint16_t i = 0; i <= 18; i++) geosets.insert(i); + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); geosets.insert(static_cast(100 + hairStyleId + 1)); geosets.insert(static_cast(200 + st.facialFeatures + 1)); - geosets.insert(701); + geosets.insert(401); // Body joint patches (knees) + geosets.insert(402); // Body joint patches (elbows) + geosets.insert(701); // Ears + geosets.insert(902); // Kneepads + geosets.insert(2002); // Bare feet mesh const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; @@ -3940,11 +4053,11 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, } } - // Feet (invType 8) + // Feet (invType 8): 401/402 are body patches (always on), 403+ are boot meshes { uint32_t did = findDisplayIdByInvType({8}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 401 + gg1 : 401)); + if (gg1 > 0) geosets.insert(static_cast(402 + gg1)); } // Hands (invType 10) diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 1584b949..0f96774b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -3347,7 +3347,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } // Trigger creature spawn callback for units/players with displayId if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { - if (block.objectType == ObjectType::PLAYER && block.guid != playerGuid) { + if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately via spawnPlayerCharacter() + } else if (block.objectType == ObjectType::PLAYER) { if (playerSpawnCallback_) { uint8_t race = 0, gender = 0, facial = 0; uint32_t appearanceBytes = 0; @@ -3725,7 +3727,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { displayIdChanged && unit->getDisplayId() != 0 && unit->getDisplayId() != oldDisplayId) { - if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { + if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately + } else if (entity->getType() == ObjectType::PLAYER) { if (playerSpawnCallback_) { uint8_t race = 0, gender = 0, facial = 0; uint32_t appearanceBytes = 0; diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index 8bba4688..f327f68e 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -966,6 +966,12 @@ M2Model M2Loader::load(const std::vector& m2Data) { model.textureLookup = readArray(m2Data, header.ofsTexLookup, header.nTexLookup); } + // Read bone lookup table (vertex bone indices reference this to get actual bone index) + if (header.nBoneLookupTable > 0 && header.ofsBoneLookupTable > 0) { + model.boneLookupTable = readArray(m2Data, header.ofsBoneLookupTable, header.nBoneLookupTable); + core::Logger::getInstance().debug(" BoneLookupTable: ", model.boneLookupTable.size(), " entries"); + } + // Read render flags / materials (blend modes) if (header.nRenderFlags > 0 && header.ofsRenderFlags > 0) { struct M2MaterialDisk { uint16_t flags; uint16_t blendMode; }; diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 54132dfe..650f5206 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -333,13 +333,14 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, activeGeosets.insert(static_cast(100 + hairStyle + 1)); // Facial hair geoset: group 2 = 200 + variation + 1 activeGeosets.insert(static_cast(200 + facialHair + 1)); - activeGeosets.insert(302); // Gloves: bare hands - activeGeosets.insert(401); // Boots: bare feet - activeGeosets.insert(501); // Chest: bare + activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 + activeGeosets.insert(502); // Bare shins (no boots) — group 5 (wider mesh) activeGeosets.insert(702); // Ears: default - activeGeosets.insert(802); // Wristbands: default - activeGeosets.insert(1301); // Trousers: bare legs - activeGeosets.insert(1502); // Back body (cloak=none) + activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8 + activeGeosets.insert(902); // Kneepads: default — group 9 + activeGeosets.insert(1301); // Bare legs (no pants) — group 13 + activeGeosets.insert(1502); // No cloak — group 15 + activeGeosets.insert(2002); // Bare feet mesh — group 20 charRenderer_->setActiveGeosets(instanceId_, activeGeosets); // Play idle animation (Stand = animation ID 0) @@ -412,44 +413,46 @@ bool CharacterPreview::applyEquipment(const std::vector& eq geosets.insert(static_cast(100 + hairStyle_ + 1)); // Hair style geosets.insert(static_cast(200 + facialHair_ + 1)); // Facial hair geosets.insert(701); // Ears + geosets.insert(902); // Kneepads: default (group 9) + geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET) - // Default naked geosets - uint16_t geosetGloves = 301; - uint16_t geosetBoots = 401; - uint16_t geosetChest = 501; - uint16_t geosetPants = 1301; + // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 13=pants + uint16_t geosetGloves = 401; // Bare forearms (group 4) + uint16_t geosetBoots = 502; // Bare shins (group 5, wider mesh) + uint16_t geosetSleeves = 801; // Bare wrists (group 8) + uint16_t geosetPants = 1301; // Bare legs (group 13) - // Chest/Shirt/Robe + // Chest/Shirt/Robe → group 8 (sleeves) { uint32_t did = findDisplayId({4, 5, 20}); uint32_t gg = getGeosetGroup(did, 0); - if (gg > 0) geosetChest = static_cast(501 + gg); + if (gg > 0) geosetSleeves = static_cast(801 + gg); // Robe kilt legs uint32_t gg3 = getGeosetGroup(did, 2); if (gg3 > 0) geosetPants = static_cast(1301 + gg3); } - // Legs + // Legs → group 13 (trousers) { uint32_t did = findDisplayId({7}); uint32_t gg = getGeosetGroup(did, 0); if (gg > 0) geosetPants = static_cast(1301 + gg); } - // Feet + // Boots → group 5 (shins) { uint32_t did = findDisplayId({8}); uint32_t gg = getGeosetGroup(did, 0); - if (gg > 0) geosetBoots = static_cast(401 + gg); + if (gg > 0) geosetBoots = static_cast(501 + gg); } - // Hands + // Gloves → group 4 (forearms) { uint32_t did = findDisplayId({10}); uint32_t gg = getGeosetGroup(did, 0); - if (gg > 0) geosetGloves = static_cast(301 + gg); + if (gg > 0) geosetGloves = static_cast(401 + gg); } geosets.insert(geosetGloves); geosets.insert(geosetBoots); - geosets.insert(geosetChest); + geosets.insert(geosetSleeves); 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 diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 205a665a..78a1ff00 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -78,7 +78,7 @@ bool CharacterRenderer::initialize() { uniform mat4 uModel; uniform mat4 uView; uniform mat4 uProjection; - uniform mat4 uBones[200]; + uniform mat4 uBones[240]; out vec3 FragPos; out vec3 Normal; @@ -205,7 +205,7 @@ bool CharacterRenderer::initialize() { uniform mat4 uLightSpaceMatrix; uniform mat4 uModel; - uniform mat4 uBones[200]; + uniform mat4 uBones[240]; out vec2 vTexCoord; @@ -556,20 +556,7 @@ GLuint CharacterRenderer::compositeTextures(const std::vector& laye // Debug dump removed: it was always-on and could stall badly under load. // Debug: dump first composite to /tmp for visual inspection - { - static bool dumped = false; - if (!dumped && layerPaths.size() > 1) { - dumped = true; - std::string dumpPath = "/tmp/wowee_composite_debug.raw"; - FILE* f = fopen(dumpPath.c_str(), "wb"); - if (f) { - fwrite(composite.data(), 1, composite.size(), f); - fclose(f); - core::Logger::getInstance().info("DEBUG: dumped composite ", width, "x", height, - " RGBA to ", dumpPath); - } - } - } + // Upload composite to GPU GLuint texId; @@ -588,6 +575,26 @@ GLuint CharacterRenderer::compositeTextures(const std::vector& laye return texId; } +void CharacterRenderer::clearCompositeCache() { + // Delete GPU textures that aren't referenced by any model's texture slots + for (auto& [key, texId] : compositeCache_) { + if (texId && texId != whiteTexture) { + // Check if any model still references this texture + bool inUse = false; + for (const auto& [mid, gm] : models) { + for (GLuint tid : gm.textureIds) { + if (tid == texId) { inUse = true; break; } + } + if (inUse) break; + } + if (!inUse) { + glDeleteTextures(1, &texId); + } + } + } + compositeCache_.clear(); +} + GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, const std::vector& baseLayers, const std::vector>& regionLayers) { @@ -606,16 +613,17 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, return cacheIt->second; } - // Region index → pixel coordinates on the 512x512 atlas - static const int regionCoords[][2] = { + // Region index → pixel coordinates on the 256x256 base atlas + // These are scaled up by (width/256, height/256) for larger textures (512x512, 1024x1024) + static const int regionCoords256[][2] = { { 0, 0 }, // 0 = ArmUpper - { 0, 128 }, // 1 = ArmLower - { 0, 256 }, // 2 = Hand - { 256, 0 }, // 3 = TorsoUpper - { 256, 128 }, // 4 = TorsoLower - { 256, 192 }, // 5 = LegUpper - { 256, 320 }, // 6 = LegLower - { 256, 448 }, // 7 = Foot + { 0, 64 }, // 1 = ArmLower + { 0, 128 }, // 2 = Hand + { 128, 0 }, // 3 = TorsoUpper + { 128, 64 }, // 4 = TorsoLower + { 128, 96 }, // 5 = LegUpper + { 128, 160 }, // 6 = LegLower + { 128, 224 }, // 7 = Foot }; // First, build base skin + underwear using existing compositeTextures @@ -638,6 +646,8 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, int width = base.width; int height = base.height; + + // If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512 // so equipment regions can be composited at correct coordinates if (width == 256 && height == 256 && !regionLayers.empty()) { @@ -663,8 +673,8 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, } // Blend face + underwear overlays - // These are native-resolution textures (designed for 256x256 base). - // If we upscaled the base to 512x512, use blitOverlayScaled2x and 2x coords. + // If we upscaled from 256→512, scale coords and texels with blitOverlayScaled2x. + // For native 512/1024 textures, face overlays are full atlas size (hit width==width branch). bool upscaled = (base.width == 256 && base.height == 256 && width == 512); for (const auto& ul : baseLayers) { if (ul.empty()) continue; @@ -679,6 +689,11 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, std::string pathLower = ul; for (auto& c : pathLower) c = std::tolower(c); + // Scale factor from 256-base coordinates to actual canvas size + int coordScale = width / 256; + if (coordScale < 1) coordScale = 1; + bool useScale = true; + if (pathLower.find("faceupper") != std::string::npos) { dstX = 0; dstY = 160; } else if (pathLower.find("facelower") != std::string::npos) { @@ -698,14 +713,19 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, } else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) { dstX = 128; dstY = 160; } else { - dstX = (base.width - overlay.width) / 2; - dstY = (base.height - overlay.height) / 2; + // Fallback: center overlay on canvas (already in canvas coords) + dstX = (width - overlay.width) / 2; + dstY = (height - overlay.height) / 2; + useScale = false; + } + + if (useScale) { + dstX *= coordScale; + dstY *= coordScale; } if (upscaled) { - // Scale coords and texels to match 512x512 canvas - dstX *= 2; - dstY *= 2; + // Overlay is 256-base sized, needs 2x texel scaling for 512 canvas blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY); } else { blitOverlay(composite, width, height, overlay, dstX, dstY); @@ -713,18 +733,24 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, } } - // Expected region sizes on the 512x512 atlas - static const int regionSizes[][2] = { - { 256, 128 }, // 0 = ArmUpper - { 256, 128 }, // 1 = ArmLower - { 256, 64 }, // 2 = Hand - { 256, 128 }, // 3 = TorsoUpper - { 256, 64 }, // 4 = TorsoLower - { 256, 128 }, // 5 = LegUpper - { 256, 128 }, // 6 = LegLower - { 256, 64 }, // 7 = Foot + // Expected region sizes on the 256x256 base atlas (scaled like coords) + static const int regionSizes256[][2] = { + { 128, 64 }, // 0 = ArmUpper + { 128, 64 }, // 1 = ArmLower + { 128, 32 }, // 2 = Hand + { 128, 64 }, // 3 = TorsoUpper + { 128, 32 }, // 4 = TorsoLower + { 128, 64 }, // 5 = LegUpper + { 128, 64 }, // 6 = LegLower + { 128, 32 }, // 7 = Foot }; + // Scale factor from 256-base to actual texture size + int scaleX = width / 256; + int scaleY = height / 256; + if (scaleX < 1) scaleX = 1; + if (scaleY < 1) scaleY = 1; + // Now blit equipment region textures at explicit coordinates for (const auto& rl : regionLayers) { int regionIdx = rl.first; @@ -736,12 +762,12 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, continue; } - int dstX = regionCoords[regionIdx][0]; - int dstY = regionCoords[regionIdx][1]; + int dstX = regionCoords256[regionIdx][0] * scaleX; + int dstY = regionCoords256[regionIdx][1] * scaleY; - // Component textures are stored at half resolution — scale 2x if needed - int expectedW = regionSizes[regionIdx][0]; - int expectedH = regionSizes[regionIdx][1]; + // Expected full-resolution size for this region at current atlas scale + int expectedW = regionSizes256[regionIdx][0] * scaleX; + int expectedH = regionSizes256[regionIdx][1] * scaleY; if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) { blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY); } else { @@ -752,6 +778,26 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath, " at (", dstX, ",", dstY, ") ", overlay.width, "x", overlay.height, " from ", rl.second); } + // Debug: dump composite to /tmp for visual inspection + { + static int dumpCount = 0; + if (dumpCount < 6) { + dumpCount++; + std::string dumpPath = "/tmp/wowee_composite_" + std::to_string(dumpCount) + ".ppm"; + FILE* f = fopen(dumpPath.c_str(), "wb"); + if (f) { + fprintf(f, "P6\n%d %d\n255\n", width, height); + for (int i = 0; i < width * height; i++) { + fputc(composite[i * 4 + 0], f); + fputc(composite[i * 4 + 1], f); + fputc(composite[i * 4 + 2], f); + } + fclose(f); + core::Logger::getInstance().info("compositeWithRegions: dumped to ", dumpPath); + } + } + } + // Upload to GPU GLuint texId; glGenTextures(1, &texId); @@ -841,6 +887,37 @@ bool CharacterRenderer::loadModel(const pipeline::M2Model& model, uint32_t id) { " verts, ", model.bones.size(), " bones, ", model.sequences.size(), " anims, ", model.textures.size(), " textures)"); + // Debug: dump vertex bounding boxes per submesh group for player model + if (id == 1) { + core::Logger::getInstance().info("MODEL1_VERSION: ", model.version); + std::map> groupBounds; // group -> minX,minY,minZ,maxX,maxY,maxZ + for (const auto& b : model.batches) { + uint16_t grp = b.submeshId; + for (uint32_t idx = b.indexStart; idx < b.indexStart + b.indexCount && idx < model.indices.size(); idx++) { + uint16_t vi = model.indices[idx]; + if (vi >= model.vertices.size()) continue; + const auto& v = model.vertices[vi]; + auto it = groupBounds.find(grp); + if (it == groupBounds.end()) { + groupBounds[grp] = {v.position.x, v.position.y, v.position.z, + v.position.x, v.position.y, v.position.z}; + } else { + auto& bb = it->second; + bb[0] = std::min(bb[0], v.position.x); + bb[1] = std::min(bb[1], v.position.y); + bb[2] = std::min(bb[2], v.position.z); + bb[3] = std::max(bb[3], v.position.x); + bb[4] = std::max(bb[4], v.position.y); + bb[5] = std::max(bb[5], v.position.z); + } + } + } + for (const auto& [grp, bb] : groupBounds) { + core::Logger::getInstance().info("MODEL1_BOUNDS: submesh=", grp, + " X[", bb[0], "..", bb[3], "] Y[", bb[1], "..", bb[4], "] Z[", bb[2], "..", bb[5], "]"); + } + } + return true; } @@ -1367,11 +1444,24 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons if (texSlot >= gm.textureIds.size()) continue; GLuint texId = gm.textureIds[texSlot]; - auto itO = inst.textureSlotOverrides.find(texSlot); - if (itO != inst.textureSlotOverrides.end() && itO->second != 0) { - texId = itO->second; - } uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; + // Apply texture slot overrides. + // For type-1 (skin) overrides, only apply to skin-group batches + // to prevent the skin composite from bleeding onto cloak/hair. + { + auto itO = inst.textureSlotOverrides.find(texSlot); + if (itO != inst.textureSlotOverrides.end() && itO->second != 0) { + if (texType == 1) { + // Only apply skin override to skin groups + uint16_t grp = b.submeshId / 100; + bool isSkinGroup = (grp == 0 || grp == 3 || grp == 4 || grp == 5 || + grp == 8 || grp == 9 || grp == 13 || grp == 20); + if (isSkinGroup) texId = itO->second; + } else { + texId = itO->second; + } + } + } if (!hasFirst) { first = {texId, texType}; @@ -1440,14 +1530,17 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons // Resolve texture for this batch (prefer hair textures for hair geosets). GLuint texId = resolveBatchTexture(instance, gpuModel, batch); - // For body parts with white/fallback texture, use skin (type 1) texture - // This handles humanoid models where some body parts use different texture slots - // that may not be set (e.g., baked NPC textures only set slot 0) - // Only apply to body skin slots (type 1), NOT hair (type 6) or other types + + + // For body/equipment parts with white/fallback texture, use skin (type 1) texture. + // Groups that share the body skin atlas: 0=body, 3=gloves, 4=boots, 5=chest, + // 8=wristbands, 9=pelvis, 13=pants. Hair (group 1) and facial hair (group 2) do NOT. if (texId == whiteTexture) { uint16_t group = batch.submeshId / 100; - if (group == 0) { - // Check if this batch's texture slot is a body skin type + bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 || + group == 8 || group == 9 || group == 13); + if (isSkinGroup) { + // Check if this batch's texture slot is a hair type (don't override hair) uint32_t texType = 0; if (batch.textureIndex < gpuModel.data.textureLookup.size()) { uint16_t lk = gpuModel.data.textureLookup[batch.textureIndex]; @@ -1455,7 +1548,6 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons texType = gpuModel.data.textures[lk].type; } } - // Only fall back for body skin (type 1), underwear (type 8), or cloak (type 2) // Do NOT apply skin composite to hair (type 6) batches if (texType != 6) { for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 02066b3d..e84fa1c8 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2628,9 +2628,9 @@ void GameScreen::updateCharacterGeosets(game::Inventory& inventory) { return false; }; - // Base geosets always present + // Base geosets always present (group 0: IDs 0-99, some models use up to 27) std::unordered_set geosets; - for (uint16_t i = 0; i <= 18; i++) { + for (uint16_t i = 0; i <= 99; i++) { geosets.insert(i); } // Hair/facial geosets must match the active character's appearance, otherwise @@ -2647,52 +2647,67 @@ void GameScreen::updateCharacterGeosets(game::Inventory& inventory) { geosets.insert(static_cast(100 + hairStyleId + 1)); // Group 1 hair geosets.insert(static_cast(200 + facialId + 1)); // Group 2 facial } - geosets.insert(701); // Ears + geosets.insert(702); // Ears: visible (default) + geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET, always on) + + // CharGeosets mapping (verified via vertex bounding boxes): + // Group 4 (401+) = GLOVES (forearm area, Z~1.1-1.4) + // Group 5 (501+) = BOOTS (shin area, Z~0.1-0.6) + // Group 8 (801+) = WRISTBANDS/SLEEVES (controlled by chest armor) + // Group 9 (901+) = KNEEPADS + // Group 13 (1301+) = TROUSERS/PANTS + // Group 15 (1501+) = CAPE/CLOAK + // Group 20 (2002) = FEET + + // Gloves: inventoryType 10 → group 4 (forearms) + // 401=bare forearms, 402+=glove styles covering forearm + { + uint32_t did = findEquippedDisplayId({10}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 401 + gg : 401)); + } + + // Boots: inventoryType 8 → group 5 (shins/lower legs) + // 501=narrow bare shin, 502=wider (matches thigh width better). Use 502 as bare default. + // When boots equipped, gg selects boot style: 501+gg (gg=1→502, gg=2→503, etc.) + { + uint32_t did = findEquippedDisplayId({8}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 501 + gg : 502)); + } // Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe) - // geosetGroup_1 > 0 → use mesh variant (502+), otherwise bare (501) + texture only + // Controls group 8 (wristbands/sleeve length): 801=bare wrists, 802+=sleeve styles + // Also controls group 13 (trousers) via GeosetGroup[2] for robes { uint32_t did = findEquippedDisplayId({4, 5, 20}); uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 501 + gg : (did > 0 ? 501 : 501))); - // geosetGroup_3 > 0 on robes also shows kilt legs (1302) + geosets.insert(static_cast(gg > 0 ? 801 + gg : 801)); + // Robe kilt: GeosetGroup[2] > 0 → show kilt legs (1302+) uint32_t gg3 = getGeosetGroup(did, 2); if (gg3 > 0) { geosets.insert(static_cast(1301 + gg3)); } } - // Legs: inventoryType 7 - // geosetGroup_1 > 0 → kilt/skirt mesh (1302+), otherwise bare legs (1301) + texture + // Kneepads: group 9 (always default 902) + geosets.insert(902); + + // Legs/Pants: inventoryType 7 → group 13 (trousers/thighs) + // 1301=bare legs, 1302+=pant/kilt styles { uint32_t did = findEquippedDisplayId({7}); uint32_t gg = getGeosetGroup(did, 0); - // Only add leg geoset if robe hasn't already set a kilt geoset + // Only add if robe hasn't already set a kilt geoset if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { geosets.insert(static_cast(gg > 0 ? 1301 + gg : 1301)); } } - // Feet/Boots: inventoryType 8 - // geosetGroup_1 > 0 → boot mesh (402+), otherwise bare feet (401) + texture - { - uint32_t did = findEquippedDisplayId({8}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 401 + gg : 401)); - } - - // Gloves/Hands: inventoryType 10 - // geosetGroup_1 > 0 → glove mesh (302+), otherwise bare hands (301) - { - uint32_t did = findEquippedDisplayId({10}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 301 + gg : 301)); - } - - // Back/Cloak: inventoryType 16 — geoset only, no skin texture (cloaks are separate models) + // Back/Cloak: inventoryType 16 → group 15 geosets.insert(hasEquippedType({16}) ? 1502 : 1501); - // Tabard: inventoryType 19 + // Tabard: inventoryType 19 → group 12 if (hasEquippedType({19})) { geosets.insert(1201); } @@ -2789,15 +2804,28 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { } } + // TEMP: log region layers for debugging + { + static const char* regionNames[] = {"ArmUpper","ArmLower","Hand","TorsoUpper","TorsoLower","LegUpper","LegLower","Foot"}; + for (const auto& rl : regionLayers) { + LOG_INFO("TEX_REGION: region=", rl.first, "(", (rl.first < 8 ? regionNames[rl.first] : "?"), ") path=", rl.second); + } + LOG_INFO("TEX_REGION: total=", regionLayers.size(), " regions, baseSkin=", bodySkinPath); + } + // Re-composite: base skin + underwear + equipment regions + // Clear composite cache first to prevent stale textures from being reused + charRenderer->clearCompositeCache(); + // Use per-instance texture override (not model-level) to avoid deleting cached composites. + uint32_t instanceId = renderer->getCharacterInstanceId(); GLuint newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); - if (newTex != 0) { - charRenderer->setModelTexture(1, skinSlot, newTex); + if (newTex != 0 && instanceId != 0) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(skinSlot), newTex); } // Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin) uint32_t cloakSlot = app.getCloakTextureSlotIndex(); - if (cloakSlot > 0) { + if (cloakSlot > 0 && instanceId != 0) { // Find equipped cloak (inventoryType 16) uint32_t cloakDisplayId = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { @@ -2818,14 +2846,14 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp"; GLuint capeTex = charRenderer->loadTexture(capePath); if (capeTex != 0) { - charRenderer->setModelTexture(1, cloakSlot, capeTex); + charRenderer->setTextureSlotOverride(instanceId, static_cast(cloakSlot), capeTex); LOG_INFO("Cloak texture applied: ", capePath); } } } } else { - // No cloak equipped — reset to white fallback - charRenderer->resetModelTexture(1, cloakSlot); + // No cloak equipped — clear override so model's default (white) shows + charRenderer->clearTextureSlotOverride(instanceId, static_cast(cloakSlot)); } } } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 7dfd4bbe..f5e4804f 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -186,7 +186,8 @@ void InventoryScreen::updatePreviewEquipment(game::Inventory& inventory) { }; std::unordered_set geosets; - for (uint16_t i = 0; i <= 18; i++) geosets.insert(i); + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); // Hair geoset: group 1 = 100 + hairStyle + 1 geosets.insert(static_cast(100 + playerHairStyle_ + 1));