Fix Turtle/Classic parsing and online player textures

This commit is contained in:
Kelsi 2026-02-13 19:40:54 -08:00
parent d2ff21a95f
commit 5afd1b65a8
13 changed files with 518 additions and 27 deletions

View file

@ -2,6 +2,7 @@
"OBJECT_FIELD_ENTRY": 3,
"UNIT_FIELD_TARGET_LO": 16,
"UNIT_FIELD_TARGET_HI": 17,
"UNIT_FIELD_BYTES_0": 36,
"UNIT_FIELD_HEALTH": 22,
"UNIT_FIELD_POWER1": 23,
"UNIT_FIELD_MAXHEALTH": 28,
@ -15,6 +16,8 @@
"UNIT_DYNAMIC_FLAGS": 143,
"UNIT_END": 188,
"PLAYER_FLAGS": 190,
"PLAYER_BYTES": 191,
"PLAYER_BYTES_2": 192,
"PLAYER_XP": 716,
"PLAYER_NEXT_LEVEL_XP": 717,
"PLAYER_FIELD_COINAGE": 1176,

View file

@ -2,6 +2,7 @@
"OBJECT_FIELD_ENTRY": 3,
"UNIT_FIELD_TARGET_LO": 16,
"UNIT_FIELD_TARGET_HI": 17,
"UNIT_FIELD_BYTES_0": 36,
"UNIT_FIELD_HEALTH": 22,
"UNIT_FIELD_POWER1": 23,
"UNIT_FIELD_MAXHEALTH": 28,
@ -16,6 +17,8 @@
"UNIT_DYNAMIC_FLAGS": 164,
"UNIT_END": 234,
"PLAYER_FLAGS": 236,
"PLAYER_BYTES": 237,
"PLAYER_BYTES_2": 238,
"PLAYER_XP": 926,
"PLAYER_NEXT_LEVEL_XP": 927,
"PLAYER_FIELD_COINAGE": 1441,

View file

@ -2,6 +2,7 @@
"OBJECT_FIELD_ENTRY": 3,
"UNIT_FIELD_TARGET_LO": 16,
"UNIT_FIELD_TARGET_HI": 17,
"UNIT_FIELD_BYTES_0": 36,
"UNIT_FIELD_HEALTH": 22,
"UNIT_FIELD_POWER1": 23,
"UNIT_FIELD_MAXHEALTH": 28,
@ -15,6 +16,8 @@
"UNIT_DYNAMIC_FLAGS": 143,
"UNIT_END": 188,
"PLAYER_FLAGS": 190,
"PLAYER_BYTES": 191,
"PLAYER_BYTES_2": 192,
"PLAYER_XP": 716,
"PLAYER_NEXT_LEVEL_XP": 717,
"PLAYER_FIELD_COINAGE": 1176,

View file

@ -2,6 +2,7 @@
"OBJECT_FIELD_ENTRY": 3,
"UNIT_FIELD_TARGET_LO": 6,
"UNIT_FIELD_TARGET_HI": 7,
"UNIT_FIELD_BYTES_0": 56,
"UNIT_FIELD_HEALTH": 24,
"UNIT_FIELD_POWER1": 25,
"UNIT_FIELD_MAXHEALTH": 32,
@ -16,6 +17,8 @@
"UNIT_DYNAMIC_FLAGS": 147,
"UNIT_END": 148,
"PLAYER_FLAGS": 150,
"PLAYER_BYTES": 151,
"PLAYER_BYTES_2": 152,
"PLAYER_XP": 634,
"PLAYER_NEXT_LEVEL_XP": 635,
"PLAYER_FIELD_COINAGE": 1170,

View file

@ -90,6 +90,13 @@ private:
void buildFactionHostilityMap(uint8_t playerRace);
void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation);
void despawnOnlineCreature(uint64_t guid);
void spawnOnlinePlayer(uint64_t guid,
uint8_t raceId,
uint8_t genderId,
uint32_t appearanceBytes,
uint8_t facialFeatures,
float x, float y, float z, float orientation);
void despawnOnlinePlayer(uint64_t guid);
void buildCreatureDisplayLookups();
std::string getModelPathForDisplayId(uint32_t displayId) const;
void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation);
@ -218,6 +225,25 @@ private:
std::unordered_set<uint64_t> pendingCreatureSpawnGuids_;
std::unordered_map<uint64_t, uint16_t> creatureSpawnRetryCounts_;
std::unordered_set<uint32_t> nonRenderableCreatureDisplayIds_;
// Online player instances (separate from creatures so we can apply per-player skin/hair textures).
std::unordered_map<uint64_t, uint32_t> playerInstances_; // guid → render instanceId
// Cache base player model geometry by (raceId, genderId)
std::unordered_map<uint32_t, uint32_t> playerModelCache_; // key=(race<<8)|gender → modelId
struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; };
std::unordered_map<uint32_t, PlayerTextureSlots> playerTextureSlotsByModelId_;
uint32_t nextPlayerModelId_ = 60000;
struct PendingPlayerSpawn {
uint64_t guid;
uint8_t raceId;
uint8_t genderId;
uint32_t appearanceBytes;
uint8_t facialFeatures;
float x, y, z, orientation;
};
std::vector<PendingPlayerSpawn> pendingPlayerSpawns_;
std::unordered_set<uint64_t> pendingPlayerSpawnGuids_;
void processPlayerSpawnQueue();
std::unordered_set<uint64_t> creaturePermanentFailureGuids_;
void processCreatureSpawnQueue();

View file

@ -480,6 +480,20 @@ public:
using CreatureDespawnCallback = std::function<void(uint64_t guid)>;
void setCreatureDespawnCallback(CreatureDespawnCallback cb) { creatureDespawnCallback_ = std::move(cb); }
// Player spawn callback (online mode - triggered when a player enters view).
// Players need appearance data so the renderer can build the right body/hair textures.
using PlayerSpawnCallback = std::function<void(uint64_t guid,
uint32_t displayId,
uint8_t raceId,
uint8_t genderId,
uint32_t appearanceBytes,
uint8_t facialFeatures,
float x, float y, float z, float orientation)>;
void setPlayerSpawnCallback(PlayerSpawnCallback cb) { playerSpawnCallback_ = std::move(cb); }
using PlayerDespawnCallback = std::function<void(uint64_t guid)>;
void setPlayerDespawnCallback(PlayerDespawnCallback cb) { playerDespawnCallback_ = std::move(cb); }
// GameObject spawn callback (online mode - triggered when gameobject enters view)
// Parameters: guid, entry, displayId, x, y, z (canonical), orientation
using GameObjectSpawnCallback = std::function<void(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation)>;
@ -1065,6 +1079,8 @@ private:
BindPointCallback bindPointCallback_;
CreatureSpawnCallback creatureSpawnCallback_;
CreatureDespawnCallback creatureDespawnCallback_;
PlayerSpawnCallback playerSpawnCallback_;
PlayerDespawnCallback playerDespawnCallback_;
CreatureMoveCallback creatureMoveCallback_;
TransportMoveCallback transportMoveCallback_;
TransportSpawnCallback transportSpawnCallback_;

View file

@ -22,6 +22,10 @@ class PacketParsers {
public:
virtual ~PacketParsers() = default;
// Size of MovementInfo.flags2 in bytes for MSG_MOVE_* payloads.
// Classic: none, TBC: u8, WotLK: u16.
virtual uint8_t movementFlags2Size() const { return 2; }
// --- Movement ---
/** Parse movement block from SMSG_UPDATE_OBJECT */
@ -145,6 +149,7 @@ class WotlkPacketParsers : public PacketParsers {
*/
class TbcPacketParsers : public PacketParsers {
public:
uint8_t movementFlags2Size() const override { return 1; }
bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override;
void writeMovementPayload(network::Packet& packet, const MovementInfo& info) override;
network::Packet buildMovementPacket(LogicalOpcode opcode,
@ -171,6 +176,7 @@ public:
*/
class ClassicPacketParsers : public TbcPacketParsers {
public:
uint8_t movementFlags2Size() const override { return 0; }
bool parseCharEnum(network::Packet& packet, CharEnumResponse& response) override;
bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override;
void writeMovementPayload(network::Packet& packet, const MovementInfo& info) override;

View file

@ -18,6 +18,7 @@ enum class UF : uint16_t {
// Unit fields
UNIT_FIELD_TARGET_LO,
UNIT_FIELD_TARGET_HI,
UNIT_FIELD_BYTES_0,
UNIT_FIELD_HEALTH,
UNIT_FIELD_POWER1,
UNIT_FIELD_MAXHEALTH,
@ -34,6 +35,8 @@ enum class UF : uint16_t {
// Player fields
PLAYER_FLAGS,
PLAYER_BYTES,
PLAYER_BYTES_2,
PLAYER_XP,
PLAYER_NEXT_LEVEL_XP,
PLAYER_FIELD_COINAGE,

View file

@ -66,6 +66,8 @@ public:
const pipeline::M2Model* getModelData(uint32_t modelId) const;
void setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets);
void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, GLuint textureId);
void setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId);
void clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot);
void setInstanceVisible(uint32_t instanceId, bool visible);
void removeInstance(uint32_t instanceId);
bool getAnimationState(uint32_t instanceId, uint32_t& animationId, float& animationTimeMs, float& animationDurationMs) const;
@ -151,6 +153,9 @@ private:
// Per-geoset-group texture overrides (group → GL texture ID)
std::unordered_map<uint16_t, GLuint> groupTextureOverrides;
// Per-texture-slot overrides (slot → GL texture ID)
std::unordered_map<uint16_t, GLuint> textureSlotOverrides;
// Weapon attachments (weapons parented to this instance's bones)
std::vector<WeaponAttachment> weaponAttachments;

View file

@ -590,6 +590,7 @@ void Application::update(float deltaTime) {
worldTime += std::chrono::duration<float, std::milli>(w2 - w1).count();
auto cq1 = std::chrono::high_resolution_clock::now();
processPlayerSpawnQueue();
// Process deferred online creature spawns (throttled)
processCreatureSpawnQueue();
auto cq2 = std::chrono::high_resolution_clock::now();
@ -1199,11 +1200,29 @@ void Application::setupUICallbacks() {
pendingCreatureSpawnGuids_.insert(guid);
});
// Player spawn callback (online mode) - spawn player models with correct textures
gameHandler->setPlayerSpawnCallback([this](uint64_t guid,
uint32_t /*displayId*/,
uint8_t raceId,
uint8_t genderId,
uint32_t appearanceBytes,
uint8_t facialFeatures,
float x, float y, float z, float orientation) {
if (playerInstances_.count(guid)) return;
if (pendingPlayerSpawnGuids_.count(guid)) return;
pendingPlayerSpawns_.push_back({guid, raceId, genderId, appearanceBytes, facialFeatures, x, y, z, orientation});
pendingPlayerSpawnGuids_.insert(guid);
});
// Creature despawn callback (online mode) - remove creature models
gameHandler->setCreatureDespawnCallback([this](uint64_t guid) {
despawnOnlineCreature(guid);
});
gameHandler->setPlayerDespawnCallback([this](uint64_t guid) {
despawnOnlinePlayer(guid);
});
// GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.)
gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation});
@ -1309,11 +1328,18 @@ void Application::setupUICallbacks() {
// Creature move callback (online mode) - update creature positions
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
else {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
}
if (instanceId != 0) {
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
float durationSec = static_cast<float>(durationMs) / 1000.0f;
renderer->getCharacterRenderer()->moveInstanceTo(it->second, renderPos, durationSec);
renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec);
}
});
@ -2915,6 +2941,10 @@ bool Application::getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, fl
if (gameHandler && guid == gameHandler->getPlayerGuid()) {
instanceId = renderer->getCharacterInstanceId();
}
if (instanceId == 0) {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId == 0) {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
@ -3488,6 +3518,240 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
" displayId=", displayId, " at (", x, ", ", y, ", ", z, ")");
}
void Application::spawnOnlinePlayer(uint64_t guid,
uint8_t raceId,
uint8_t genderId,
uint32_t appearanceBytes,
uint8_t facialFeatures,
float x, float y, float z, float orientation) {
if (!renderer || !renderer->getCharacterRenderer() || !assetManager || !assetManager->isInitialized()) return;
if (playerInstances_.count(guid)) return;
auto* charRenderer = renderer->getCharacterRenderer();
// Base geometry model: cache by (race, gender)
uint32_t cacheKey = (static_cast<uint32_t>(raceId) << 8) | static_cast<uint32_t>(genderId & 0xFF);
uint32_t modelId = 0;
auto itCache = playerModelCache_.find(cacheKey);
if (itCache != playerModelCache_.end()) {
modelId = itCache->second;
} else {
game::Race race = static_cast<game::Race>(raceId);
game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE;
std::string m2Path = game::getPlayerModelPath(race, gender);
if (m2Path.empty()) {
LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec,
" race=", (int)raceId, " gender=", (int)genderId);
return;
}
// Parse modelDir/baseName for skin/anim loading
std::string modelDir;
std::string baseName;
{
size_t slash = m2Path.rfind('\\');
if (slash != std::string::npos) {
modelDir = m2Path.substr(0, slash + 1);
baseName = m2Path.substr(slash + 1);
} else {
baseName = m2Path;
}
size_t dot = baseName.rfind('.');
if (dot != std::string::npos) baseName = baseName.substr(0, dot);
}
auto m2Data = assetManager->readFile(m2Path);
if (m2Data.empty()) {
LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path);
return;
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (!model.isValid() || model.vertices.empty()) {
LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path);
return;
}
// Skin file
std::string skinPath = modelDir + baseName + "00.skin";
auto skinData = assetManager->readFile(skinPath);
if (!skinData.empty()) {
pipeline::M2Loader::loadSkin(skinData, model);
}
// Load only core external animations (stand/walk/run) to avoid stalls
for (uint32_t si = 0; si < model.sequences.size(); si++) {
if (!(model.sequences[si].flags & 0x20)) {
uint32_t animId = model.sequences[si].id;
if (animId != 0 && animId != 4 && animId != 5) continue;
char animFileName[256];
snprintf(animFileName, sizeof(animFileName),
"%s%s%04u-%02u.anim",
modelDir.c_str(),
baseName.c_str(),
animId,
model.sequences[si].variationIndex);
auto animData = assetManager->readFileOptional(animFileName);
if (!animData.empty()) {
pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model);
}
}
}
modelId = nextPlayerModelId_++;
if (!charRenderer->loadModel(model, modelId)) {
LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path);
return;
}
playerModelCache_[cacheKey] = modelId;
}
// Determine texture slots once per model
if (!playerTextureSlotsByModelId_.count(modelId)) {
PlayerTextureSlots slots;
if (const auto* md = charRenderer->getModelData(modelId)) {
for (size_t ti = 0; ti < md->textures.size(); ti++) {
uint32_t t = md->textures[ti].type;
if (t == 1 && slots.skin < 0) slots.skin = (int)ti;
else if (t == 6 && slots.hair < 0) slots.hair = (int)ti;
else if (t == 8 && slots.underwear < 0) slots.underwear = (int)ti;
}
}
playerTextureSlotsByModelId_[modelId] = slots;
}
// Create instance at server position
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
float renderYaw = orientation + glm::radians(90.0f);
uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f);
if (instanceId == 0) return;
// Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides
const char* raceFolderName = "Human";
switch (static_cast<game::Race>(raceId)) {
case game::Race::HUMAN: raceFolderName = "Human"; break;
case game::Race::ORC: raceFolderName = "Orc"; break;
case game::Race::DWARF: raceFolderName = "Dwarf"; break;
case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break;
case game::Race::UNDEAD: raceFolderName = "Scourge"; break;
case game::Race::TAUREN: raceFolderName = "Tauren"; break;
case game::Race::GNOME: raceFolderName = "Gnome"; break;
case game::Race::TROLL: raceFolderName = "Troll"; break;
case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break;
case game::Race::DRAENEI: raceFolderName = "Draenei"; break;
default: break;
}
const char* genderFolder = (genderId == 1) ? "Female" : "Male";
std::string raceGender = std::string(raceFolderName) + genderFolder;
std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp";
std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp";
std::vector<std::string> underwearPaths;
std::string hairTexturePath;
uint8_t skinId = appearanceBytes & 0xFF;
uint8_t faceId = (appearanceBytes >> 8) & 0xFF;
uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF;
uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF;
if (auto charSectionsDbc = assetManager->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) {
const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr;
uint32_t targetRaceId = raceId;
uint32_t targetSexId = genderId;
const uint32_t csTex1 = csL ? (*csL)["Texture1"] : 4;
bool foundSkin = false;
bool foundUnderwear = false;
bool foundHair = false;
bool foundFaceLower = false;
(void)faceId; // face lower not yet applied as separate layer
for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) {
uint32_t rRace = charSectionsDbc->getUInt32(r, csL ? (*csL)["RaceID"] : 1);
uint32_t rSex = charSectionsDbc->getUInt32(r, csL ? (*csL)["SexID"] : 2);
uint32_t baseSection = charSectionsDbc->getUInt32(r, csL ? (*csL)["BaseSection"] : 3);
uint32_t variationIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 8);
uint32_t colorIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 9);
if (rRace != targetRaceId || rSex != targetSexId) continue;
if (baseSection == 0 && !foundSkin && colorIndex == skinId) {
std::string tex1 = charSectionsDbc->getString(r, csTex1);
if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; }
} else if (baseSection == 3 && !foundHair &&
variationIndex == hairStyleId && colorIndex == hairColorId) {
hairTexturePath = charSectionsDbc->getString(r, csTex1);
if (!hairTexturePath.empty()) foundHair = true;
} else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) {
for (uint32_t f = csTex1; f <= csTex1 + 2; f++) {
std::string tex = charSectionsDbc->getString(r, f);
if (!tex.empty()) underwearPaths.push_back(tex);
}
foundUnderwear = true;
} else if (baseSection == 1 && !foundFaceLower &&
variationIndex == faceId && colorIndex == skinId) {
foundFaceLower = true;
}
if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break;
}
}
// Composite base skin + underwear overlays (same as local character logic)
GLuint compositeTex = 0;
if (!underwearPaths.empty()) {
std::vector<std::string> layers;
layers.push_back(bodySkinPath);
for (const auto& up : underwearPaths) layers.push_back(up);
compositeTex = charRenderer->compositeTextures(layers);
} else {
compositeTex = charRenderer->loadTexture(bodySkinPath);
}
GLuint hairTex = 0;
if (!hairTexturePath.empty()) {
hairTex = charRenderer->loadTexture(hairTexturePath);
}
GLuint underwearTex = 0;
if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]);
else underwearTex = charRenderer->loadTexture(pelvisPath);
const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId];
if (slots.skin >= 0 && compositeTex != 0) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(slots.skin), compositeTex);
}
if (slots.hair >= 0 && hairTex != 0) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(slots.hair), hairTex);
}
if (slots.underwear >= 0 && underwearTex != 0) {
charRenderer->setTextureSlotOverride(instanceId, static_cast<uint16_t>(slots.underwear), underwearTex);
}
// Geosets: body + hair/facial hair selections
std::unordered_set<uint16_t> activeGeosets;
for (uint16_t i = 0; i <= 18; i++) activeGeosets.insert(i);
activeGeosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
activeGeosets.insert(static_cast<uint16_t>(200 + facialFeatures + 1));
activeGeosets.insert(301);
activeGeosets.insert(401);
activeGeosets.insert(501);
activeGeosets.insert(701);
activeGeosets.insert(1301);
activeGeosets.insert(1501);
charRenderer->setActiveGeosets(instanceId, activeGeosets);
charRenderer->playAnimation(instanceId, 0, true);
playerInstances_[guid] = instanceId;
}
void Application::despawnOnlinePlayer(uint64_t guid) {
if (!renderer || !renderer->getCharacterRenderer()) return;
auto it = playerInstances_.find(guid);
if (it == playerInstances_.end()) return;
renderer->getCharacterRenderer()->removeInstance(it->second);
playerInstances_.erase(it);
}
void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
if (!renderer || !assetManager) return;
@ -3818,6 +4082,27 @@ void Application::processCreatureSpawnQueue() {
}
}
void Application::processPlayerSpawnQueue() {
if (pendingPlayerSpawns_.empty()) return;
if (!assetManager || !assetManager->isInitialized()) return;
int processed = 0;
while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) {
PendingPlayerSpawn s = pendingPlayerSpawns_.front();
pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin());
pendingPlayerSpawnGuids_.erase(s.guid);
// Skip if already spawned (could have been spawned by a previous update this frame)
if (playerInstances_.count(s.guid)) {
processed++;
continue;
}
spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation);
processed++;
}
}
void Application::processGameObjectSpawnQueue() {
if (pendingGameObjectSpawns_.empty()) return;
@ -4083,6 +4368,13 @@ void Application::processPendingMount() {
}
void Application::despawnOnlineCreature(uint64_t guid) {
// If this guid is a PLAYER, it will be tracked in playerInstances_.
// Route to the correct despawn path so we don't leak instances.
if (playerInstances_.count(guid)) {
despawnOnlinePlayer(guid);
return;
}
pendingCreatureSpawnGuids_.erase(guid);
creatureSpawnRetryCounts_.erase(guid);
creaturePermanentFailureGuids_.erase(guid);

View file

@ -1558,14 +1558,10 @@ void GameHandler::handleCharEnum(network::Packet& packet) {
LOG_INFO("Handling SMSG_CHAR_ENUM");
CharEnumResponse response;
bool parsed = false;
if (build <= 6005) {
// Vanilla 1.12.x format (different equipment layout, no customization flag)
ClassicPacketParsers classicParser;
parsed = classicParser.parseCharEnum(packet, response);
} else {
parsed = CharEnumParser::parse(packet, response);
}
// IMPORTANT: Do not infer packet formats from numeric build alone.
// Turtle WoW uses a "high" build but classic-era world packet formats.
bool parsed = packetParsers_ ? packetParsers_->parseCharEnum(packet, response)
: CharEnumParser::parse(packet, response);
if (!parsed) {
fail("Failed to parse SMSG_CHAR_ENUM");
return;
@ -2688,6 +2684,87 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
return;
}
auto extractPlayerAppearance = [&](const std::map<uint16_t, uint32_t>& fields,
uint8_t& outRace,
uint8_t& outGender,
uint32_t& outAppearanceBytes,
uint8_t& outFacial) -> bool {
outRace = 0;
outGender = 0;
outAppearanceBytes = 0;
outFacial = 0;
auto readField = [&](uint16_t idx, uint32_t& out) -> bool {
if (idx == 0xFFFF) return false;
auto it = fields.find(idx);
if (it == fields.end()) return false;
out = it->second;
return true;
};
uint32_t bytes0 = 0;
uint32_t pbytes = 0;
uint32_t pbytes2 = 0;
const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0);
const uint16_t ufPbytes = fieldIndex(UF::PLAYER_BYTES);
const uint16_t ufPbytes2 = fieldIndex(UF::PLAYER_BYTES_2);
bool haveBytes0 = readField(ufBytes0, bytes0);
bool havePbytes = readField(ufPbytes, pbytes);
bool havePbytes2 = readField(ufPbytes2, pbytes2);
// Heuristic fallback: Turtle can run with unusual build numbers; if the JSON table is missing,
// try to locate plausible packed fields by scanning.
if (!haveBytes0) {
for (const auto& [idx, v] : fields) {
uint8_t race = static_cast<uint8_t>(v & 0xFF);
uint8_t cls = static_cast<uint8_t>((v >> 8) & 0xFF);
uint8_t gender = static_cast<uint8_t>((v >> 16) & 0xFF);
uint8_t power = static_cast<uint8_t>((v >> 24) & 0xFF);
if (race >= 1 && race <= 20 &&
cls >= 1 && cls <= 20 &&
gender <= 1 &&
power <= 10) {
bytes0 = v;
haveBytes0 = true;
break;
}
}
}
if (!havePbytes) {
for (const auto& [idx, v] : fields) {
uint8_t skin = static_cast<uint8_t>(v & 0xFF);
uint8_t face = static_cast<uint8_t>((v >> 8) & 0xFF);
uint8_t hair = static_cast<uint8_t>((v >> 16) & 0xFF);
uint8_t color = static_cast<uint8_t>((v >> 24) & 0xFF);
if (skin <= 50 && face <= 50 && hair <= 100 && color <= 50) {
pbytes = v;
havePbytes = true;
break;
}
}
}
if (!havePbytes2) {
for (const auto& [idx, v] : fields) {
uint8_t facial = static_cast<uint8_t>(v & 0xFF);
if (facial <= 100) {
pbytes2 = v;
havePbytes2 = true;
break;
}
}
}
if (!haveBytes0 || !havePbytes) return false;
outRace = static_cast<uint8_t>(bytes0 & 0xFF);
outGender = static_cast<uint8_t>((bytes0 >> 16) & 0xFF);
outAppearanceBytes = pbytes;
outFacial = havePbytes2 ? static_cast<uint8_t>(pbytes2 & 0xFF) : 0;
return true;
};
// Process out-of-range objects first
for (uint64_t guid : data.outOfRangeGuids) {
if (entityManager.hasEntity(guid)) {
@ -2713,6 +2790,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (entity) {
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(guid);
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
playerDespawnCallback_(guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(guid);
}
@ -2903,9 +2982,20 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (unit->getFactionTemplate() != 0) {
unit->setHostile(isHostileFaction(unit->getFactionTemplate()));
}
// 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 (creatureSpawnCallback_) {
if (block.objectType == ObjectType::PLAYER && block.guid != playerGuid) {
if (playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0;
// Use the entity's accumulated field state, not just this block's changed fields.
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
}
}
} else if (creatureSpawnCallback_) {
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
}
@ -3238,7 +3328,18 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
displayIdChanged &&
unit->getDisplayId() != 0 &&
unit->getDisplayId() != oldDisplayId) {
if (creatureSpawnCallback_) {
if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) {
if (playerSpawnCallback_) {
uint8_t race = 0, gender = 0, facial = 0;
uint32_t appearanceBytes = 0;
// Use the entity's accumulated field state, not just this block's changed fields.
if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) {
playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender,
appearanceBytes, facial,
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
}
}
} else if (creatureSpawnCallback_) {
creatureSpawnCallback_(block.guid, unit->getDisplayId(),
unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation());
}
@ -5332,14 +5433,11 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
// For classic: moveFlags(u32) + time(u32) + pos(4xf32) + [transport] + [pitch] + fallTime(u32) + [jump] + [splineElev]
MovementInfo info = {};
info.flags = packet.readUInt32();
// WotLK has uint16 flags2, classic/TBC don't
if (build >= 8606) { // TBC+
if (build >= 12340) {
info.flags2 = packet.readUInt16();
} else {
info.flags2 = packet.readUInt8();
}
}
// WotLK has u16 flags2, TBC has u8, Classic has none.
// Do NOT use build-number thresholds here (Turtle uses classic formats with a high build).
uint8_t flags2Size = packetParsers_ ? packetParsers_->movementFlags2Size() : 2;
if (flags2Size == 2) info.flags2 = packet.readUInt16();
else if (flags2Size == 1) info.flags2 = packet.readUInt8();
info.time = packet.readUInt32();
info.x = packet.readFloat();
info.y = packet.readFloat();

View file

@ -21,6 +21,7 @@ static const UFNameEntry kUFNames[] = {
{"OBJECT_FIELD_ENTRY", UF::OBJECT_FIELD_ENTRY},
{"UNIT_FIELD_TARGET_LO", UF::UNIT_FIELD_TARGET_LO},
{"UNIT_FIELD_TARGET_HI", UF::UNIT_FIELD_TARGET_HI},
{"UNIT_FIELD_BYTES_0", UF::UNIT_FIELD_BYTES_0},
{"UNIT_FIELD_HEALTH", UF::UNIT_FIELD_HEALTH},
{"UNIT_FIELD_POWER1", UF::UNIT_FIELD_POWER1},
{"UNIT_FIELD_MAXHEALTH", UF::UNIT_FIELD_MAXHEALTH},
@ -35,6 +36,8 @@ static const UFNameEntry kUFNames[] = {
{"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS},
{"UNIT_END", UF::UNIT_END},
{"PLAYER_FLAGS", UF::PLAYER_FLAGS},
{"PLAYER_BYTES", UF::PLAYER_BYTES},
{"PLAYER_BYTES_2", UF::PLAYER_BYTES_2},
{"PLAYER_XP", UF::PLAYER_XP},
{"PLAYER_NEXT_LEVEL_XP", UF::PLAYER_NEXT_LEVEL_XP},
{"PLAYER_FIELD_COINAGE", UF::PLAYER_FIELD_COINAGE},
@ -55,6 +58,7 @@ void UpdateFieldTable::loadWotlkDefaults() {
{UF::OBJECT_FIELD_ENTRY, 3},
{UF::UNIT_FIELD_TARGET_LO, 6},
{UF::UNIT_FIELD_TARGET_HI, 7},
{UF::UNIT_FIELD_BYTES_0, 56},
{UF::UNIT_FIELD_HEALTH, 24},
{UF::UNIT_FIELD_POWER1, 25},
{UF::UNIT_FIELD_MAXHEALTH, 32},
@ -69,6 +73,8 @@ void UpdateFieldTable::loadWotlkDefaults() {
{UF::UNIT_DYNAMIC_FLAGS, 147},
{UF::UNIT_END, 148},
{UF::PLAYER_FLAGS, 150},
{UF::PLAYER_BYTES, 151},
{UF::PLAYER_BYTES_2, 152},
{UF::PLAYER_XP, 634},
{UF::PLAYER_NEXT_LEVEL_XP, 635},
{UF::PLAYER_FIELD_COINAGE, 1170},

View file

@ -1291,7 +1291,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
}
}
auto resolveBatchTexture = [&](const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint {
auto resolveBatchTexture = [&](const CharacterInstance& inst, const M2ModelGPU& gm, const pipeline::M2Batch& b) -> GLuint {
// A skin batch can reference multiple textures (b.textureCount) starting at b.textureIndex.
// We currently bind only a single texture, so pick the most appropriate one.
//
@ -1316,6 +1316,10 @@ 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;
if (!hasFirst) {
@ -1353,7 +1357,7 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
(b.submeshId / 100 != 0) &&
instance.activeGeosets.find(b.submeshId) == instance.activeGeosets.end();
GLuint resolvedTex = resolveBatchTexture(gpuModel, b);
GLuint resolvedTex = resolveBatchTexture(instance, gpuModel, b);
std::string texInfo = "GL" + std::to_string(resolvedTex);
if (filtered) skipped++; else rendered++;
@ -1383,7 +1387,7 @@ 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(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
@ -1404,11 +1408,16 @@ void CharacterRenderer::render(const Camera& camera, const glm::mat4& view, cons
// Do NOT apply skin composite to hair (type 6) batches
if (texType != 6) {
for (size_t ti = 0; ti < gpuModel.textureIds.size(); ti++) {
if (gpuModel.textureIds[ti] != whiteTexture && gpuModel.textureIds[ti] != 0) {
GLuint candidate = gpuModel.textureIds[ti];
auto itO = instance.textureSlotOverrides.find(static_cast<uint16_t>(ti));
if (itO != instance.textureSlotOverrides.end() && itO->second != 0) {
candidate = itO->second;
}
if (candidate != whiteTexture && candidate != 0) {
// Only use type 1 (skin) textures as fallback
if (ti < gpuModel.data.textures.size() &&
(gpuModel.data.textures[ti].type == 1 || gpuModel.data.textures[ti].type == 11)) {
texId = gpuModel.textureIds[ti];
texId = candidate;
break;
}
}
@ -1489,6 +1498,10 @@ void CharacterRenderer::renderShadow(const glm::mat4& lightSpaceMatrix) {
uint16_t lookupIdx = gpuModel.data.textureLookup[batch.textureIndex];
if (lookupIdx < gpuModel.textureIds.size()) {
texId = gpuModel.textureIds[lookupIdx];
auto itO = instance.textureSlotOverrides.find(lookupIdx);
if (itO != instance.textureSlotOverrides.end() && itO->second != 0) {
texId = itO->second;
}
}
}
@ -1613,6 +1626,20 @@ void CharacterRenderer::setGroupTextureOverride(uint32_t instanceId, uint16_t ge
}
}
void CharacterRenderer::setTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot, GLuint textureId) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.textureSlotOverrides[textureSlot] = textureId;
}
}
void CharacterRenderer::clearTextureSlotOverride(uint32_t instanceId, uint16_t textureSlot) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.textureSlotOverrides.erase(textureSlot);
}
}
void CharacterRenderer::setInstanceVisible(uint32_t instanceId, bool visible) {
auto it = instances.find(instanceId);
if (it != instances.end()) {