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
This commit is contained in:
Kelsi 2026-02-15 20:53:01 -08:00
parent 27d6150ecb
commit 1a5c43d18a
10 changed files with 424 additions and 169 deletions

View file

@ -12,8 +12,9 @@
}, },
"CharSections": { "CharSections": {
"RaceID": 1, "SexID": 2, "BaseSection": 3, "RaceID": 1, "SexID": 2, "BaseSection": 3,
"Texture1": 4, "Texture2": 5, "Texture3": 6, "VariationIndex": 4, "ColorIndex": 5,
"Flags": 7, "VariationIndex": 8, "ColorIndex": 9 "Texture1": 6, "Texture2": 7, "Texture3": 8,
"Flags": 9
}, },
"SpellIcon": { "ID": 0, "Path": 1 }, "SpellIcon": { "ID": 0, "Path": 1 },
"FactionTemplate": { "FactionTemplate": {

View file

@ -183,6 +183,9 @@ struct M2Model {
std::vector<M2Sequence> sequences; std::vector<M2Sequence> sequences;
std::vector<uint32_t> globalSequenceDurations; // Per-global-sequence loop durations (ms) std::vector<uint32_t> globalSequenceDurations; // Per-global-sequence loop durations (ms)
// Bone lookup table (vertex bone indices reference this to get global bone index)
std::vector<uint16_t> boneLookupTable;
// Rendering // Rendering
std::vector<M2Batch> batches; std::vector<M2Batch> batches;
std::vector<M2Texture> textures; std::vector<M2Texture> textures;

View file

@ -210,6 +210,9 @@ public:
const std::vector<std::string>& baseLayers, const std::vector<std::string>& baseLayers,
const std::vector<std::pair<int, std::string>>& regionLayers); const std::vector<std::pair<int, std::string>>& 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). */ /** Load a BLP texture from MPQ and return the GL texture ID (cached). */
GLuint loadTexture(const std::string& path); GLuint loadTexture(const std::string& path);
@ -259,7 +262,8 @@ private:
uint32_t nextInstanceId = 1; uint32_t nextInstanceId = 1;
// Maximum bones supported (GPU uniform limit) // 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 } // namespace rendering

View file

@ -1191,6 +1191,14 @@ void Application::setupUICallbacks() {
uint32_t appearanceBytes, uint32_t appearanceBytes,
uint8_t facialFeatures, uint8_t facialFeatures,
float x, float y, float z, float orientation) { 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 (playerInstances_.count(guid)) return;
if (pendingPlayerSpawnGuids_.count(guid)) return; if (pendingPlayerSpawnGuids_.count(guid)) return;
pendingPlayerSpawns_.push_back({guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation}); pendingPlayerSpawns_.push_back({guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation});
@ -1947,8 +1955,12 @@ void Application::spawnPlayerCharacter() {
if (useCharSections) { if (useCharSections) {
// Save skin composite state for re-compositing on equipment changes // Save skin composite state for re-compositing on equipment changes
// Include face textures so compositeWithRegions can rebuild the full base
bodySkinPath_ = bodySkinPath; 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 // Composite body skin + face + underwear overlays
{ {
@ -2080,8 +2092,8 @@ void Application::spawnPlayerCharacter() {
// Default geosets for the active character (match CharacterPreview logic). // Default geosets for the active character (match CharacterPreview logic).
// Previous hardcoded values (notably always inserting 101) caused wrong hair meshes in-world. // Previous hardcoded values (notably always inserting 101) caused wrong hair meshes in-world.
std::unordered_set<uint16_t> activeGeosets; std::unordered_set<uint16_t> activeGeosets;
// Body parts (group 0) // Body parts (group 0: IDs 0-99, some models use up to 27)
for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i); for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i);
uint8_t hairStyleId = 0; uint8_t hairStyleId = 0;
uint8_t facialId = 0; uint8_t facialId = 0;
@ -2095,13 +2107,14 @@ void Application::spawnPlayerCharacter() {
activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
// Facial hair geoset: group 2 = 200 + variation + 1 // Facial hair geoset: group 2 = 200 + variation + 1
activeGeosets.insert(static_cast<uint16_t>(200 + facialId + 1)); activeGeosets.insert(static_cast<uint16_t>(200 + facialId + 1));
activeGeosets.insert(302); // Gloves: bare hands activeGeosets.insert(401); // Bare forearms (no gloves) — group 4
activeGeosets.insert(401); // Boots: bare feet activeGeosets.insert(502); // Bare shins (no boots) — group 5 (wider mesh matches thighs)
activeGeosets.insert(501); // Chest: bare
activeGeosets.insert(702); // Ears: default activeGeosets.insert(702); // Ears: default
activeGeosets.insert(802); // Wristbands: default activeGeosets.insert(801); // Bare wrists (no chest armor sleeves) — group 8
activeGeosets.insert(1301); // Trousers: bare legs activeGeosets.insert(902); // Kneepads: default — group 9
activeGeosets.insert(1502); // Back body (cloak=none) 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 // 1703 = DK eye glow mesh — skip for normal characters
// Normal eyes are part of the face texture on the body mesh // Normal eyes are part of the face texture on the body mesh
charRenderer->setActiveGeosets(instanceId, activeGeosets); charRenderer->setActiveGeosets(instanceId, activeGeosets);
@ -3105,11 +3118,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
" hands=", extra.equipDisplayId[8], " tabard=", extra.equipDisplayId[9], " hands=", extra.equipDisplayId[8], " tabard=", extra.equipDisplayId[9],
" cape=", extra.equipDisplayId[10]); " 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 // Build equipment texture region layers from NPC equipment display IDs
// (texture-only compositing — no geoset changes to avoid invisibility bugs) // (texture-only compositing — no geoset changes to avoid invisibility bugs)
std::vector<std::pair<int, std::string>> npcRegionLayers; std::vector<std::pair<int, std::string>> npcRegionLayers;
@ -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 // Composite equipment textures over baked NPC texture, or just load baked texture
GLuint finalTex = 0; GLuint finalTex = 0;
if (!npcRegionLayers.empty()) { if (!npcRegionLayers.empty()) {
@ -3188,6 +3201,79 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
} }
} else { } else {
LOG_DEBUG(" Humanoid extra has empty bakeName, trying CharSections fallback"); 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<uint32_t>(extra.raceId);
uint32_t npcSex = static_cast<uint32_t>(extra.sexId);
uint32_t npcSkin = static_cast<uint32_t>(extra.skinId);
uint32_t npcFace = static_cast<uint32_t>(extra.faceId);
std::string npcSkinPath, npcFaceLower, npcFaceUpper;
std::vector<std::string> 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<std::string> 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<std::string>(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<uint32_t>(ti), npcSkinTex);
hasHumanoidTexture = true;
}
}
LOG_DEBUG("Applied CharSections skin to NPC: ", npcSkinPath);
}
}
}
} }
// Load hair texture from CharSections.dbc (section 3) // 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) // Default equipment geosets (bare/no armor)
uint16_t geosetGloves = 302; // Bare hands // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 9=kneepads, 13=pants
uint16_t geosetBoots = 401; // Bare feet uint16_t geosetGloves = 401; // Bare forearms (group 4)
uint16_t geosetChest = 501; // Bare chest uint16_t geosetBoots = 502; // Bare shins (group 5, wider mesh)
uint16_t geosetPants = 1301; // Bare legs uint16_t geosetSleeves = 801; // Bare wrists (group 8, controlled by chest)
uint16_t geosetCape = 1502; // No cape uint16_t geosetPants = 1301; // Bare legs (group 13)
uint16_t geosetTabard = 1201; // No tabard uint16_t geosetCape = 1502; // No cape (group 15)
uint16_t geosetTabard = 1201; // No tabard (group 12)
// Load equipment geosets from ItemDisplayInfo.dbc // Load equipment geosets from ItemDisplayInfo.dbc
// DBC columns: 7=GeosetGroup[0], 8=GeosetGroup[1], 9=GeosetGroup[2] // 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 fGG1 = idiL ? (*idiL)["GeosetGroup1"] : 7;
const uint32_t fGG3 = idiL ? (*idiL)["GeosetGroup3"] : 9; const uint32_t fGG3 = idiL ? (*idiL)["GeosetGroup3"] : 9;
// Helm (slot 0) - noted for helmet model attachment below // Chest (slot 3) → group 8 (sleeves/wristbands)
// Chest (slot 3) - geoset group 5xx
if (extra.equipDisplayId[3] != 0) { if (extra.equipDisplayId[3] != 0) {
int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[3]); int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[3]);
if (idx >= 0) { if (idx >= 0) {
uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1); uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1);
if (gg > 0) geosetChest = static_cast<uint16_t>(501 + gg); if (gg > 0) geosetSleeves = static_cast<uint16_t>(801 + gg);
// Robes: GeosetGroup[2] > 0 shows kilt legs // Robes: GeosetGroup[2] > 0 shows kilt legs
uint32_t gg3 = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG3); uint32_t gg3 = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG3);
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3); if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
} }
} }
// Legs (slot 5) - geoset group 13xx // Legs (slot 5) → group 13 (trousers)
if (extra.equipDisplayId[5] != 0) { if (extra.equipDisplayId[5] != 0) {
int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[5]); int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[5]);
if (idx >= 0) { 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) { if (extra.equipDisplayId[6] != 0) {
int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[6]); int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[6]);
if (idx >= 0) { if (idx >= 0) {
uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1); uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1);
if (gg > 0) geosetBoots = static_cast<uint16_t>(401 + gg); if (gg > 0) geosetBoots = static_cast<uint16_t>(501 + gg);
} }
} }
// Hands (slot 8) - geoset group 3xx // Hands (slot 8) → group 4 (gloves/forearms)
if (extra.equipDisplayId[8] != 0) { if (extra.equipDisplayId[8] != 0) {
int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[8]); int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[8]);
if (idx >= 0) { if (idx >= 0) {
uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1); uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1);
if (gg > 0) geosetGloves = static_cast<uint16_t>(301 + gg); if (gg > 0) geosetGloves = static_cast<uint16_t>(401 + gg);
} }
} }
// Tabard (slot 9) - geoset group 12xx // Tabard (slot 9) → group 12
if (extra.equipDisplayId[9] != 0) { if (extra.equipDisplayId[9] != 0) {
int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[9]); int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[9]);
if (idx >= 0) { 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) { if (extra.equipDisplayId[10] != 0) {
int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[10]); int32_t idx = itemDisplayDbc->findRecordById(extra.equipDisplayId[10]);
if (idx >= 0) { 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 // Apply equipment geosets
activeGeosets.insert(geosetGloves); activeGeosets.insert(geosetGloves);
activeGeosets.insert(geosetBoots); activeGeosets.insert(geosetBoots);
activeGeosets.insert(geosetChest); activeGeosets.insert(geosetSleeves);
activeGeosets.insert(geosetPants); activeGeosets.insert(geosetPants);
activeGeosets.insert(geosetCape); activeGeosets.insert(geosetCape);
activeGeosets.insert(geosetTabard); activeGeosets.insert(geosetTabard);
activeGeosets.insert(702); // Ears: default 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 // Hide hair under helmets: replace style-specific scalp with bald scalp
if (extra.equipDisplayId[0] != 0 && hairGeoset > 1) { 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, "]"); LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]");
charRenderer->setActiveGeosets(instanceId, activeGeosets); charRenderer->setActiveGeosets(instanceId, activeGeosets);
LOG_DEBUG("Set humanoid geosets: hair=", (int)hairGeoset, LOG_DEBUG("Set humanoid geosets: hair=", (int)hairGeoset,
" chest=", geosetChest, " pants=", geosetPants, " sleeves=", geosetSleeves, " pants=", geosetPants,
" boots=", geosetBoots, " gloves=", geosetGloves); " boots=", geosetBoots, " gloves=", geosetGloves);
// Load and attach helmet model if equipped // 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 (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return;
if (playerInstances_.count(guid)) 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(); auto* charRenderer = renderer->getCharacterRenderer();
// Base geometry model: cache by (race, gender) // Base geometry model: cache by (race, gender)
@ -3828,16 +3924,18 @@ void Application::spawnOnlinePlayer(uint64_t guid,
// Geosets: body + hair/facial hair selections // Geosets: body + hair/facial hair selections
std::unordered_set<uint16_t> activeGeosets; std::unordered_set<uint16_t> 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<uint16_t>(100 + hairStyleId + 1)); activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
activeGeosets.insert(static_cast<uint16_t>(200 + facialFeatures + 1)); activeGeosets.insert(static_cast<uint16_t>(200 + facialFeatures + 1));
activeGeosets.insert(302); activeGeosets.insert(401); // Bare forearms (no gloves) — group 4
activeGeosets.insert(401); activeGeosets.insert(502); // Bare shins (no boots) — group 5 (wider mesh)
activeGeosets.insert(501); activeGeosets.insert(702); // Ears
activeGeosets.insert(702); activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8
activeGeosets.insert(802); activeGeosets.insert(902); // Kneepads — group 9
activeGeosets.insert(1301); activeGeosets.insert(1301); // Bare legs — group 13
activeGeosets.insert(1502); activeGeosets.insert(1502); // No cloak — group 15
activeGeosets.insert(2002); // Bare feet — group 20
charRenderer->setActiveGeosets(instanceId, activeGeosets); charRenderer->setActiveGeosets(instanceId, activeGeosets);
charRenderer->playAnimation(instanceId, 0, true); charRenderer->playAnimation(instanceId, 0, true);
@ -3851,7 +3949,10 @@ void Application::spawnOnlinePlayer(uint64_t guid,
st.appearanceBytes = appearanceBytes; st.appearanceBytes = appearanceBytes;
st.facialFeatures = facialFeatures; st.facialFeatures = facialFeatures;
st.bodySkinPath = bodySkinPath; 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); onlinePlayerAppearance_[guid] = std::move(st);
} }
@ -3860,6 +3961,13 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
const std::array<uint8_t, 19>& inventoryTypes) { const std::array<uint8_t, 19>& inventoryTypes) {
if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return; 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 the player isn't spawned yet, store equipment until spawn.
if (!playerInstances_.count(guid) || !onlinePlayerAppearance_.count(guid)) { if (!playerInstances_.count(guid) || !onlinePlayerAppearance_.count(guid)) {
pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes};
@ -3911,12 +4019,17 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
// --- Geosets --- // --- Geosets ---
std::unordered_set<uint16_t> geosets; std::unordered_set<uint16_t> 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<uint8_t>((st.appearanceBytes >> 16) & 0xFF); uint8_t hairStyleId = static_cast<uint8_t>((st.appearanceBytes >> 16) & 0xFF);
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
geosets.insert(static_cast<uint16_t>(200 + st.facialFeatures + 1)); geosets.insert(static_cast<uint16_t>(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 geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7;
const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; 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 did = findDisplayIdByInvType({8});
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 401 + gg1 : 401)); if (gg1 > 0) geosets.insert(static_cast<uint16_t>(402 + gg1));
} }
// Hands (invType 10) // Hands (invType 10)

View file

@ -3347,7 +3347,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
} }
// Trigger creature spawn callback for units/players with displayId // Trigger creature spawn callback for units/players with displayId
if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { if ((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_) { if (playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0; uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0; uint32_t appearanceBytes = 0;
@ -3725,7 +3727,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
displayIdChanged && displayIdChanged &&
unit->getDisplayId() != 0 && unit->getDisplayId() != 0 &&
unit->getDisplayId() != oldDisplayId) { 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_) { if (playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0; uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0; uint32_t appearanceBytes = 0;

View file

@ -966,6 +966,12 @@ M2Model M2Loader::load(const std::vector<uint8_t>& m2Data) {
model.textureLookup = readArray<uint16_t>(m2Data, header.ofsTexLookup, header.nTexLookup); model.textureLookup = readArray<uint16_t>(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<uint16_t>(m2Data, header.ofsBoneLookupTable, header.nBoneLookupTable);
core::Logger::getInstance().debug(" BoneLookupTable: ", model.boneLookupTable.size(), " entries");
}
// Read render flags / materials (blend modes) // Read render flags / materials (blend modes)
if (header.nRenderFlags > 0 && header.ofsRenderFlags > 0) { if (header.nRenderFlags > 0 && header.ofsRenderFlags > 0) {
struct M2MaterialDisk { uint16_t flags; uint16_t blendMode; }; struct M2MaterialDisk { uint16_t flags; uint16_t blendMode; };

View file

@ -333,13 +333,14 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender,
activeGeosets.insert(static_cast<uint16_t>(100 + hairStyle + 1)); activeGeosets.insert(static_cast<uint16_t>(100 + hairStyle + 1));
// Facial hair geoset: group 2 = 200 + variation + 1 // Facial hair geoset: group 2 = 200 + variation + 1
activeGeosets.insert(static_cast<uint16_t>(200 + facialHair + 1)); activeGeosets.insert(static_cast<uint16_t>(200 + facialHair + 1));
activeGeosets.insert(302); // Gloves: bare hands activeGeosets.insert(401); // Bare forearms (no gloves) — group 4
activeGeosets.insert(401); // Boots: bare feet activeGeosets.insert(502); // Bare shins (no boots) — group 5 (wider mesh)
activeGeosets.insert(501); // Chest: bare
activeGeosets.insert(702); // Ears: default activeGeosets.insert(702); // Ears: default
activeGeosets.insert(802); // Wristbands: default activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8
activeGeosets.insert(1301); // Trousers: bare legs activeGeosets.insert(902); // Kneepads: default — group 9
activeGeosets.insert(1502); // Back body (cloak=none) 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); charRenderer_->setActiveGeosets(instanceId_, activeGeosets);
// Play idle animation (Stand = animation ID 0) // Play idle animation (Stand = animation ID 0)
@ -412,44 +413,46 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style geosets.insert(static_cast<uint16_t>(100 + hairStyle_ + 1)); // Hair style
geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair geosets.insert(static_cast<uint16_t>(200 + facialHair_ + 1)); // Facial hair
geosets.insert(701); // Ears geosets.insert(701); // Ears
geosets.insert(902); // Kneepads: default (group 9)
geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET)
// Default naked geosets // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 13=pants
uint16_t geosetGloves = 301; uint16_t geosetGloves = 401; // Bare forearms (group 4)
uint16_t geosetBoots = 401; uint16_t geosetBoots = 502; // Bare shins (group 5, wider mesh)
uint16_t geosetChest = 501; uint16_t geosetSleeves = 801; // Bare wrists (group 8)
uint16_t geosetPants = 1301; 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 did = findDisplayId({4, 5, 20});
uint32_t gg = getGeosetGroup(did, 0); uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetChest = static_cast<uint16_t>(501 + gg); if (gg > 0) geosetSleeves = static_cast<uint16_t>(801 + gg);
// Robe kilt legs // Robe kilt legs
uint32_t gg3 = getGeosetGroup(did, 2); uint32_t gg3 = getGeosetGroup(did, 2);
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3); if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
} }
// Legs // Legs → group 13 (trousers)
{ {
uint32_t did = findDisplayId({7}); uint32_t did = findDisplayId({7});
uint32_t gg = getGeosetGroup(did, 0); uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetPants = static_cast<uint16_t>(1301 + gg); if (gg > 0) geosetPants = static_cast<uint16_t>(1301 + gg);
} }
// Feet // Boots → group 5 (shins)
{ {
uint32_t did = findDisplayId({8}); uint32_t did = findDisplayId({8});
uint32_t gg = getGeosetGroup(did, 0); uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetBoots = static_cast<uint16_t>(401 + gg); if (gg > 0) geosetBoots = static_cast<uint16_t>(501 + gg);
} }
// Hands // Gloves → group 4 (forearms)
{ {
uint32_t did = findDisplayId({10}); uint32_t did = findDisplayId({10});
uint32_t gg = getGeosetGroup(did, 0); uint32_t gg = getGeosetGroup(did, 0);
if (gg > 0) geosetGloves = static_cast<uint16_t>(301 + gg); if (gg > 0) geosetGloves = static_cast<uint16_t>(401 + gg);
} }
geosets.insert(geosetGloves); geosets.insert(geosetGloves);
geosets.insert(geosetBoots); geosets.insert(geosetBoots);
geosets.insert(geosetChest); geosets.insert(geosetSleeves);
geosets.insert(geosetPants); geosets.insert(geosetPants);
geosets.insert(hasInvType({16}) ? 1502 : 1501); // Cloak mesh toggle (visual may still be limited) geosets.insert(hasInvType({16}) ? 1502 : 1501); // Cloak mesh toggle (visual may still be limited)
if (hasInvType({19})) geosets.insert(1201); // Tabard mesh toggle if (hasInvType({19})) geosets.insert(1201); // Tabard mesh toggle

View file

@ -78,7 +78,7 @@ bool CharacterRenderer::initialize() {
uniform mat4 uModel; uniform mat4 uModel;
uniform mat4 uView; uniform mat4 uView;
uniform mat4 uProjection; uniform mat4 uProjection;
uniform mat4 uBones[200]; uniform mat4 uBones[240];
out vec3 FragPos; out vec3 FragPos;
out vec3 Normal; out vec3 Normal;
@ -205,7 +205,7 @@ bool CharacterRenderer::initialize() {
uniform mat4 uLightSpaceMatrix; uniform mat4 uLightSpaceMatrix;
uniform mat4 uModel; uniform mat4 uModel;
uniform mat4 uBones[200]; uniform mat4 uBones[240];
out vec2 vTexCoord; out vec2 vTexCoord;
@ -556,20 +556,7 @@ GLuint CharacterRenderer::compositeTextures(const std::vector<std::string>& laye
// Debug dump removed: it was always-on and could stall badly under load. // Debug dump removed: it was always-on and could stall badly under load.
// Debug: dump first composite to /tmp for visual inspection // 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 // Upload composite to GPU
GLuint texId; GLuint texId;
@ -588,6 +575,26 @@ GLuint CharacterRenderer::compositeTextures(const std::vector<std::string>& laye
return texId; 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, GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
const std::vector<std::string>& baseLayers, const std::vector<std::string>& baseLayers,
const std::vector<std::pair<int, std::string>>& regionLayers) { const std::vector<std::pair<int, std::string>>& regionLayers) {
@ -606,16 +613,17 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
return cacheIt->second; return cacheIt->second;
} }
// Region index → pixel coordinates on the 512x512 atlas // Region index → pixel coordinates on the 256x256 base atlas
static const int regionCoords[][2] = { // These are scaled up by (width/256, height/256) for larger textures (512x512, 1024x1024)
static const int regionCoords256[][2] = {
{ 0, 0 }, // 0 = ArmUpper { 0, 0 }, // 0 = ArmUpper
{ 0, 128 }, // 1 = ArmLower { 0, 64 }, // 1 = ArmLower
{ 0, 256 }, // 2 = Hand { 0, 128 }, // 2 = Hand
{ 256, 0 }, // 3 = TorsoUpper { 128, 0 }, // 3 = TorsoUpper
{ 256, 128 }, // 4 = TorsoLower { 128, 64 }, // 4 = TorsoLower
{ 256, 192 }, // 5 = LegUpper { 128, 96 }, // 5 = LegUpper
{ 256, 320 }, // 6 = LegLower { 128, 160 }, // 6 = LegLower
{ 256, 448 }, // 7 = Foot { 128, 224 }, // 7 = Foot
}; };
// First, build base skin + underwear using existing compositeTextures // First, build base skin + underwear using existing compositeTextures
@ -638,6 +646,8 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
int width = base.width; int width = base.width;
int height = base.height; int height = base.height;
// If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512 // If base texture is 256x256 (e.g., baked NPC texture), upscale to 512x512
// so equipment regions can be composited at correct coordinates // so equipment regions can be composited at correct coordinates
if (width == 256 && height == 256 && !regionLayers.empty()) { if (width == 256 && height == 256 && !regionLayers.empty()) {
@ -663,8 +673,8 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
} }
// Blend face + underwear overlays // Blend face + underwear overlays
// These are native-resolution textures (designed for 256x256 base). // If we upscaled from 256→512, scale coords and texels with blitOverlayScaled2x.
// If we upscaled the base to 512x512, use blitOverlayScaled2x and 2x coords. // 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); bool upscaled = (base.width == 256 && base.height == 256 && width == 512);
for (const auto& ul : baseLayers) { for (const auto& ul : baseLayers) {
if (ul.empty()) continue; if (ul.empty()) continue;
@ -679,6 +689,11 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
std::string pathLower = ul; std::string pathLower = ul;
for (auto& c : pathLower) c = std::tolower(c); 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) { if (pathLower.find("faceupper") != std::string::npos) {
dstX = 0; dstY = 160; dstX = 0; dstY = 160;
} else if (pathLower.find("facelower") != std::string::npos) { } 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) { } else if (pathLower.find("legupper") != std::string::npos || pathLower.find("leg") != std::string::npos) {
dstX = 128; dstY = 160; dstX = 128; dstY = 160;
} else { } else {
dstX = (base.width - overlay.width) / 2; // Fallback: center overlay on canvas (already in canvas coords)
dstY = (base.height - overlay.height) / 2; dstX = (width - overlay.width) / 2;
dstY = (height - overlay.height) / 2;
useScale = false;
}
if (useScale) {
dstX *= coordScale;
dstY *= coordScale;
} }
if (upscaled) { if (upscaled) {
// Scale coords and texels to match 512x512 canvas // Overlay is 256-base sized, needs 2x texel scaling for 512 canvas
dstX *= 2;
dstY *= 2;
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY); blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
} else { } else {
blitOverlay(composite, width, height, overlay, dstX, dstY); 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 // Expected region sizes on the 256x256 base atlas (scaled like coords)
static const int regionSizes[][2] = { static const int regionSizes256[][2] = {
{ 256, 128 }, // 0 = ArmUpper { 128, 64 }, // 0 = ArmUpper
{ 256, 128 }, // 1 = ArmLower { 128, 64 }, // 1 = ArmLower
{ 256, 64 }, // 2 = Hand { 128, 32 }, // 2 = Hand
{ 256, 128 }, // 3 = TorsoUpper { 128, 64 }, // 3 = TorsoUpper
{ 256, 64 }, // 4 = TorsoLower { 128, 32 }, // 4 = TorsoLower
{ 256, 128 }, // 5 = LegUpper { 128, 64 }, // 5 = LegUpper
{ 256, 128 }, // 6 = LegLower { 128, 64 }, // 6 = LegLower
{ 256, 64 }, // 7 = Foot { 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 // Now blit equipment region textures at explicit coordinates
for (const auto& rl : regionLayers) { for (const auto& rl : regionLayers) {
int regionIdx = rl.first; int regionIdx = rl.first;
@ -736,12 +762,12 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
continue; continue;
} }
int dstX = regionCoords[regionIdx][0]; int dstX = regionCoords256[regionIdx][0] * scaleX;
int dstY = regionCoords[regionIdx][1]; int dstY = regionCoords256[regionIdx][1] * scaleY;
// Component textures are stored at half resolution — scale 2x if needed // Expected full-resolution size for this region at current atlas scale
int expectedW = regionSizes[regionIdx][0]; int expectedW = regionSizes256[regionIdx][0] * scaleX;
int expectedH = regionSizes[regionIdx][1]; int expectedH = regionSizes256[regionIdx][1] * scaleY;
if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) { if (overlay.width * 2 == expectedW && overlay.height * 2 == expectedH) {
blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY); blitOverlayScaled2x(composite, width, height, overlay, dstX, dstY);
} else { } else {
@ -752,6 +778,26 @@ GLuint CharacterRenderer::compositeWithRegions(const std::string& basePath,
" at (", dstX, ",", dstY, ") ", overlay.width, "x", overlay.height, " from ", rl.second); " 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 // Upload to GPU
GLuint texId; GLuint texId;
glGenTextures(1, &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(), " verts, ", model.bones.size(), " bones, ", model.sequences.size(),
" anims, ", model.textures.size(), " textures)"); " 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<uint16_t, std::array<float,6>> 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; return true;
} }
@ -1367,11 +1444,24 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
if (texSlot >= gm.textureIds.size()) continue; if (texSlot >= gm.textureIds.size()) continue;
GLuint texId = gm.textureIds[texSlot]; GLuint texId = gm.textureIds[texSlot];
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); auto itO = inst.textureSlotOverrides.find(texSlot);
if (itO != inst.textureSlotOverrides.end() && itO->second != 0) { 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; texId = itO->second;
} }
uint32_t texType = (texSlot < gm.data.textures.size()) ? gm.data.textures[texSlot].type : 0; }
}
if (!hasFirst) { if (!hasFirst) {
first = {texId, texType}; 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). // Resolve texture for this batch (prefer hair textures for hair geosets).
GLuint texId = resolveBatchTexture(instance, gpuModel, batch); GLuint texId = resolveBatchTexture(instance, gpuModel, batch);
// For body parts with white/fallback texture, use skin (type 1) texture
// This handles humanoid models where some body parts use different texture slots
// that may not be set (e.g., baked NPC textures only set slot 0) // For body/equipment parts with white/fallback texture, use skin (type 1) texture.
// Only apply to body skin slots (type 1), NOT hair (type 6) or other types // 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) { if (texId == whiteTexture) {
uint16_t group = batch.submeshId / 100; uint16_t group = batch.submeshId / 100;
if (group == 0) { bool isSkinGroup = (group == 0 || group == 3 || group == 4 || group == 5 ||
// Check if this batch's texture slot is a body skin type 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; uint32_t texType = 0;
if (batch.textureIndex < gpuModel.data.textureLookup.size()) { if (batch.textureIndex < gpuModel.data.textureLookup.size()) {
uint16_t lk = gpuModel.data.textureLookup[batch.textureIndex]; 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; 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 // Do NOT apply skin composite to hair (type 6) batches
if (texType != 6) { if (texType != 6) {
for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) { for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) {

View file

@ -2628,9 +2628,9 @@ void GameScreen::updateCharacterGeosets(game::Inventory& inventory) {
return false; return false;
}; };
// Base geosets always present // Base geosets always present (group 0: IDs 0-99, some models use up to 27)
std::unordered_set<uint16_t> geosets; std::unordered_set<uint16_t> geosets;
for (uint16_t i = 0; i <= 18; i++) { for (uint16_t i = 0; i <= 99; i++) {
geosets.insert(i); geosets.insert(i);
} }
// Hair/facial geosets must match the active character's appearance, otherwise // 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<uint16_t>(100 + hairStyleId + 1)); // Group 1 hair geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1)); // Group 1 hair
geosets.insert(static_cast<uint16_t>(200 + facialId + 1)); // Group 2 facial geosets.insert(static_cast<uint16_t>(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<uint16_t>(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<uint16_t>(gg > 0 ? 501 + gg : 502));
}
// Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe) // 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 did = findEquippedDisplayId({4, 5, 20});
uint32_t gg = getGeosetGroup(did, 0); uint32_t gg = getGeosetGroup(did, 0);
geosets.insert(static_cast<uint16_t>(gg > 0 ? 501 + gg : (did > 0 ? 501 : 501))); geosets.insert(static_cast<uint16_t>(gg > 0 ? 801 + gg : 801));
// geosetGroup_3 > 0 on robes also shows kilt legs (1302) // Robe kilt: GeosetGroup[2] > 0 → show kilt legs (1302+)
uint32_t gg3 = getGeosetGroup(did, 2); uint32_t gg3 = getGeosetGroup(did, 2);
if (gg3 > 0) { if (gg3 > 0) {
geosets.insert(static_cast<uint16_t>(1301 + gg3)); geosets.insert(static_cast<uint16_t>(1301 + gg3));
} }
} }
// Legs: inventoryType 7 // Kneepads: group 9 (always default 902)
// geosetGroup_1 > 0 → kilt/skirt mesh (1302+), otherwise bare legs (1301) + texture 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 did = findEquippedDisplayId({7});
uint32_t gg = getGeosetGroup(did, 0); 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) { if (geosets.count(1302) == 0 && geosets.count(1303) == 0) {
geosets.insert(static_cast<uint16_t>(gg > 0 ? 1301 + gg : 1301)); geosets.insert(static_cast<uint16_t>(gg > 0 ? 1301 + gg : 1301));
} }
} }
// Feet/Boots: inventoryType 8 // Back/Cloak: inventoryType 16 → group 15
// 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<uint16_t>(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<uint16_t>(gg > 0 ? 301 + gg : 301));
}
// Back/Cloak: inventoryType 16 — geoset only, no skin texture (cloaks are separate models)
geosets.insert(hasEquippedType({16}) ? 1502 : 1501); geosets.insert(hasEquippedType({16}) ? 1502 : 1501);
// Tabard: inventoryType 19 // Tabard: inventoryType 19 → group 12
if (hasEquippedType({19})) { if (hasEquippedType({19})) {
geosets.insert(1201); 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 // 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); GLuint newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers);
if (newTex != 0) { if (newTex != 0 && instanceId != 0) {
charRenderer->setModelTexture(1, skinSlot, newTex); charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(skinSlot), newTex);
} }
// Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin) // Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin)
uint32_t cloakSlot = app.getCloakTextureSlotIndex(); uint32_t cloakSlot = app.getCloakTextureSlotIndex();
if (cloakSlot > 0) { if (cloakSlot > 0 && instanceId != 0) {
// Find equipped cloak (inventoryType 16) // Find equipped cloak (inventoryType 16)
uint32_t cloakDisplayId = 0; uint32_t cloakDisplayId = 0;
for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { 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"; std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp";
GLuint capeTex = charRenderer->loadTexture(capePath); GLuint capeTex = charRenderer->loadTexture(capePath);
if (capeTex != 0) { if (capeTex != 0) {
charRenderer->setModelTexture(1, cloakSlot, capeTex); charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot), capeTex);
LOG_INFO("Cloak texture applied: ", capePath); LOG_INFO("Cloak texture applied: ", capePath);
} }
} }
} }
} else { } else {
// No cloak equipped — reset to white fallback // No cloak equipped — clear override so model's default (white) shows
charRenderer->resetModelTexture(1, cloakSlot); charRenderer->clearTextureSlotOverride(instanceId, static_cast<uint16_t>(cloakSlot));
} }
} }
} }

View file

@ -186,7 +186,8 @@ void InventoryScreen::updatePreviewEquipment(game::Inventory& inventory) {
}; };
std::unordered_set<uint16_t> geosets; std::unordered_set<uint16_t> 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 // Hair geoset: group 1 = 100 + hairStyle + 1
geosets.insert(static_cast<uint16_t>(100 + playerHairStyle_ + 1)); geosets.insert(static_cast<uint16_t>(100 + playerHairStyle_ + 1));