fix(rendering): emote animations, WMO portal culling, transport teleport

Emote animations: fix DBC chain for /laugh, /flirt, /sleep, /fart, /stink.
Previously all emotes with AnimID=0 used emoteRef as animId (wrong DBC
record IDs). Now resolves through Emotes.dbc properly, with per-emote
overrides for emotes whose DBC chain yields 0. Adds Emotes.dbc load
failure warning and diagnostic logging.

WMO culling: skip portal culling when camera is outside all groups (fixes
vanishing Stormwind ground tiles). Also handle indoor/outdoor AABB overlap
by showing all groups when position is in both indoor and outdoor AABBs.

Transport: clear ONTRANSPORT flag and transport state when transport not
found, preventing stale transport data from teleporting player to map
origin. Add area trigger safety net near (0,0,0) on Eastern Kingdoms.
This commit is contained in:
Kelsi 2026-04-05 17:25:25 -07:00
parent fe29ccad3f
commit aff545edef
5 changed files with 101 additions and 10 deletions

View file

@ -190,7 +190,7 @@ void ChatHandler::handleMessageChat(network::Packet& packet) {
LOG_WARNING("Failed to parse SMSG_MESSAGECHAT, size=", packet.getSize());
return;
}
LOG_INFO("INCOMING CHAT: type=", static_cast<int>(data.type),
LOG_WARNING("INCOMING CHAT: type=", static_cast<int>(data.type),
" (", getChatTypeString(data.type), ") sender=0x", std::hex, data.senderGuid, std::dec,
" '", data.senderName, "' msg='", data.message.substr(0, 60), "'");

View file

@ -515,17 +515,28 @@ void MovementHandler::sendMovement(Opcode opcode) {
// Add transport data if player is on a server-recognized transport
if (includeTransportInWire) {
bool transportResolved = false;
if (owner_.transportManager_) {
auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_);
if (tr) {
transportResolved = true;
glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_);
movementInfo.x = composed.x;
movementInfo.y = composed.y;
movementInfo.z = composed.z;
}
// If transport not found, keep current movementInfo position —
// the localOffset fallback would place us near map origin (0,0,0).
}
if (!transportResolved) {
// Transport not tracked — don't send ONTRANSPORT to the server.
// Sending stale transport GUID + local offset causes the server to
// compute a bad world position and teleport us to map origin.
LOG_WARNING("sendMovement: transport 0x", std::hex, owner_.playerTransportGuid_,
std::dec, " not found — clearing transport state");
includeTransportInWire = false;
owner_.clearPlayerTransport();
}
}
if (includeTransportInWire) {
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::ONTRANSPORT);
movementInfo.transportGuid = owner_.playerTransportGuid_;
movementInfo.transportX = owner_.playerTransportOffset_.x;
@ -601,6 +612,17 @@ void MovementHandler::sendMovement(Opcode opcode) {
wireOpcode(opcode), std::dec,
(includeTransportInWire ? " ONTRANSPORT" : ""));
// Detect near-origin position on Eastern Kingdoms (map 0) — this would place
// the player near Alterac Mountains and is almost certainly a bug.
if (owner_.currentMapId_ == 0 &&
std::abs(movementInfo.x) < 500.0f && std::abs(movementInfo.y) < 500.0f) {
LOG_WARNING("sendMovement: position near map origin! canonical=(",
movementInfo.x, ", ", movementInfo.y, ", ", movementInfo.z,
") onTransport=", owner_.isOnTransport(),
" transportGuid=0x", std::hex, owner_.playerTransportGuid_, std::dec,
" flags=0x", std::hex, movementInfo.flags, std::dec);
}
// Convert canonical → server coordinates for the wire
MovementInfo wireInfo = movementInfo;
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wireInfo.x, wireInfo.y, wireInfo.z));
@ -2540,6 +2562,17 @@ void MovementHandler::checkAreaTriggers() {
const float py = movementInfo.y;
const float pz = movementInfo.z;
// Sanity: if position is near map origin on Eastern Kingdoms (map 0),
// something has corrupted movementInfo — skip area trigger check to
// avoid firing Alterac/Hillsbrad triggers and causing a rogue teleport.
if (owner_.currentMapId_ == 0 && std::abs(px) < 500.0f && std::abs(py) < 500.0f) {
LOG_WARNING("checkAreaTriggers: position near map origin (", px, ", ", py, ", ", pz,
") on map 0 — skipping to avoid rogue teleport. onTransport=",
owner_.isOnTransport(), " transportGuid=0x", std::hex,
owner_.playerTransportGuid_, std::dec);
return;
}
// On first check after map transfer, just mark which triggers we're inside
// without firing them — prevents exit portal from immediately sending us back
bool suppressFirst = owner_.areaTriggerSuppressFirst_;

View file

@ -32,7 +32,7 @@ static std::vector<std::string> parseEmoteCommands(const std::string& raw) {
static bool isLoopingEmote(const std::string& command) {
static const std::unordered_set<std::string> kLooping = {
"dance", "train", "dead", "eat", "work",
"dance", "train", "dead", "eat", "work", "sleep",
};
return kLooping.find(command) != kLooping.end();
}
@ -117,6 +117,9 @@ void EmoteRegistry::loadFromDbc() {
uint32_t animId = emotesDbc->getUInt32(r, emL ? (*emL)["AnimID"] : 2);
if (animId != 0) emoteIdToAnim[emoteId] = animId;
}
LOG_WARNING("Emotes: loaded ", emoteIdToAnim.size(), " anim mappings from Emotes.dbc");
} else {
LOG_WARNING("Emotes: Emotes.dbc failed to load — all emotes will use fallback animations");
}
emoteTable_.clear();
@ -128,11 +131,13 @@ void EmoteRegistry::loadFromDbc() {
uint32_t emoteRef = emotesTextDbc->getUInt32(r, etL ? (*etL)["EmoteRef"] : 2);
uint32_t animId = 0;
auto animIt = emoteIdToAnim.find(emoteRef);
if (animIt != emoteIdToAnim.end()) {
animId = animIt->second;
} else {
animId = emoteRef;
if (emoteRef != 0) {
auto animIt = emoteIdToAnim.find(emoteRef);
if (animIt != emoteIdToAnim.end()) {
animId = animIt->second;
}
// If Emotes.dbc has AnimID=0 for this ref, leave animId=0 (text-only).
// Previously fell back to using emoteRef as animId which is wrong.
}
uint32_t senderTargetTextId = emotesTextDbc->getUInt32(r, etL ? (*etL)["SenderTargetTextID"] : 5);
@ -161,11 +166,33 @@ void EmoteRegistry::loadFromDbc() {
}
}
// Override emotes whose DBC chain yields animId=0.
// /sleep uses the stand-state system in WoW rather than Emotes.dbc AnimID.
// /laugh and /flirt should resolve from Emotes.dbc (70 and 83), but these
// serve as backup if Emotes.dbc failed to load.
// /fart and /stink have EmoteRef=0 in EmotesText.dbc — no Emotes.dbc link.
static const std::unordered_map<std::string, uint32_t> kAnimOverrides = {
{"sleep", anim::EMOTE_SLEEP}, // 71 — stand-state emote
{"laugh", anim::EMOTE_LAUGH}, // 70 — backup
{"flirt", anim::EMOTE_SHY}, // 83 — DBC calls it SHY; it's the flirt animation
{"fart", anim::EMOTE_FLEX}, // 82 — straining/tensing gesture
{"stink", anim::EMOTE_RUDE}, // 73 — dismissive/disgusted gesture
};
for (auto& [cmd, info] : emoteTable_) {
if (info.animId == 0) {
auto ov = kAnimOverrides.find(cmd);
if (ov != kAnimOverrides.end()) {
LOG_WARNING("Emotes: override /", cmd, " → animId=", ov->second);
info.animId = ov->second;
}
}
}
if (emoteTable_.empty()) {
LOG_WARNING("Emotes: DBC loaded but no commands parsed, using fallback list");
loadFallbackEmotes();
} else {
LOG_INFO("Emotes: loaded ", emoteTable_.size(), " commands from DBC");
LOG_WARNING("Emotes: loaded ", emoteTable_.size(), " commands from DBC");
}
buildDbcIdIndex();

View file

@ -93,6 +93,9 @@ void AnimationController::playEmote(const std::string& emoteName) {
auto* characterRenderer = renderer_->getCharacterRenderer();
uint32_t characterInstanceId = renderer_->getCharacterInstanceId();
if (characterRenderer && characterInstanceId > 0) {
bool hasAnim = characterRenderer->hasAnimation(characterInstanceId, animId);
LOG_WARNING("playEmote '", emoteName, "': animId=", animId, " loop=", loop,
" modelHasAnim=", hasAnim);
characterRenderer->playAnimation(characterInstanceId, animId, loop);
lastPlayerAnimRequest_ = animId;
lastPlayerAnimLoopRequest_ = loop;

View file

@ -1418,6 +1418,17 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
// Portal-based visibility — reuse member scratch buffers (avoid per-frame alloc)
portalVisibleGroups_.clear();
bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty();
if (usePortalCulling) {
// If the actual camera is outside all groups, skip portal culling.
// The character position (portalViewerPos) may fall inside a group's
// loose AABB while visually outside the WMO, causing the BFS to start
// from an interior group whose portals aren't in the frustum — hiding
// the entire WMO.
glm::vec3 localRealCam = glm::vec3(instance.invModelMatrix * glm::vec4(camPos, 1.0f));
if (findContainingGroup(model, localRealCam) < 0) {
usePortalCulling = false;
}
}
if (usePortalCulling) {
portalVisibleGroupSet_.clear();
glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(portalViewerPos, 1.0f);
@ -2135,6 +2146,23 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,
}
return;
}
// Best-fit group is indoor-only, but the position might also be inside an
// outdoor group's AABB (e.g., standing on a street near a building whose
// indoor AABB extends outward). If any outdoor group also contains the
// position, treat this as an outdoor location and show all groups.
for (size_t gi = 0; gi < model.groups.size(); gi++) {
if (static_cast<int>(gi) == cameraGroup) continue;
const auto& g = model.groups[gi];
if (!(g.groupFlags & WMO_GROUP_FLAG_OUTDOOR)) continue;
if (cameraLocalPos.x >= g.boundingBoxMin.x && cameraLocalPos.x <= g.boundingBoxMax.x &&
cameraLocalPos.y >= g.boundingBoxMin.y && cameraLocalPos.y <= g.boundingBoxMax.y &&
cameraLocalPos.z >= g.boundingBoxMin.z && cameraLocalPos.z <= g.boundingBoxMax.z) {
for (size_t gj = 0; gj < model.groups.size(); gj++) {
outVisibleGroups.insert(static_cast<uint32_t>(gj));
}
return;
}
}
}
// If the camera group has no portal refs, it's a dead-end group (utility/transition group).