mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-04-25 04:53:52 +00:00
Compare commits
67 commits
29ca9809b1
...
785df23f1b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
785df23f1b | ||
|
|
274419584e | ||
|
|
d96a87aafc | ||
|
|
ec665bae25 | ||
|
|
48d21f97bd | ||
|
|
863ea742f6 | ||
|
|
3439df0333 | ||
|
|
d7ebc5c8c7 | ||
|
|
333ada8eb6 | ||
|
|
14c2bc97b1 | ||
|
|
137b25f318 | ||
|
|
279c30367a | ||
|
|
baaa063342 | ||
|
|
4e137c4061 | ||
|
|
c8d9d6b792 | ||
|
|
366321042f | ||
|
|
9f3c236c48 | ||
|
|
59c50e3beb | ||
|
|
c20d7c2638 | ||
|
|
60b93cdfd9 | ||
|
|
55895340e9 | ||
|
|
b2dccca58c | ||
|
|
c622e547c9 | ||
|
|
6763cfcda0 | ||
|
|
4f51103cdb | ||
|
|
a06ac018ea | ||
|
|
d22f4b30ac | ||
|
|
54246345bb | ||
|
|
baab997da8 | ||
|
|
0a157d3255 | ||
|
|
846ba58d2e | ||
|
|
03c4d59592 | ||
|
|
2a9d26e1ea | ||
|
|
0ea8e55ad4 | ||
|
|
3cdaf78369 | ||
|
|
dc2aab5e90 | ||
|
|
ac0fe1bd61 | ||
|
|
1e53369869 | ||
|
|
ea9c7e68e7 | ||
|
|
463e3f5ed1 | ||
|
|
a2dd8ee5b5 | ||
|
|
90b8cccac5 | ||
|
|
753790ae47 | ||
|
|
6f7363fbcb | ||
|
|
3fce3adb39 | ||
|
|
7cd8e86d3b | ||
|
|
f98cc32947 | ||
|
|
bbf0c0b22c | ||
|
|
65c4bd1a17 | ||
|
|
983c64720d | ||
|
|
962c640ff5 | ||
|
|
fb80b125bd | ||
|
|
682f47f66b | ||
|
|
f0233c092b | ||
|
|
4dab5daf79 | ||
|
|
4972472b2a | ||
|
|
dd8c2cbb20 | ||
|
|
f22845b238 | ||
|
|
31ae689b2c | ||
|
|
475e0c213c | ||
|
|
f533373050 | ||
|
|
d7ef40c9d7 | ||
|
|
3bee0882cc | ||
|
|
5c830216be | ||
|
|
b31a2a66b6 | ||
|
|
5b06a62d91 | ||
|
|
4a213d8da8 |
29 changed files with 1966 additions and 608 deletions
|
|
@ -22,6 +22,7 @@
|
|||
"PLAYER_BYTES_2": 192,
|
||||
"PLAYER_XP": 716,
|
||||
"PLAYER_NEXT_LEVEL_XP": 717,
|
||||
"PLAYER_REST_STATE_EXPERIENCE": 718,
|
||||
"PLAYER_FIELD_COINAGE": 1176,
|
||||
"PLAYER_QUEST_LOG_START": 198,
|
||||
"PLAYER_FIELD_INV_SLOT_HEAD": 486,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"PLAYER_BYTES_2": 238,
|
||||
"PLAYER_XP": 926,
|
||||
"PLAYER_NEXT_LEVEL_XP": 927,
|
||||
"PLAYER_REST_STATE_EXPERIENCE": 928,
|
||||
"PLAYER_FIELD_COINAGE": 1441,
|
||||
"PLAYER_QUEST_LOG_START": 244,
|
||||
"PLAYER_FIELD_INV_SLOT_HEAD": 650,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"PLAYER_BYTES_2": 192,
|
||||
"PLAYER_XP": 716,
|
||||
"PLAYER_NEXT_LEVEL_XP": 717,
|
||||
"PLAYER_REST_STATE_EXPERIENCE": 718,
|
||||
"PLAYER_FIELD_COINAGE": 1176,
|
||||
"PLAYER_QUEST_LOG_START": 198,
|
||||
"PLAYER_FIELD_INV_SLOT_HEAD": 486,
|
||||
|
|
@ -35,4 +36,4 @@
|
|||
"ITEM_FIELD_STACK_COUNT": 14,
|
||||
"CONTAINER_FIELD_NUM_SLOTS": 48,
|
||||
"CONTAINER_FIELD_SLOT_1": 50
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"PLAYER_BYTES_2": 154,
|
||||
"PLAYER_XP": 634,
|
||||
"PLAYER_NEXT_LEVEL_XP": 635,
|
||||
"PLAYER_REST_STATE_EXPERIENCE": 636,
|
||||
"PLAYER_FIELD_COINAGE": 1170,
|
||||
"PLAYER_QUEST_LOG_START": 158,
|
||||
"PLAYER_FIELD_INV_SLOT_HEAD": 324,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
A native C++ World of Warcraft client with a custom Vulkan renderer.
|
||||
|
||||
[](https://github.com/sponsors/Kelsidavis)
|
||||
[](https://discord.gg/SDqjA79B)
|
||||
[](https://discord.gg/PSdMPS8uje)
|
||||
|
||||
[](https://youtu.be/B-jtpPmiXGM)
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ public:
|
|||
// Render bounds lookup (for click targeting / selection)
|
||||
bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const;
|
||||
bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const;
|
||||
bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const;
|
||||
|
||||
// Character skin composite state (saved at spawn for re-compositing on equipment change)
|
||||
const std::string& getBodySkinPath() const { return bodySkinPath_; }
|
||||
|
|
@ -186,6 +187,9 @@ private:
|
|||
std::unordered_map<uint64_t, uint32_t> creatureInstances_; // guid → render instanceId
|
||||
std::unordered_map<uint64_t, uint32_t> creatureModelIds_; // guid → loaded modelId
|
||||
std::unordered_map<uint64_t, glm::vec3> creatureRenderPosCache_; // guid -> last synced render position
|
||||
std::unordered_map<uint64_t, bool> creatureWasMoving_; // guid -> previous-frame movement state
|
||||
std::unordered_map<uint64_t, bool> creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag)
|
||||
std::unordered_map<uint64_t, bool> creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5))
|
||||
std::unordered_set<uint64_t> creatureWeaponsAttached_; // guid set when NPC virtual weapons attached
|
||||
std::unordered_map<uint64_t, uint8_t> creatureWeaponAttachAttempts_; // guid -> attach attempts
|
||||
std::unordered_map<uint32_t, bool> modelIdIsWolfLike_; // modelId → cached wolf/worg check
|
||||
|
|
@ -360,7 +364,7 @@ private:
|
|||
std::future<PreparedNpcComposite> future;
|
||||
};
|
||||
std::vector<AsyncNpcCompositeLoad> asyncNpcCompositeLoads_;
|
||||
void processAsyncNpcCompositeResults();
|
||||
void processAsyncNpcCompositeResults(bool unlimited = false);
|
||||
// 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; };
|
||||
|
|
|
|||
|
|
@ -55,6 +55,24 @@ enum class QuestGiverStatus : uint8_t {
|
|||
REWARD = 10 // ? (yellow)
|
||||
};
|
||||
|
||||
/**
|
||||
* A single contact list entry (friend, ignore, or mute).
|
||||
*/
|
||||
struct ContactEntry {
|
||||
uint64_t guid = 0;
|
||||
std::string name;
|
||||
std::string note;
|
||||
uint32_t flags = 0; // 0x1=friend, 0x2=ignore, 0x4=mute
|
||||
uint8_t status = 0; // 0=offline, 1=online, 2=AFK, 3=DND
|
||||
uint32_t areaId = 0;
|
||||
uint32_t level = 0;
|
||||
uint32_t classId = 0;
|
||||
|
||||
bool isFriend() const { return (flags & 0x1) != 0; }
|
||||
bool isIgnored() const { return (flags & 0x2) != 0; }
|
||||
bool isOnline() const { return status != 0; }
|
||||
};
|
||||
|
||||
/**
|
||||
* World connection state
|
||||
*/
|
||||
|
|
@ -570,8 +588,10 @@ public:
|
|||
const std::unordered_map<uint32_t, TalentTabEntry>& getAllTalentTabs() const { return talentTabCache_; }
|
||||
void loadTalentDbc();
|
||||
|
||||
// Action bar
|
||||
static constexpr int ACTION_BAR_SLOTS = 12;
|
||||
// Action bar — 2 bars × 12 slots = 24 total
|
||||
static constexpr int SLOTS_PER_BAR = 12;
|
||||
static constexpr int ACTION_BARS = 2;
|
||||
static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24
|
||||
std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() { return actionBar; }
|
||||
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
|
||||
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
|
||||
|
|
@ -599,10 +619,33 @@ public:
|
|||
using NpcRespawnCallback = std::function<void(uint64_t guid)>;
|
||||
void setNpcRespawnCallback(NpcRespawnCallback cb) { npcRespawnCallback_ = std::move(cb); }
|
||||
|
||||
// Stand state animation callback — fired when SMSG_STANDSTATE_UPDATE confirms a new state
|
||||
// standState: 0=stand, 1-6=sit variants, 7=dead, 8=kneel
|
||||
using StandStateCallback = std::function<void(uint8_t standState)>;
|
||||
void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); }
|
||||
|
||||
// Ghost state callback — fired when player enters or leaves ghost (spirit) form
|
||||
using GhostStateCallback = std::function<void(bool isGhost)>;
|
||||
void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); }
|
||||
|
||||
// Melee swing callback (for driving animation/SFX)
|
||||
using MeleeSwingCallback = std::function<void()>;
|
||||
void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); }
|
||||
|
||||
// Spell cast animation callbacks — true=start cast/channel, false=finish/cancel
|
||||
// guid: caster (may be player or another unit), isChannel: channel vs regular cast
|
||||
using SpellCastAnimCallback = std::function<void(uint64_t guid, bool start, bool isChannel)>;
|
||||
void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); }
|
||||
|
||||
// Unit animation hint: signal jump (animId=38) for other players/NPCs
|
||||
using UnitAnimHintCallback = std::function<void(uint64_t guid, uint32_t animId)>;
|
||||
void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); }
|
||||
|
||||
// Unit move-flags callback: fired on every MSG_MOVE_* for other players with the raw flags field.
|
||||
// Drives Walk(4) vs Run(5) selection and swim state initialization from heartbeat packets.
|
||||
using UnitMoveFlagsCallback = std::function<void(uint64_t guid, uint32_t moveFlags)>;
|
||||
void setUnitMoveFlagsCallback(UnitMoveFlagsCallback cb) { unitMoveFlagsCallback_ = std::move(cb); }
|
||||
|
||||
// NPC swing callback (plays attack animation on NPC)
|
||||
using NpcSwingCallback = std::function<void(uint64_t guid)>;
|
||||
void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); }
|
||||
|
|
@ -620,6 +663,8 @@ public:
|
|||
// XP tracking
|
||||
uint32_t getPlayerXp() const { return playerXp_; }
|
||||
uint32_t getPlayerNextLevelXp() const { return playerNextLevelXp_; }
|
||||
uint32_t getPlayerRestedXp() const { return playerRestedXp_; }
|
||||
bool isPlayerResting() const { return isResting_; }
|
||||
uint32_t getPlayerLevel() const { return serverPlayerLevel_; }
|
||||
const std::vector<uint32_t>& getPlayerExploredZoneMasks() const { return playerExploredZones_; }
|
||||
bool hasPlayerExploredZoneMasks() const { return hasPlayerExploredZones_; }
|
||||
|
|
@ -644,8 +689,8 @@ public:
|
|||
uint32_t getSkillCategory(uint32_t skillId) const;
|
||||
|
||||
// World entry callback (online mode - triggered when entering world)
|
||||
// Parameters: mapId, x, y, z (canonical WoW coordinates)
|
||||
using WorldEntryCallback = std::function<void(uint32_t mapId, float x, float y, float z)>;
|
||||
// Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect
|
||||
using WorldEntryCallback = std::function<void(uint32_t mapId, float x, float y, float z, bool isInitialEntry)>;
|
||||
void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); }
|
||||
|
||||
// Unstuck callback (resets player Z to floor height)
|
||||
|
|
@ -800,6 +845,7 @@ public:
|
|||
void leaveGroup();
|
||||
bool isInGroup() const { return !partyData.isEmpty(); }
|
||||
const GroupListData& getPartyData() const { return partyData; }
|
||||
const std::vector<ContactEntry>& getContacts() const { return contacts_; }
|
||||
bool hasPendingGroupInvite() const { return pendingGroupInvite; }
|
||||
const std::string& getPendingInviterName() const { return pendingInviterName; }
|
||||
|
||||
|
|
@ -867,6 +913,21 @@ public:
|
|||
return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0;
|
||||
}
|
||||
|
||||
// Raid target markers (MSG_RAID_TARGET_UPDATE)
|
||||
// Icon indices 0-7: Star, Circle, Diamond, Triangle, Moon, Square, Cross, Skull
|
||||
static constexpr uint32_t kRaidMarkCount = 8;
|
||||
// Returns the GUID marked with the given icon (0 = no mark)
|
||||
uint64_t getRaidMarkGuid(uint32_t icon) const {
|
||||
return (icon < kRaidMarkCount) ? raidTargetGuids_[icon] : 0;
|
||||
}
|
||||
// Returns the raid mark icon for a given guid (0xFF = no mark)
|
||||
uint8_t getEntityRaidMark(uint64_t guid) const {
|
||||
if (guid == 0) return 0xFF;
|
||||
for (uint32_t i = 0; i < kRaidMarkCount; ++i)
|
||||
if (raidTargetGuids_[i] == guid) return static_cast<uint8_t>(i);
|
||||
return 0xFF;
|
||||
}
|
||||
|
||||
// ---- LFG / Dungeon Finder ----
|
||||
enum class LfgState : uint8_t {
|
||||
None = 0,
|
||||
|
|
@ -966,6 +1027,12 @@ public:
|
|||
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
|
||||
void abandonQuest(uint32_t questId);
|
||||
bool requestQuestQuery(uint32_t questId, bool force = false);
|
||||
bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; }
|
||||
void setQuestTracked(uint32_t questId, bool tracked) {
|
||||
if (tracked) trackedQuestIds_.insert(questId);
|
||||
else trackedQuestIds_.erase(questId);
|
||||
}
|
||||
const std::unordered_set<uint32_t>& getTrackedQuestIds() const { return trackedQuestIds_; }
|
||||
bool isQuestQueryPending(uint32_t questId) const {
|
||||
return pendingQuestQueryIds_.count(questId) > 0;
|
||||
}
|
||||
|
|
@ -1656,11 +1723,12 @@ private:
|
|||
std::unordered_map<uint32_t, GameObjectQueryResponseData> gameObjectInfoCache_;
|
||||
std::unordered_set<uint32_t> pendingGameObjectQueries_;
|
||||
|
||||
// ---- Friend list cache ----
|
||||
// ---- Friend/contact list cache ----
|
||||
std::unordered_map<std::string, uint64_t> friendsCache; // name -> guid
|
||||
std::unordered_set<uint64_t> friendGuids_; // all known friend GUIDs (for name backfill)
|
||||
uint32_t lastContactListMask_ = 0;
|
||||
uint32_t lastContactListCount_ = 0;
|
||||
std::vector<ContactEntry> contacts_; // structured contact list (friends + ignores)
|
||||
|
||||
// ---- World state and faction initialization snapshots ----
|
||||
uint32_t worldStateMapId_ = 0;
|
||||
|
|
@ -1796,6 +1864,7 @@ private:
|
|||
std::unordered_map<uint32_t, TalentEntry> talentCache_; // talentId -> entry
|
||||
std::unordered_map<uint32_t, TalentTabEntry> talentTabCache_; // tabId -> entry
|
||||
bool talentDbcLoaded_ = false;
|
||||
bool talentsInitialized_ = false; // Reset on world entry; guards first-spec selection
|
||||
|
||||
// ---- Area trigger detection ----
|
||||
struct AreaTriggerEntry {
|
||||
|
|
@ -1813,7 +1882,7 @@ private:
|
|||
bool areaTriggerSuppressFirst_ = false; // suppress first check after map transfer
|
||||
|
||||
float castTimeTotal = 0.0f;
|
||||
std::array<ActionBarSlot, 12> actionBar{};
|
||||
std::array<ActionBarSlot, ACTION_BAR_SLOTS> actionBar{};
|
||||
std::vector<AuraSlot> playerAuras;
|
||||
std::vector<AuraSlot> targetAuras;
|
||||
uint64_t petGuid_ = 0;
|
||||
|
|
@ -1836,6 +1905,9 @@ private:
|
|||
uint32_t instanceDifficulty_ = 0;
|
||||
bool instanceIsHeroic_ = false;
|
||||
|
||||
// Raid target markers (icon 0-7 -> guid; 0 = empty slot)
|
||||
std::array<uint64_t, kRaidMarkCount> raidTargetGuids_ = {};
|
||||
|
||||
// Mirror timers (0=fatigue, 1=breath, 2=feigndeath)
|
||||
MirrorTimer mirrorTimers_[3];
|
||||
|
||||
|
|
@ -1981,6 +2053,7 @@ private:
|
|||
// Quest log
|
||||
std::vector<QuestLogEntry> questLog_;
|
||||
std::unordered_set<uint32_t> pendingQuestQueryIds_;
|
||||
std::unordered_set<uint32_t> trackedQuestIds_;
|
||||
bool pendingLoginQuestResync_ = false;
|
||||
float pendingLoginQuestResyncTimeout_ = 0.0f;
|
||||
|
||||
|
|
@ -2097,6 +2170,12 @@ private:
|
|||
std::unordered_map<uint32_t, std::string> achievementNameCache_;
|
||||
bool achievementNameCacheLoaded_ = false;
|
||||
void loadAchievementNameCache();
|
||||
|
||||
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
|
||||
std::unordered_map<uint32_t, std::string> areaNameCache_;
|
||||
bool areaNameCacheLoaded_ = false;
|
||||
void loadAreaNameCache();
|
||||
std::string getAreaName(uint32_t areaId) const;
|
||||
std::vector<TrainerTab> trainerTabs_;
|
||||
void handleTrainerList(network::Packet& packet);
|
||||
void loadSpellNameCache();
|
||||
|
|
@ -2152,6 +2231,8 @@ private:
|
|||
// ---- XP tracking ----
|
||||
uint32_t playerXp_ = 0;
|
||||
uint32_t playerNextLevelXp_ = 0;
|
||||
uint32_t playerRestedXp_ = 0;
|
||||
bool isResting_ = false;
|
||||
uint32_t serverPlayerLevel_ = 1;
|
||||
static uint32_t xpForLevel(uint32_t level);
|
||||
|
||||
|
|
@ -2187,7 +2268,12 @@ private:
|
|||
NpcDeathCallback npcDeathCallback_;
|
||||
NpcAggroCallback npcAggroCallback_;
|
||||
NpcRespawnCallback npcRespawnCallback_;
|
||||
StandStateCallback standStateCallback_;
|
||||
GhostStateCallback ghostStateCallback_;
|
||||
MeleeSwingCallback meleeSwingCallback_;
|
||||
SpellCastAnimCallback spellCastAnimCallback_;
|
||||
UnitAnimHintCallback unitAnimHintCallback_;
|
||||
UnitMoveFlagsCallback unitMoveFlagsCallback_;
|
||||
NpcSwingCallback npcSwingCallback_;
|
||||
NpcGreetingCallback npcGreetingCallback_;
|
||||
NpcFarewellCallback npcFarewellCallback_;
|
||||
|
|
|
|||
|
|
@ -353,6 +353,9 @@ public:
|
|||
// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST: guid(8) + questId(4) — no trailing
|
||||
// isDialogContinued byte that WotLK added
|
||||
network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override;
|
||||
// TBC/Classic SMSG_QUESTGIVER_QUEST_DETAILS lacks informUnit(u64), flags(u32),
|
||||
// isFinished(u8) that WotLK added; uses variable item counts + emote section.
|
||||
bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -402,7 +405,7 @@ public:
|
|||
uint8_t readQuestGiverStatus(network::Packet& packet) override;
|
||||
network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override;
|
||||
network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override;
|
||||
bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override;
|
||||
// parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3)
|
||||
uint8_t questLogStride() const override { return 3; }
|
||||
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override {
|
||||
return MonsterMoveParser::parseVanilla(packet, data);
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ enum class UF : uint16_t {
|
|||
PLAYER_BYTES_2,
|
||||
PLAYER_XP,
|
||||
PLAYER_NEXT_LEVEL_XP,
|
||||
PLAYER_REST_STATE_EXPERIENCE,
|
||||
PLAYER_FIELD_COINAGE,
|
||||
PLAYER_QUEST_LOG_START,
|
||||
PLAYER_FIELD_INV_SLOT_HEAD,
|
||||
|
|
|
|||
|
|
@ -481,6 +481,10 @@ struct UpdateBlock {
|
|||
// Update flags from movement block (for detecting transports, etc.)
|
||||
uint16_t updateFlags = 0;
|
||||
|
||||
// Raw movement flags from LIVING block (SWIMMING=0x200000, WALKING=0x100, CAN_FLY=0x800000, FLYING=0x1000000)
|
||||
// Used to initialise swim/walk/fly state on entity spawn (cold-join).
|
||||
uint32_t moveFlags = 0;
|
||||
|
||||
// Transport data from LIVING movement block (MOVEMENTFLAG_ONTRANSPORT)
|
||||
bool onTransport = false;
|
||||
uint64_t transportGuid = 0;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,15 @@ public:
|
|||
*/
|
||||
void setExpansionDataPath(const std::string& path);
|
||||
|
||||
/**
|
||||
* Set a base data path to fall back to when the primary manifest
|
||||
* does not contain a requested file. Call this when the primary
|
||||
* dataPath is an expansion-specific subset (e.g. Data/expansions/vanilla/)
|
||||
* that only holds DBC overrides, not the full world asset set.
|
||||
* @param basePath Path to the base extraction (Data/) that has a manifest.json
|
||||
*/
|
||||
void setBaseFallbackPath(const std::string& basePath);
|
||||
|
||||
/**
|
||||
* Load a DBC file
|
||||
* @param name DBC file name (e.g., "Map.dbc")
|
||||
|
|
@ -66,6 +75,14 @@ public:
|
|||
*/
|
||||
std::shared_ptr<DBCFile> loadDBC(const std::string& name);
|
||||
|
||||
/**
|
||||
* Load a DBC file that is optional (not all expansions ship it).
|
||||
* Returns nullptr quietly (debug-level log only) when the file is absent.
|
||||
* @param name DBC file name (e.g., "Item.dbc")
|
||||
* @return Loaded DBC file, or nullptr if not available
|
||||
*/
|
||||
std::shared_ptr<DBCFile> loadDBCOptional(const std::string& name);
|
||||
|
||||
/**
|
||||
* Get a cached DBC file
|
||||
* @param name DBC file name
|
||||
|
|
@ -136,6 +153,11 @@ private:
|
|||
AssetManifest manifest_;
|
||||
LooseFileReader looseReader_;
|
||||
|
||||
// Optional base-path fallback: used when manifest_ doesn't contain a file.
|
||||
// Populated by setBaseFallbackPath(); ignored if baseFallbackDataPath_ is empty.
|
||||
std::string baseFallbackDataPath_;
|
||||
AssetManifest baseFallbackManifest_;
|
||||
|
||||
/**
|
||||
* Resolve filesystem path: check override dir first, then base manifest.
|
||||
* Returns empty string if not found.
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ public:
|
|||
bool isSwimming() const { return swimming; }
|
||||
bool isInsideWMO() const { return cachedInsideWMO; }
|
||||
void setGrounded(bool g) { grounded = g; }
|
||||
void setSitting(bool s) { sitting = s; }
|
||||
bool isOnTaxi() const { return externalFollow_; }
|
||||
const glm::vec3* getFollowTarget() const { return followTarget; }
|
||||
glm::vec3* getFollowTargetMutable() { return followTarget; }
|
||||
|
|
@ -156,6 +157,7 @@ private:
|
|||
static constexpr float MAX_PITCH = 35.0f; // Limited upward look
|
||||
glm::vec3* followTarget = nullptr;
|
||||
glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement
|
||||
float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised)
|
||||
|
||||
// Gravity / grounding
|
||||
float verticalVelocity = 0.0f;
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ public:
|
|||
void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation);
|
||||
void moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds);
|
||||
void startFadeIn(uint32_t instanceId, float durationSeconds);
|
||||
void setInstanceOpacity(uint32_t instanceId, float opacity);
|
||||
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, VkTexture* texture);
|
||||
|
|
@ -91,6 +92,7 @@ public:
|
|||
bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const;
|
||||
bool getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const;
|
||||
bool getInstanceFootZ(uint32_t instanceId, float& outFootZ) const;
|
||||
bool getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const;
|
||||
|
||||
/** Debug: Log all available animations for an instance */
|
||||
void dumpAnimations(uint32_t instanceId) const;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ private:
|
|||
bool showChatWindow = true;
|
||||
bool showNameplates_ = true; // V key toggles nameplates
|
||||
bool showPlayerInfo = false;
|
||||
bool showSocialFrame_ = false; // O key toggles social/friends list
|
||||
bool showGuildRoster_ = false;
|
||||
std::string selectedGuildMember_;
|
||||
bool showGuildNoteEdit_ = false;
|
||||
|
|
@ -219,6 +220,7 @@ private:
|
|||
void renderSharedQuestPopup(game::GameHandler& gameHandler);
|
||||
void renderItemTextWindow(game::GameHandler& gameHandler);
|
||||
void renderBuffBar(game::GameHandler& gameHandler);
|
||||
void renderSocialFrame(game::GameHandler& gameHandler);
|
||||
void renderLootWindow(game::GameHandler& gameHandler);
|
||||
void renderGossipWindow(game::GameHandler& gameHandler);
|
||||
void renderQuestDetailsWindow(game::GameHandler& gameHandler);
|
||||
|
|
|
|||
|
|
@ -13,11 +13,19 @@ public:
|
|||
bool isOpen() const { return open; }
|
||||
void toggle() { open = !open; }
|
||||
void setOpen(bool o) { open = o; }
|
||||
// Open the log and scroll to the given quest (by questId)
|
||||
void openAndSelectQuest(uint32_t questId) {
|
||||
open = true;
|
||||
pendingSelectQuestId_ = questId;
|
||||
scrollToSelected_ = true;
|
||||
}
|
||||
|
||||
private:
|
||||
bool open = false;
|
||||
bool lKeyWasDown = false;
|
||||
int selectedIndex = -1;
|
||||
uint32_t pendingSelectQuestId_ = 0; // non-zero: select this quest on next render
|
||||
bool scrollToSelected_ = false; // true: call SetScrollHereY once after selection
|
||||
uint32_t lastDetailRequestQuestId_ = 0;
|
||||
double lastDetailRequestAt_ = 0.0;
|
||||
std::unordered_set<uint32_t> questDetailQueryNoResponse_;
|
||||
|
|
|
|||
|
|
@ -292,6 +292,11 @@ bool Application::initialize() {
|
|||
if (std::filesystem::exists(expansionManifest)) {
|
||||
assetPath = profile->dataPath;
|
||||
LOG_INFO("Using expansion-specific asset path: ", assetPath);
|
||||
// Register base Data/ as fallback so world terrain files are found
|
||||
// even when the expansion path only contains DBC overrides.
|
||||
if (assetPath != dataPath) {
|
||||
assetManager->setBaseFallbackPath(dataPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -744,6 +749,9 @@ void Application::logoutToLogin() {
|
|||
creatureRenderPosCache_.clear();
|
||||
creatureWeaponsAttached_.clear();
|
||||
creatureWeaponAttachAttempts_.clear();
|
||||
creatureWasMoving_.clear();
|
||||
creatureSwimmingState_.clear();
|
||||
creatureWalkingState_.clear();
|
||||
deadCreatureGuids_.clear();
|
||||
nonRenderableCreatureDisplayIds_.clear();
|
||||
creaturePermanentFailureGuids_.clear();
|
||||
|
|
@ -1461,14 +1469,36 @@ void Application::update(float deltaTime) {
|
|||
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
|
||||
const bool deadOrCorpse = unitPtr->getHealth() == 0;
|
||||
const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f);
|
||||
const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f);
|
||||
if (deadOrCorpse || largeCorrection) {
|
||||
charRenderer->setInstancePosition(instanceId, renderPos);
|
||||
} else if (planarDist > 0.03f || dz > 0.08f) {
|
||||
// Use movement interpolation so step/run animation can play.
|
||||
} else if (isMovingNow) {
|
||||
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
|
||||
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
|
||||
}
|
||||
posIt->second = renderPos;
|
||||
|
||||
// Drive movement animation: Walk/Run/Swim (4/5/42) when moving,
|
||||
// Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set.
|
||||
// WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim.
|
||||
// Only switch on transitions to avoid resetting animation time.
|
||||
// Don't override Death (1) animation.
|
||||
const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0;
|
||||
const bool isWalkingNow = creatureWalkingState_.count(guid) > 0;
|
||||
bool prevMoving = creatureWasMoving_[guid];
|
||||
if (isMovingNow != prevMoving) {
|
||||
creatureWasMoving_[guid] = isMovingNow;
|
||||
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
|
||||
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
|
||||
if (!gotState || curAnimId != 1 /*Death*/) {
|
||||
uint32_t targetAnim;
|
||||
if (isMovingNow)
|
||||
targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run
|
||||
else
|
||||
targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand
|
||||
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
|
||||
}
|
||||
}
|
||||
}
|
||||
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
|
||||
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
|
||||
|
|
@ -1687,8 +1717,72 @@ void Application::setupUICallbacks() {
|
|||
});
|
||||
|
||||
// World entry callback (online mode) - load terrain when entering world
|
||||
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z) {
|
||||
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")");
|
||||
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) {
|
||||
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"
|
||||
" initial=", isInitialEntry);
|
||||
|
||||
// Reconnect to the same map: terrain stays loaded but all online entities are stale.
|
||||
// Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world.
|
||||
if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) {
|
||||
LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)");
|
||||
|
||||
// Pending spawn queues and failure caches
|
||||
pendingCreatureSpawns_.clear();
|
||||
pendingCreatureSpawnGuids_.clear();
|
||||
creatureSpawnRetryCounts_.clear();
|
||||
creaturePermanentFailureGuids_.clear(); // Clear so previously-failed GUIDs can retry
|
||||
deadCreatureGuids_.clear(); // Will be re-populated from fresh server state
|
||||
pendingPlayerSpawns_.clear();
|
||||
pendingPlayerSpawnGuids_.clear();
|
||||
pendingOnlinePlayerEquipment_.clear();
|
||||
deferredEquipmentQueue_.clear();
|
||||
pendingGameObjectSpawns_.clear();
|
||||
|
||||
// Properly despawn all tracked instances from the renderer
|
||||
{
|
||||
std::vector<uint64_t> guids;
|
||||
guids.reserve(creatureInstances_.size());
|
||||
for (const auto& [g, _] : creatureInstances_) guids.push_back(g);
|
||||
for (auto g : guids) despawnOnlineCreature(g);
|
||||
}
|
||||
{
|
||||
std::vector<uint64_t> guids;
|
||||
guids.reserve(playerInstances_.size());
|
||||
for (const auto& [g, _] : playerInstances_) guids.push_back(g);
|
||||
for (auto g : guids) despawnOnlinePlayer(g);
|
||||
}
|
||||
{
|
||||
std::vector<uint64_t> guids;
|
||||
guids.reserve(gameObjectInstances_.size());
|
||||
for (const auto& [g, _] : gameObjectInstances_) guids.push_back(g);
|
||||
for (auto g : guids) despawnOnlineGameObject(g);
|
||||
}
|
||||
|
||||
// Update player position and re-queue nearby tiles (same logic as teleport)
|
||||
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
|
||||
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
|
||||
renderer->getCharacterPosition() = renderPos;
|
||||
if (renderer->getCameraController()) {
|
||||
auto* ft = renderer->getCameraController()->getFollowTargetMutable();
|
||||
if (ft) *ft = renderPos;
|
||||
renderer->getCameraController()->clearMovementInputs();
|
||||
renderer->getCameraController()->suppressMovementFor(1.0f);
|
||||
}
|
||||
worldEntryMovementGraceTimer_ = 2.0f;
|
||||
taxiLandingClampTimer_ = 0.0f;
|
||||
lastTaxiFlight_ = false;
|
||||
renderer->getTerrainManager()->processAllReadyTiles();
|
||||
{
|
||||
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
|
||||
std::vector<std::pair<int,int>> nearbyTiles;
|
||||
nearbyTiles.reserve(289);
|
||||
for (int dy = -8; dy <= 8; dy++)
|
||||
for (int dx = -8; dx <= 8; dx++)
|
||||
nearbyTiles.push_back({tileX + dx, tileY + dy});
|
||||
renderer->getTerrainManager()->precacheTiles(nearbyTiles);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-map teleport (taxi landing, GM teleport on same continent):
|
||||
// just update position, let terrain streamer handle tile loading incrementally.
|
||||
|
|
@ -1714,6 +1808,21 @@ void Application::setupUICallbacks() {
|
|||
// (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before
|
||||
// the first frame at the new position.
|
||||
renderer->getTerrainManager()->processAllReadyTiles();
|
||||
|
||||
// Queue all remaining tiles within the load radius (8 tiles = 17x17)
|
||||
// at the new position. precacheTiles skips already-loaded/pending tiles,
|
||||
// so this only enqueues tiles that aren't yet in the pipeline.
|
||||
// This ensures background workers immediately start loading everything
|
||||
// visible from the new position (hearthstone may land far from old location).
|
||||
{
|
||||
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
|
||||
std::vector<std::pair<int,int>> nearbyTiles;
|
||||
nearbyTiles.reserve(289);
|
||||
for (int dy = -8; dy <= 8; dy++)
|
||||
for (int dx = -8; dx <= 8; dx++)
|
||||
nearbyTiles.push_back({tileX + dx, tileY + dy});
|
||||
renderer->getTerrainManager()->precacheTiles(nearbyTiles);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1976,13 +2085,15 @@ void Application::setupUICallbacks() {
|
|||
if (mapId == loadedMapId_) {
|
||||
// Same map: pre-enqueue tiles around the bind point so workers start
|
||||
// loading them now. Uses render-space coords (canonicalToRender).
|
||||
// Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time
|
||||
// for workers to parse most of these before the player arrives.
|
||||
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
|
||||
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
|
||||
|
||||
std::vector<std::pair<int,int>> tiles;
|
||||
tiles.reserve(25);
|
||||
for (int dy = -2; dy <= 2; dy++)
|
||||
for (int dx = -2; dx <= 2; dx++)
|
||||
tiles.reserve(81);
|
||||
for (int dy = -4; dy <= 4; dy++)
|
||||
for (int dx = -4; dx <= 4; dx++)
|
||||
tiles.push_back({tileX + dx, tileY + dy});
|
||||
|
||||
terrainMgr->precacheTiles(tiles);
|
||||
|
|
@ -2341,8 +2452,9 @@ void Application::setupUICallbacks() {
|
|||
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
|
||||
if (!renderer || !renderer->getCharacterRenderer()) return;
|
||||
uint32_t instanceId = 0;
|
||||
bool isPlayer = false;
|
||||
auto pit = playerInstances_.find(guid);
|
||||
if (pit != playerInstances_.end()) instanceId = pit->second;
|
||||
if (pit != playerInstances_.end()) { instanceId = pit->second; isPlayer = true; }
|
||||
else {
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end()) instanceId = it->second;
|
||||
|
|
@ -2351,6 +2463,19 @@ void Application::setupUICallbacks() {
|
|||
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
|
||||
float durationSec = static_cast<float>(durationMs) / 1000.0f;
|
||||
renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec);
|
||||
// Play Run animation (anim 5) for the duration of the spline move.
|
||||
// WoW M2 animation IDs: 4=Walk, 5=Run.
|
||||
// Don't override Death animation (1). The per-frame sync loop will return to
|
||||
// Stand when movement stops.
|
||||
if (durationMs > 0) {
|
||||
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur);
|
||||
if (!gotState || curAnimId != 1 /*Death*/) {
|
||||
cr->playAnimation(instanceId, 5u, /*loop=*/true);
|
||||
}
|
||||
if (!isPlayer) creatureWasMoving_[guid] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -2655,6 +2780,136 @@ void Application::setupUICallbacks() {
|
|||
}
|
||||
});
|
||||
|
||||
// Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs.
|
||||
// Swim/walking state is now authoritative from the move-flags callback below.
|
||||
// animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync.
|
||||
gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) {
|
||||
if (!renderer) return;
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
if (!cr) return;
|
||||
uint32_t instanceId = 0;
|
||||
{
|
||||
auto it = playerInstances_.find(guid);
|
||||
if (it != playerInstances_.end()) instanceId = it->second;
|
||||
}
|
||||
if (instanceId == 0) {
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end()) instanceId = it->second;
|
||||
}
|
||||
if (instanceId == 0) return;
|
||||
// Don't override Death animation (1)
|
||||
uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f;
|
||||
if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) return;
|
||||
cr->playAnimation(instanceId, animId, /*loop=*/true);
|
||||
});
|
||||
|
||||
// Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet.
|
||||
// This is more reliable than opcode-based hints for cold joins and heartbeats:
|
||||
// a player already swimming when we join will have SWIMMING set on the first heartbeat.
|
||||
// Walking(4) vs Running(5) is also driven here from the WALKING flag.
|
||||
gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) {
|
||||
const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
|
||||
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
|
||||
if (isSwimming) creatureSwimmingState_[guid] = true;
|
||||
else creatureSwimmingState_.erase(guid);
|
||||
if (isWalking) creatureWalkingState_[guid] = true;
|
||||
else creatureWalkingState_.erase(guid);
|
||||
});
|
||||
|
||||
// Emote animation callback — play server-driven emote animations on NPCs and other players
|
||||
gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
|
||||
if (!renderer || emoteAnim == 0) return;
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
if (!cr) return;
|
||||
// Look up creature instance first, then online players
|
||||
{
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end()) {
|
||||
cr->playAnimation(it->second, emoteAnim, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
{
|
||||
auto it = playerInstances_.find(guid);
|
||||
if (it != playerInstances_.end()) {
|
||||
cr->playAnimation(it->second, emoteAnim, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
|
||||
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) {
|
||||
if (!renderer) return;
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
if (!cr) return;
|
||||
// Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer)
|
||||
const uint32_t castAnim = 3;
|
||||
// Check player character
|
||||
{
|
||||
uint32_t charInstId = renderer->getCharacterInstanceId();
|
||||
if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) {
|
||||
if (start) cr->playAnimation(charInstId, castAnim, false);
|
||||
// On finish: playAnimation(castAnim, loop=false) will auto-return to Stand
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check creatures and other online players
|
||||
{
|
||||
auto it = creatureInstances_.find(guid);
|
||||
if (it != creatureInstances_.end()) {
|
||||
if (start) cr->playAnimation(it->second, castAnim, false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
{
|
||||
auto it = playerInstances_.find(guid);
|
||||
if (it != playerInstances_.end()) {
|
||||
if (start) cr->playAnimation(it->second, castAnim, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ghost state callback — make player semi-transparent when in spirit form
|
||||
gameHandler->setGhostStateCallback([this](bool isGhost) {
|
||||
if (!renderer) return;
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
if (!cr) return;
|
||||
uint32_t charInstId = renderer->getCharacterInstanceId();
|
||||
if (charInstId == 0) return;
|
||||
cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f);
|
||||
});
|
||||
|
||||
// Stand state animation callback — map server stand state to M2 animation on player
|
||||
// and sync camera sit flag so movement is blocked while sitting
|
||||
gameHandler->setStandStateCallback([this](uint8_t standState) {
|
||||
if (!renderer) return;
|
||||
|
||||
// Sync camera controller sitting flag: block movement while sitting/kneeling
|
||||
if (auto* cc = renderer->getCameraController()) {
|
||||
cc->setSitting(standState >= 1 && standState <= 8 && standState != 7);
|
||||
}
|
||||
|
||||
auto* cr = renderer->getCharacterRenderer();
|
||||
if (!cr) return;
|
||||
uint32_t charInstId = renderer->getCharacterInstanceId();
|
||||
if (charInstId == 0) return;
|
||||
// WoW stand state → M2 animation ID mapping
|
||||
// 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72
|
||||
uint32_t animId = 0;
|
||||
if (standState == 0) {
|
||||
animId = 0; // Stand
|
||||
} else if (standState >= 1 && standState <= 6) {
|
||||
animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height)
|
||||
} else if (standState == 7) {
|
||||
animId = 1; // Death
|
||||
} else if (standState == 8) {
|
||||
animId = 72; // Kneel
|
||||
}
|
||||
// Loop sit/kneel (not death) so the held-pose frame stays visible
|
||||
const bool loop = (animId != 1);
|
||||
cr->playAnimation(charInstId, animId, loop);
|
||||
});
|
||||
|
||||
// NPC greeting callback - play voice line
|
||||
gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) {
|
||||
if (renderer && renderer->getNpcVoiceManager()) {
|
||||
|
|
@ -3348,7 +3603,9 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan
|
|||
|
||||
auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
|
||||
if (!itemDisplayDbc) return false;
|
||||
auto itemDbc = assetManager->loadDBC("Item.dbc");
|
||||
// Item.dbc is not distributed to clients in Vanilla 1.12; on those expansions
|
||||
// item display IDs are resolved via the server-sent item cache instead.
|
||||
auto itemDbc = assetManager->loadDBCOptional("Item.dbc");
|
||||
const auto* idiL = pipeline::getActiveDBCLayout()
|
||||
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
||||
const auto* itemL = pipeline::getActiveDBCLayout()
|
||||
|
|
@ -3356,7 +3613,7 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan
|
|||
|
||||
auto resolveDisplayInfoId = [&](uint32_t rawId) -> uint32_t {
|
||||
if (rawId == 0) return 0;
|
||||
// AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID.
|
||||
// Primary path: AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID.
|
||||
// Resolve strictly through Item.dbc entry -> DisplayID to avoid
|
||||
// accidental ItemDisplayInfo ID collisions (staff/hilt mismatches).
|
||||
if (itemDbc) {
|
||||
|
|
@ -3369,6 +3626,17 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fallback: Vanilla 1.12 does not distribute Item.dbc to clients.
|
||||
// Items arrive via SMSG_ITEM_QUERY_SINGLE_RESPONSE and are cached in
|
||||
// itemInfoCache_. Use the server-sent displayInfoId when available.
|
||||
if (!itemDbc && gameHandler) {
|
||||
if (const auto* info = gameHandler->getItemInfo(rawId)) {
|
||||
uint32_t displayIdB = info->displayInfoId;
|
||||
if (displayIdB != 0 && itemDisplayDbc->findRecordById(displayIdB) >= 0) {
|
||||
return displayIdB;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
|
|
@ -4380,7 +4648,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
|
|||
// During load screen warmup: lift per-frame budgets so GPU uploads
|
||||
// and spawns happen in bulk while the loading screen is still visible.
|
||||
processCreatureSpawnQueue(true);
|
||||
processAsyncNpcCompositeResults();
|
||||
processAsyncNpcCompositeResults(true);
|
||||
// Process equipment queue more aggressively during warmup (multiple per iteration)
|
||||
for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) {
|
||||
processDeferredEquipmentQueue();
|
||||
|
|
@ -4862,6 +5130,26 @@ bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const {
|
|||
return renderer->getCharacterRenderer()->getInstanceFootZ(instanceId, outFootZ);
|
||||
}
|
||||
|
||||
bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const {
|
||||
if (!renderer || !renderer->getCharacterRenderer()) return false;
|
||||
uint32_t instanceId = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
if (instanceId == 0) return false;
|
||||
|
||||
return renderer->getCharacterRenderer()->getInstancePosition(instanceId, outPos);
|
||||
}
|
||||
|
||||
pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) {
|
||||
auto m2Data = assetManager->readFile(m2Path);
|
||||
if (m2Data.empty()) {
|
||||
|
|
@ -5595,11 +5883,11 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
if (did == 0) return 0;
|
||||
int32_t idx = itemDisplayDbc->findRecordById(did);
|
||||
if (idx < 0) {
|
||||
LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc");
|
||||
LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc");
|
||||
return 0;
|
||||
}
|
||||
uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1);
|
||||
LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg, " (field=", fGG1, ")");
|
||||
LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg);
|
||||
return gg;
|
||||
};
|
||||
|
||||
|
|
@ -5729,23 +6017,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
|
|||
activeGeosets.insert(101); // Default group 1 connector
|
||||
}
|
||||
|
||||
// Log model's actual submesh IDs for debugging geoset mismatches
|
||||
if (auto* md = charRenderer->getModelData(modelId)) {
|
||||
std::string batchIds;
|
||||
for (const auto& b : md->batches) {
|
||||
if (!batchIds.empty()) batchIds += ",";
|
||||
batchIds += std::to_string(b.submeshId);
|
||||
}
|
||||
LOG_INFO("Model batches submeshIds: [", batchIds, "]");
|
||||
}
|
||||
|
||||
// Log what geosets we're setting for debugging
|
||||
std::string geosetList;
|
||||
for (uint16_t g : activeGeosets) {
|
||||
if (!geosetList.empty()) geosetList += ",";
|
||||
geosetList += std::to_string(g);
|
||||
}
|
||||
LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]");
|
||||
charRenderer->setActiveGeosets(instanceId, activeGeosets);
|
||||
if (geosetCape != 0 && npcCapeTextureId) {
|
||||
charRenderer->setGroupTextureOverride(instanceId, 15, npcCapeTextureId);
|
||||
|
|
@ -6661,6 +6932,8 @@ void Application::despawnOnlinePlayer(uint64_t guid) {
|
|||
playerInstances_.erase(it);
|
||||
onlinePlayerAppearance_.erase(guid);
|
||||
pendingOnlinePlayerEquipment_.erase(guid);
|
||||
creatureSwimmingState_.erase(guid);
|
||||
creatureWalkingState_.erase(guid);
|
||||
}
|
||||
|
||||
void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
|
||||
|
|
@ -7052,11 +7325,21 @@ void Application::processAsyncCreatureResults(bool unlimited) {
|
|||
}
|
||||
}
|
||||
|
||||
void Application::processAsyncNpcCompositeResults() {
|
||||
void Application::processAsyncNpcCompositeResults(bool unlimited) {
|
||||
auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr;
|
||||
if (!charRenderer) return;
|
||||
|
||||
// Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing
|
||||
// simultaneously. In unlimited mode (load screen), process everything without cap.
|
||||
static constexpr float kCompositeBudgetMs = 2.0f;
|
||||
auto startTime = std::chrono::steady_clock::now();
|
||||
|
||||
for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) {
|
||||
if (!unlimited) {
|
||||
float elapsed = std::chrono::duration<float, std::milli>(
|
||||
std::chrono::steady_clock::now() - startTime).count();
|
||||
if (elapsed >= kCompositeBudgetMs) break;
|
||||
}
|
||||
if (!it->future.valid() ||
|
||||
it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
|
||||
++it;
|
||||
|
|
@ -8243,6 +8526,9 @@ void Application::despawnOnlineCreature(uint64_t guid) {
|
|||
creatureRenderPosCache_.erase(guid);
|
||||
creatureWeaponsAttached_.erase(guid);
|
||||
creatureWeaponAttachAttempts_.erase(guid);
|
||||
creatureWasMoving_.erase(guid);
|
||||
creatureSwimmingState_.erase(guid);
|
||||
creatureWalkingState_.erase(guid);
|
||||
|
||||
LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -483,8 +483,13 @@ void GameHandler::disconnect() {
|
|||
playerNameCache.clear();
|
||||
pendingNameQueries.clear();
|
||||
friendGuids_.clear();
|
||||
contacts_.clear();
|
||||
transportAttachments_.clear();
|
||||
serverUpdatedTransportGuids_.clear();
|
||||
// Clear in-flight query sets so reconnect can re-issue queries for any
|
||||
// entries whose responses were lost during the disconnect.
|
||||
pendingCreatureQueries.clear();
|
||||
pendingGameObjectQueries_.clear();
|
||||
requiresWarden_ = false;
|
||||
wardenGateSeen_ = false;
|
||||
wardenGateElapsed_ = 0.0f;
|
||||
|
|
@ -498,6 +503,8 @@ void GameHandler::disconnect() {
|
|||
wardenModuleSize_ = 0;
|
||||
wardenModuleData_.clear();
|
||||
wardenLoadedModule_.reset();
|
||||
// Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects.
|
||||
entityManager.clear();
|
||||
setState(WorldState::DISCONNECTED);
|
||||
LOG_INFO("Disconnected from world server");
|
||||
}
|
||||
|
|
@ -520,6 +527,13 @@ void GameHandler::resetDbcCaches() {
|
|||
talentDbcLoaded_ = false;
|
||||
talentCache_.clear();
|
||||
talentTabCache_.clear();
|
||||
// Clear the AssetManager DBC file cache so that expansion-specific DBCs
|
||||
// (CharSections, ItemDisplayInfo, etc.) are reloaded from the new expansion's
|
||||
// MPQ files instead of returning stale data from a previous session/expansion.
|
||||
auto* am = core::Application::getInstance().getAssetManager();
|
||||
if (am) {
|
||||
am->clearDBCCache();
|
||||
}
|
||||
LOG_INFO("GameHandler: DBC caches cleared for expansion switch");
|
||||
}
|
||||
|
||||
|
|
@ -651,6 +665,30 @@ void GameHandler::update(float deltaTime) {
|
|||
}
|
||||
}
|
||||
|
||||
// Periodically re-query names for players whose initial CMSG_NAME_QUERY was
|
||||
// lost (server didn't respond) or whose entity was recreated while the query
|
||||
// was still pending. Runs every 5 seconds to keep overhead minimal.
|
||||
if (state == WorldState::IN_WORLD && socket) {
|
||||
static float nameResyncTimer = 0.0f;
|
||||
nameResyncTimer += deltaTime;
|
||||
if (nameResyncTimer >= 5.0f) {
|
||||
nameResyncTimer = 0.0f;
|
||||
for (const auto& [guid, entity] : entityManager.getEntities()) {
|
||||
if (!entity || entity->getType() != ObjectType::PLAYER) continue;
|
||||
if (guid == playerGuid) continue;
|
||||
auto player = std::static_pointer_cast<Player>(entity);
|
||||
if (!player->getName().empty()) continue;
|
||||
if (playerNameCache.count(guid)) continue;
|
||||
if (pendingNameQueries.count(guid)) continue;
|
||||
// Player entity exists with empty name and no pending query — resend.
|
||||
LOG_DEBUG("Name resync: re-querying guid=0x", std::hex, guid, std::dec);
|
||||
pendingNameQueries.insert(guid);
|
||||
auto pkt = NameQueryPacket::build(guid);
|
||||
socket->send(pkt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingLootMoneyNotifyTimer_ > 0.0f) {
|
||||
pendingLootMoneyNotifyTimer_ -= deltaTime;
|
||||
if (pendingLootMoneyNotifyTimer_ <= 0.0f) {
|
||||
|
|
@ -1573,13 +1611,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_EXPLORATION_EXPERIENCE: {
|
||||
// uint32 areaId + uint32 xpGained
|
||||
if (packet.getSize() - packet.getReadPos() >= 8) {
|
||||
/*uint32_t areaId =*/ packet.readUInt32();
|
||||
uint32_t areaId = packet.readUInt32();
|
||||
uint32_t xpGained = packet.readUInt32();
|
||||
if (xpGained > 0) {
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"Discovered new area! Gained %u experience.", xpGained);
|
||||
addSystemChatMessage(buf);
|
||||
std::string areaName = getAreaName(areaId);
|
||||
std::string msg;
|
||||
if (!areaName.empty()) {
|
||||
msg = "Discovered " + areaName + "! Gained " +
|
||||
std::to_string(xpGained) + " experience.";
|
||||
} else {
|
||||
char buf[128];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"Discovered new area! Gained %u experience.", xpGained);
|
||||
msg = buf;
|
||||
}
|
||||
addSystemChatMessage(msg);
|
||||
// XP is updated via PLAYER_XP update fields from the server.
|
||||
}
|
||||
}
|
||||
|
|
@ -2301,24 +2347,40 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_MONSTER_MOVE_TRANSPORT:
|
||||
handleMonsterMoveTransport(packet);
|
||||
break;
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE:
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE:
|
||||
case Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL:
|
||||
case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE:
|
||||
case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE:
|
||||
case Opcode::SMSG_SPLINE_MOVE_LAND_WALK:
|
||||
case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL:
|
||||
case Opcode::SMSG_SPLINE_MOVE_ROOT:
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_FLYING:
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_HOVER:
|
||||
case Opcode::SMSG_SPLINE_MOVE_START_SWIM:
|
||||
case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: {
|
||||
// Minimal parse: PackedGuid only — entity state flag change.
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: {
|
||||
// Minimal parse: PackedGuid only — no animation-relevant state change.
|
||||
if (packet.getSize() - packet.getReadPos() >= 1) {
|
||||
(void)UpdateObjectParser::readPackedGuid(packet);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE:
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE:
|
||||
case Opcode::SMSG_SPLINE_MOVE_SET_FLYING:
|
||||
case Opcode::SMSG_SPLINE_MOVE_START_SWIM:
|
||||
case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: {
|
||||
// PackedGuid + synthesised move-flags → drives animation state in application layer.
|
||||
// SWIMMING=0x00200000, WALKING=0x00000100, CAN_FLY=0x00800000, FLYING=0x01000000
|
||||
if (packet.getSize() - packet.getReadPos() < 1) break;
|
||||
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
|
||||
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break;
|
||||
uint32_t synthFlags = 0;
|
||||
if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_START_SWIM)
|
||||
synthFlags = 0x00200000u; // SWIMMING
|
||||
else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE)
|
||||
synthFlags = 0x00000100u; // WALKING
|
||||
else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_FLYING)
|
||||
synthFlags = 0x01000000u | 0x00800000u; // FLYING | CAN_FLY
|
||||
// STOP_SWIM and SET_RUN_MODE: synthFlags stays 0 → clears swim/walk
|
||||
unitMoveFlagsCallback_(guid, synthFlags);
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_SPLINE_SET_RUN_SPEED:
|
||||
case Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED:
|
||||
case Opcode::SMSG_SPLINE_SET_SWIM_SPEED: {
|
||||
|
|
@ -2559,9 +2621,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
ssm->stopPrecast();
|
||||
}
|
||||
}
|
||||
if (spellCastAnimCallback_) {
|
||||
spellCastAnimCallback_(playerGuid, false, false);
|
||||
}
|
||||
} else {
|
||||
// Another unit's cast failed — clear their tracked cast bar
|
||||
unitCastStates_.erase(failGuid);
|
||||
if (spellCastAnimCallback_) {
|
||||
spellCastAnimCallback_(failGuid, false, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -3325,12 +3393,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
/*uint8_t mode =*/ packet.readUInt8();
|
||||
rem--;
|
||||
constexpr int SERVER_BAR_SLOTS = 144;
|
||||
constexpr int OUR_BAR_SLOTS = 12; // our actionBar array size
|
||||
for (int i = 0; i < SERVER_BAR_SLOTS; ++i) {
|
||||
if (rem < 4) break;
|
||||
uint32_t packed = packet.readUInt32();
|
||||
rem -= 4;
|
||||
if (i >= OUR_BAR_SLOTS) continue; // only load first bar
|
||||
if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2
|
||||
if (packed == 0) {
|
||||
// Empty slot — only clear if not already set to Attack/Hearthstone defaults
|
||||
// so we don't wipe hardcoded fallbacks when the server sends zeros.
|
||||
|
|
@ -3645,8 +3712,33 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
}
|
||||
break;
|
||||
}
|
||||
case Opcode::MSG_RAID_TARGET_UPDATE:
|
||||
case Opcode::MSG_RAID_TARGET_UPDATE: {
|
||||
// uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)),
|
||||
// 1 = single update (uint8 icon + uint64 guid)
|
||||
size_t remRTU = packet.getSize() - packet.getReadPos();
|
||||
if (remRTU < 1) break;
|
||||
uint8_t rtuType = packet.readUInt8();
|
||||
if (rtuType == 0) {
|
||||
// Full update: always 8 entries
|
||||
for (uint32_t i = 0; i < kRaidMarkCount; ++i) {
|
||||
if (packet.getSize() - packet.getReadPos() < 9) break;
|
||||
uint8_t icon = packet.readUInt8();
|
||||
uint64_t guid = packet.readUInt64();
|
||||
if (icon < kRaidMarkCount)
|
||||
raidTargetGuids_[icon] = guid;
|
||||
}
|
||||
} else {
|
||||
// Single update
|
||||
if (packet.getSize() - packet.getReadPos() >= 9) {
|
||||
uint8_t icon = packet.readUInt8();
|
||||
uint64_t guid = packet.readUInt64();
|
||||
if (icon < kRaidMarkCount)
|
||||
raidTargetGuids_[icon] = guid;
|
||||
}
|
||||
}
|
||||
LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast<int>(rtuType));
|
||||
break;
|
||||
}
|
||||
case Opcode::SMSG_BUY_ITEM: {
|
||||
// uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount
|
||||
// Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT.
|
||||
|
|
@ -3938,9 +4030,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
}
|
||||
quest.killCounts[entry] = {count, reqCount};
|
||||
|
||||
std::string progressMsg = quest.title + ": " +
|
||||
std::to_string(count) + "/" +
|
||||
std::to_string(reqCount);
|
||||
std::string creatureName = getCachedCreatureName(entry);
|
||||
std::string progressMsg = quest.title + ": ";
|
||||
if (!creatureName.empty()) {
|
||||
progressMsg += creatureName + " ";
|
||||
}
|
||||
progressMsg += std::to_string(count) + "/" + std::to_string(reqCount);
|
||||
addSystemChatMessage(progressMsg);
|
||||
|
||||
LOG_INFO("Updated kill count for quest ", questId, ": ",
|
||||
|
|
@ -4140,7 +4235,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_TRANSFER_PENDING: {
|
||||
// SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data
|
||||
uint32_t pendingMapId = packet.readUInt32();
|
||||
LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
|
||||
LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
|
||||
// Optional: if remaining data, there's a transport entry + mapId
|
||||
if (packet.getReadPos() + 8 <= packet.getSize()) {
|
||||
uint32_t transportEntry = packet.readUInt32();
|
||||
|
|
@ -4174,6 +4269,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
LOG_INFO("Stand state updated: ", static_cast<int>(standState_),
|
||||
" (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit"
|
||||
: standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")");
|
||||
if (standStateCallback_) {
|
||||
standStateCallback_(standState_);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case Opcode::SMSG_NEW_TAXI_PATH:
|
||||
|
|
@ -4375,6 +4473,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::MSG_MOVE_HEARTBEAT:
|
||||
case Opcode::MSG_MOVE_START_SWIM:
|
||||
case Opcode::MSG_MOVE_STOP_SWIM:
|
||||
case Opcode::MSG_MOVE_SET_WALK_MODE:
|
||||
case Opcode::MSG_MOVE_SET_RUN_MODE:
|
||||
case Opcode::MSG_MOVE_START_PITCH_UP:
|
||||
case Opcode::MSG_MOVE_START_PITCH_DOWN:
|
||||
case Opcode::MSG_MOVE_STOP_PITCH:
|
||||
case Opcode::MSG_MOVE_START_ASCEND:
|
||||
case Opcode::MSG_MOVE_STOP_ASCEND:
|
||||
if (state == WorldState::IN_WORLD) {
|
||||
handleOtherPlayerMovement(packet);
|
||||
}
|
||||
|
|
@ -4588,8 +4693,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
|
|||
case Opcode::SMSG_SET_REST_START: {
|
||||
if (packet.getSize() - packet.getReadPos() >= 4) {
|
||||
uint32_t restTrigger = packet.readUInt32();
|
||||
addSystemChatMessage(restTrigger > 0 ? "You are now resting."
|
||||
: "You are no longer resting.");
|
||||
isResting_ = (restTrigger > 0);
|
||||
addSystemChatMessage(isResting_ ? "You are now resting."
|
||||
: "You are no longer resting.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
@ -5952,7 +6058,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
|||
|
||||
// Initialize movement info with world entry position (server → canonical)
|
||||
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z));
|
||||
LOG_WARNING("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
|
||||
LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
|
||||
") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId);
|
||||
movementInfo.x = canonical.x;
|
||||
movementInfo.y = canonical.y;
|
||||
|
|
@ -5978,8 +6084,18 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
|||
mountCallback_(0);
|
||||
}
|
||||
|
||||
// Clear boss encounter unit slots on world transfer
|
||||
// Clear boss encounter unit slots and raid marks on world transfer
|
||||
encounterUnitGuids_.fill(0);
|
||||
raidTargetGuids_.fill(0);
|
||||
|
||||
// Reset talent initialization so the first SMSG_TALENTS_INFO after login
|
||||
// correctly sets the active spec (static locals don't reset across logins)
|
||||
talentsInitialized_ = false;
|
||||
learnedTalents_[0].clear();
|
||||
learnedTalents_[1].clear();
|
||||
unspentTalentPoints_[0] = 0;
|
||||
unspentTalentPoints_[1] = 0;
|
||||
activeTalentSpec_ = 0;
|
||||
|
||||
// Suppress area triggers on initial login — prevents exit portals from
|
||||
// immediately firing when spawning inside a dungeon/instance.
|
||||
|
|
@ -5996,7 +6112,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
|
|||
|
||||
// Notify application to load terrain for this map/position (online mode)
|
||||
if (worldEntryCallback_) {
|
||||
worldEntryCallback_(data.mapId, data.x, data.y, data.z);
|
||||
worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry);
|
||||
}
|
||||
|
||||
// Auto-join default chat channels
|
||||
|
|
@ -7247,7 +7363,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
static int updateObjErrors = 0;
|
||||
if (++updateObjErrors <= 5)
|
||||
LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT");
|
||||
return;
|
||||
if (data.blocks.empty()) return;
|
||||
// Fall through: process any blocks that were successfully parsed before the failure.
|
||||
}
|
||||
|
||||
auto extractPlayerAppearance = [&](const std::map<uint16_t, uint32_t>& fields,
|
||||
|
|
@ -7403,6 +7520,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
otherPlayerMoveTimeMs_.erase(guid);
|
||||
inspectedPlayerItemEntries_.erase(guid);
|
||||
pendingAutoInspect_.erase(guid);
|
||||
// Clear pending name query so the query is re-sent when this player
|
||||
// comes back into range (entity is recreated as a new object).
|
||||
pendingNameQueries.erase(guid);
|
||||
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
|
||||
gameObjectDespawnCallback_(guid);
|
||||
}
|
||||
|
|
@ -7652,6 +7772,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
releasedSpirit_ = true;
|
||||
playerDead_ = true;
|
||||
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
|
||||
if (ghostStateCallback_) ghostStateCallback_(true);
|
||||
}
|
||||
}
|
||||
// Determine hostility from faction template for online creatures
|
||||
|
|
@ -7688,6 +7809,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
npcDeathCallback_(block.guid);
|
||||
}
|
||||
}
|
||||
// Initialise swim/walk state from spawn-time movement flags (cold-join fix).
|
||||
// Without this, an entity already swimming/walking when the client joins
|
||||
// won't get its animation state set until the next MSG_MOVE_* heartbeat.
|
||||
if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ &&
|
||||
block.guid != playerGuid) {
|
||||
unitMoveFlagsCallback_(block.guid, block.moveFlags);
|
||||
}
|
||||
// Query quest giver status for NPCs with questgiver flag (0x02)
|
||||
if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
|
||||
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
|
||||
|
|
@ -7713,13 +7841,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
queryGameObjectInfo(itEntry->second, block.guid);
|
||||
}
|
||||
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
|
||||
LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
|
||||
LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
|
||||
" entry=", go->getEntry(), " displayId=", go->getDisplayId(),
|
||||
" updateFlags=0x", std::hex, block.updateFlags, std::dec,
|
||||
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
|
||||
if (block.updateFlags & 0x0002) {
|
||||
transportGuids_.insert(block.guid);
|
||||
LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
|
||||
LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
|
||||
" entry=", go->getEntry(),
|
||||
" displayId=", go->getDisplayId(),
|
||||
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
|
||||
|
|
@ -7775,6 +7903,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
bool slotsChanged = false;
|
||||
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
|
||||
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
|
||||
const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE);
|
||||
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
|
||||
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
|
||||
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
|
||||
|
|
@ -7782,6 +7911,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
for (const auto& [key, val] : block.fields) {
|
||||
if (key == ufPlayerXp) { playerXp_ = val; }
|
||||
else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; }
|
||||
else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; }
|
||||
else if (key == ufPlayerLevel) {
|
||||
serverPlayerLevel_ = val;
|
||||
for (auto& ch : characters) {
|
||||
|
|
@ -8113,12 +8243,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
|
|||
if (!wasGhost && nowGhost) {
|
||||
releasedSpirit_ = true;
|
||||
LOG_INFO("Player entered ghost form (PLAYER_FLAGS)");
|
||||
if (ghostStateCallback_) ghostStateCallback_(true);
|
||||
} else if (wasGhost && !nowGhost) {
|
||||
releasedSpirit_ = false;
|
||||
playerDead_ = false;
|
||||
repopPending_ = false;
|
||||
resurrectPending_ = false;
|
||||
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
|
||||
if (ghostStateCallback_) ghostStateCallback_(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8366,6 +8498,15 @@ void GameHandler::handleDestroyObject(network::Packet& packet) {
|
|||
if (entity) {
|
||||
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
|
||||
creatureDespawnCallback_(data.guid);
|
||||
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
|
||||
// Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range.
|
||||
playerDespawnCallback_(data.guid);
|
||||
otherPlayerVisibleItemEntries_.erase(data.guid);
|
||||
otherPlayerVisibleDirty_.erase(data.guid);
|
||||
otherPlayerMoveTimeMs_.erase(data.guid);
|
||||
inspectedPlayerItemEntries_.erase(data.guid);
|
||||
pendingAutoInspect_.erase(data.guid);
|
||||
pendingNameQueries.erase(data.guid);
|
||||
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
|
||||
gameObjectDespawnCallback_(data.guid);
|
||||
}
|
||||
|
|
@ -8719,6 +8860,10 @@ void GameHandler::setTarget(uint64_t guid) {
|
|||
|
||||
targetGuid = guid;
|
||||
|
||||
// Clear stale aura data from the previous target so the buff bar shows
|
||||
// an empty state until the server sends SMSG_AURA_UPDATE_ALL for the new target.
|
||||
for (auto& slot : targetAuras) slot = AuraSlot{};
|
||||
|
||||
// Clear previous target's cast bar on target change
|
||||
// (the new target's cast state is naturally fetched from unitCastStates_ by GUID)
|
||||
|
||||
|
|
@ -9822,7 +9967,20 @@ void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
|
|||
// ============================================================
|
||||
|
||||
void GameHandler::queryPlayerName(uint64_t guid) {
|
||||
if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return;
|
||||
// If already cached, apply the name to the entity (handles entity recreation after
|
||||
// moving out/in range — the entity object is new but the cached name is valid).
|
||||
auto cacheIt = playerNameCache.find(guid);
|
||||
if (cacheIt != playerNameCache.end()) {
|
||||
auto entity = entityManager.getEntity(guid);
|
||||
if (entity && entity->getType() == ObjectType::PLAYER) {
|
||||
auto player = std::static_pointer_cast<Player>(entity);
|
||||
if (player->getName().empty()) {
|
||||
player->setName(cacheIt->second);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (pendingNameQueries.count(guid)) return;
|
||||
if (state != WorldState::IN_WORLD || !socket) {
|
||||
LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec,
|
||||
" state=", worldStateName(state), " socket=", (socket ? "yes" : "no"));
|
||||
|
|
@ -10037,8 +10195,8 @@ void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) {
|
|||
? packetParsers_->buildItemQuery(entry, queryGuid)
|
||||
: ItemQueryPacket::build(entry, queryGuid);
|
||||
socket->send(packet);
|
||||
LOG_INFO("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
|
||||
" pending=", pendingItemQueries_.size());
|
||||
LOG_DEBUG("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
|
||||
" pending=", pendingItemQueries_.size());
|
||||
}
|
||||
|
||||
void GameHandler::handleItemQueryResponse(network::Packet& packet) {
|
||||
|
|
@ -10052,9 +10210,8 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) {
|
|||
}
|
||||
|
||||
pendingItemQueries_.erase(data.entry);
|
||||
LOG_INFO("handleItemQueryResponse: entry=", data.entry, " valid=", data.valid,
|
||||
" name='", data.name, "' displayInfoId=", data.displayInfoId,
|
||||
" pending=", pendingItemQueries_.size());
|
||||
LOG_DEBUG("handleItemQueryResponse: entry=", data.entry, " name='", data.name,
|
||||
"' displayInfoId=", data.displayInfoId, " pending=", pendingItemQueries_.size());
|
||||
|
||||
if (data.valid) {
|
||||
itemInfoCache_[data.entry] = data;
|
||||
|
|
@ -11056,7 +11213,8 @@ void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* na
|
|||
if (guid != playerGuid) return;
|
||||
|
||||
// Always ACK the speed change to prevent server stall.
|
||||
if (socket && !isClassicLikeExpansion()) {
|
||||
// Classic/TBC use full uint64 GUID; WotLK uses packed GUID.
|
||||
if (socket) {
|
||||
network::Packet ack(wireOpcode(ackOpcode));
|
||||
const bool legacyGuidAck =
|
||||
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
|
||||
|
|
@ -11142,7 +11300,7 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted)
|
|||
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ROOT);
|
||||
}
|
||||
|
||||
if (!socket || isClassicLikeExpansion()) return;
|
||||
if (!socket) return;
|
||||
uint16_t ackWire = wireOpcode(rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK
|
||||
: Opcode::CMSG_FORCE_MOVE_UNROOT_ACK);
|
||||
if (ackWire == 0xFFFF) return;
|
||||
|
|
@ -11203,7 +11361,7 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char*
|
|||
}
|
||||
}
|
||||
|
||||
if (!socket || isClassicLikeExpansion()) return;
|
||||
if (!socket) return;
|
||||
uint16_t ackWire = wireOpcode(ackOpcode);
|
||||
if (ackWire == 0xFFFF) return;
|
||||
|
||||
|
|
@ -11258,7 +11416,7 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) {
|
|||
|
||||
if (guid != playerGuid) return;
|
||||
|
||||
if (!socket || isClassicLikeExpansion()) return;
|
||||
if (!socket) return;
|
||||
uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK);
|
||||
if (ackWire == 0xFFFF) return;
|
||||
|
||||
|
|
@ -12037,11 +12195,39 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
|
|||
}
|
||||
otherPlayerMoveTimeMs_[moverGuid] = info.time;
|
||||
|
||||
entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, durationMs / 1000.0f);
|
||||
// Classify the opcode so we can drive the correct entity update and animation.
|
||||
const uint16_t wireOp = packet.getOpcode();
|
||||
const bool isStopOpcode =
|
||||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP)) ||
|
||||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE)) ||
|
||||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_TURN)) ||
|
||||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)) ||
|
||||
(wireOp == wireOpcode(Opcode::MSG_MOVE_FALL_LAND));
|
||||
const bool isJumpOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_JUMP));
|
||||
|
||||
// Notify renderer
|
||||
// For stop opcodes snap the entity position (duration=0) so it doesn't keep interpolating,
|
||||
// and pass durationMs=0 to the renderer so the Run-anim flash is suppressed.
|
||||
// The per-frame sync will detect no movement and play Stand on the next frame.
|
||||
const float entityDuration = isStopOpcode ? 0.0f : (durationMs / 1000.0f);
|
||||
entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, entityDuration);
|
||||
|
||||
// Notify renderer of position change
|
||||
if (creatureMoveCallback_) {
|
||||
creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, durationMs);
|
||||
const uint32_t notifyDuration = isStopOpcode ? 0u : durationMs;
|
||||
creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration);
|
||||
}
|
||||
|
||||
// Signal specific animation transitions that the per-frame sync can't detect reliably.
|
||||
// WoW M2 animation ID 38=JumpMid (loops during airborne).
|
||||
// Swim/walking state is now authoritative from the movement flags field via unitMoveFlagsCallback_.
|
||||
if (unitAnimHintCallback_ && isJumpOpcode) {
|
||||
unitAnimHintCallback_(moverGuid, 38u);
|
||||
}
|
||||
|
||||
// Fire move-flags callback so application.cpp can update swimming/walking state
|
||||
// from the flags field embedded in every movement packet (covers heartbeats and cold joins).
|
||||
if (unitMoveFlagsCallback_) {
|
||||
unitMoveFlagsCallback_(moverGuid, info.flags);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -12059,7 +12245,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) {
|
|||
|
||||
// Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*)
|
||||
// Not static — wireOpcode() depends on runtime active opcode table.
|
||||
const std::array<uint16_t, 15> kMoveOpcodes = {
|
||||
const std::array<uint16_t, 22> kMoveOpcodes = {
|
||||
wireOpcode(Opcode::MSG_MOVE_START_FORWARD),
|
||||
wireOpcode(Opcode::MSG_MOVE_START_BACKWARD),
|
||||
wireOpcode(Opcode::MSG_MOVE_STOP),
|
||||
|
|
@ -12075,6 +12261,13 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) {
|
|||
wireOpcode(Opcode::MSG_MOVE_HEARTBEAT),
|
||||
wireOpcode(Opcode::MSG_MOVE_START_SWIM),
|
||||
wireOpcode(Opcode::MSG_MOVE_STOP_SWIM),
|
||||
wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE),
|
||||
wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE),
|
||||
wireOpcode(Opcode::MSG_MOVE_START_PITCH_UP),
|
||||
wireOpcode(Opcode::MSG_MOVE_START_PITCH_DOWN),
|
||||
wireOpcode(Opcode::MSG_MOVE_STOP_PITCH),
|
||||
wireOpcode(Opcode::MSG_MOVE_START_ASCEND),
|
||||
wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND),
|
||||
};
|
||||
|
||||
// Track unhandled sub-opcodes once per compressed packet (avoid log spam)
|
||||
|
|
@ -12164,16 +12357,6 @@ void GameHandler::handleMonsterMove(network::Packet& packet) {
|
|||
return;
|
||||
}
|
||||
decompressed.resize(destLen);
|
||||
// Dump ALL bytes for format diagnosis (remove once confirmed)
|
||||
static int dumpCount = 0;
|
||||
if (dumpCount < 10) {
|
||||
++dumpCount;
|
||||
std::string hex;
|
||||
for (size_t i = 0; i < destLen; ++i) {
|
||||
char buf[4]; snprintf(buf, sizeof(buf), "%02X ", decompressed[i]); hex += buf;
|
||||
}
|
||||
LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex);
|
||||
}
|
||||
std::vector<uint8_t> stripped;
|
||||
bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped);
|
||||
|
||||
|
|
@ -12763,11 +12946,8 @@ void GameHandler::handleInitialSpells(network::Packet& packet) {
|
|||
|
||||
knownSpells = {data.spellIds.begin(), data.spellIds.end()};
|
||||
|
||||
// Debug: check if specific spells are in initial spells
|
||||
bool has527 = knownSpells.count(527u);
|
||||
bool has988 = knownSpells.count(988u);
|
||||
bool has1180 = knownSpells.count(1180u);
|
||||
LOG_INFO("Initial spells include: 527=", has527, " 988=", has988, " 1180=", has1180);
|
||||
LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u),
|
||||
" 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u));
|
||||
|
||||
// Ensure Attack (6603) and Hearthstone (8690) are always present
|
||||
knownSpells.insert(6603u);
|
||||
|
|
@ -12846,6 +13026,10 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
|
|||
s.spellId = data.spellId;
|
||||
s.timeTotal = data.castTime / 1000.0f;
|
||||
s.timeRemaining = s.timeTotal;
|
||||
// Trigger cast animation on the casting unit
|
||||
if (spellCastAnimCallback_) {
|
||||
spellCastAnimCallback_(data.casterUnit, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is the player's own cast, start cast bar
|
||||
|
|
@ -12867,6 +13051,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
|
|||
}
|
||||
}
|
||||
|
||||
// Trigger cast animation on player character
|
||||
if (spellCastAnimCallback_) {
|
||||
spellCastAnimCallback_(playerGuid, true, false);
|
||||
}
|
||||
|
||||
// Hearthstone cast: begin pre-loading terrain at bind point during cast time
|
||||
// so tiles are ready when the teleport fires (avoids falling through un-loaded terrain).
|
||||
// Spell IDs: 6948 = Vanilla Hearthstone (rank 1), 8690 = TBC/WotLK Hearthstone
|
||||
|
|
@ -12918,6 +13107,14 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
|
|||
casting = false;
|
||||
currentCastSpellId = 0;
|
||||
castTimeRemaining = 0.0f;
|
||||
|
||||
// End cast animation on player character
|
||||
if (spellCastAnimCallback_) {
|
||||
spellCastAnimCallback_(playerGuid, false, false);
|
||||
}
|
||||
} else if (spellCastAnimCallback_) {
|
||||
// End cast animation on other unit
|
||||
spellCastAnimCallback_(data.casterUnit, false, false);
|
||||
}
|
||||
|
||||
// Clear unit cast bar when the spell lands (for any tracked unit)
|
||||
|
|
@ -13134,10 +13331,9 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) {
|
|||
" unspent=", (int)unspentTalentPoints_[data.talentSpec],
|
||||
" learned=", learnedTalents_[data.talentSpec].size());
|
||||
|
||||
// If this is the first spec received, set it as active
|
||||
static bool firstSpecReceived = false;
|
||||
if (!firstSpecReceived) {
|
||||
firstSpecReceived = true;
|
||||
// If this is the first spec received after login, set it as the active spec
|
||||
if (!talentsInitialized_) {
|
||||
talentsInitialized_ = true;
|
||||
activeTalentSpec_ = data.talentSpec;
|
||||
|
||||
// Show message to player about active spec
|
||||
|
|
@ -13261,6 +13457,9 @@ void GameHandler::handleGroupList(network::Packet& packet) {
|
|||
// WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder.
|
||||
// Classic 1.12 and TBC 2.4.3 do not send the roles byte.
|
||||
const bool hasRoles = isActiveExpansion("wotlk");
|
||||
// Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta.
|
||||
// Without this, repeated GROUP_LIST packets push duplicate members.
|
||||
partyData = GroupListData{};
|
||||
if (!GroupListParser::parse(packet, partyData, hasRoles)) return;
|
||||
|
||||
if (partyData.isEmpty()) {
|
||||
|
|
@ -14688,12 +14887,12 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
|
|||
|
||||
void GameHandler::useItemById(uint32_t itemId) {
|
||||
if (itemId == 0) return;
|
||||
LOG_INFO("useItemById: searching for itemId=", itemId, " in backpack (", inventory.getBackpackSize(), " slots)");
|
||||
LOG_DEBUG("useItemById: searching for itemId=", itemId);
|
||||
// Search backpack first
|
||||
for (int i = 0; i < inventory.getBackpackSize(); i++) {
|
||||
const auto& slot = inventory.getBackpackSlot(i);
|
||||
if (!slot.empty() && slot.item.itemId == itemId) {
|
||||
LOG_INFO("useItemById: found itemId=", itemId, " at backpack slot ", i);
|
||||
LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i);
|
||||
useItemBySlot(i);
|
||||
return;
|
||||
}
|
||||
|
|
@ -14704,7 +14903,7 @@ void GameHandler::useItemById(uint32_t itemId) {
|
|||
for (int slot = 0; slot < bagSize; slot++) {
|
||||
const auto& bagSlot = inventory.getBagSlot(bag, slot);
|
||||
if (!bagSlot.empty() && bagSlot.item.itemId == itemId) {
|
||||
LOG_INFO("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
|
||||
LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
|
||||
useItemInBag(bag, slot);
|
||||
return;
|
||||
}
|
||||
|
|
@ -15003,29 +15202,24 @@ void GameHandler::handleTrainerList(network::Packet& packet) {
|
|||
trainerWindowOpen_ = true;
|
||||
gossipWindowOpen = false;
|
||||
|
||||
// Debug: log known spells
|
||||
LOG_INFO("Known spells count: ", knownSpells.size());
|
||||
LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells");
|
||||
LOG_DEBUG("Known spells count: ", knownSpells.size());
|
||||
if (knownSpells.size() <= 50) {
|
||||
std::string spellList;
|
||||
for (uint32_t id : knownSpells) {
|
||||
if (!spellList.empty()) spellList += ", ";
|
||||
spellList += std::to_string(id);
|
||||
}
|
||||
LOG_INFO("Known spells: ", spellList);
|
||||
LOG_DEBUG("Known spells: ", spellList);
|
||||
}
|
||||
|
||||
// Check if specific prerequisite spells are known
|
||||
bool has527 = knownSpells.count(527u);
|
||||
bool has25312 = knownSpells.count(25312u);
|
||||
LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312);
|
||||
|
||||
// Debug: log first few trainer spells to see their state
|
||||
LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells");
|
||||
LOG_DEBUG("Prerequisite check: 527=", knownSpells.count(527u),
|
||||
" 25312=", knownSpells.count(25312u));
|
||||
for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) {
|
||||
const auto& s = currentTrainerList_.spells[i];
|
||||
LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
|
||||
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
|
||||
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
|
||||
LOG_DEBUG(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
|
||||
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
|
||||
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -15460,12 +15654,13 @@ void GameHandler::handleTeleportAck(network::Packet& packet) {
|
|||
|
||||
// Send the ack back to the server
|
||||
// Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time
|
||||
if (socket && !isClassicLikeExpansion()) {
|
||||
// Classic/TBC use full uint64 GUID; WotLK uses packed GUID.
|
||||
if (socket) {
|
||||
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK));
|
||||
const bool legacyGuidAck =
|
||||
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
|
||||
if (legacyGuidAck) {
|
||||
ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for teleport ACK
|
||||
ack.writeUInt64(playerGuid); // CMaNGOS/VMaNGOS expects full GUID for Classic/TBC
|
||||
} else {
|
||||
MovementPacket::writePackedGuid(ack, playerGuid);
|
||||
}
|
||||
|
|
@ -15478,7 +15673,7 @@ void GameHandler::handleTeleportAck(network::Packet& packet) {
|
|||
// Notify application of teleport — the callback decides whether to do
|
||||
// a full world reload (map change) or just update position (same map).
|
||||
if (worldEntryCallback_) {
|
||||
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ);
|
||||
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -15495,7 +15690,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
|
|||
float serverZ = packet.readFloat();
|
||||
float orientation = packet.readFloat();
|
||||
|
||||
LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId,
|
||||
LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId,
|
||||
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")",
|
||||
" orient=", orientation);
|
||||
|
||||
|
|
@ -15585,7 +15780,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
|
|||
|
||||
// Reload terrain at new position
|
||||
if (worldEntryCallback_) {
|
||||
worldEntryCallback_(mapId, serverX, serverY, serverZ);
|
||||
worldEntryCallback_(mapId, serverX, serverY, serverZ, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16398,16 +16593,23 @@ void GameHandler::handleWho(network::Packet& packet) {
|
|||
uint32_t raceId = packet.readUInt32();
|
||||
if (hasGender && packet.getSize() - packet.getReadPos() >= 1)
|
||||
packet.readUInt8(); // gender (WotLK only, unused)
|
||||
uint32_t zoneId = 0;
|
||||
if (packet.getSize() - packet.getReadPos() >= 4)
|
||||
packet.readUInt32(); // zoneId (unused)
|
||||
zoneId = packet.readUInt32();
|
||||
|
||||
std::string msg = " " + playerName;
|
||||
if (!guildName.empty())
|
||||
msg += " <" + guildName + ">";
|
||||
msg += " - Level " + std::to_string(level);
|
||||
if (zoneId != 0) {
|
||||
std::string zoneName = getAreaName(zoneId);
|
||||
if (!zoneName.empty())
|
||||
msg += " [" + zoneName + "]";
|
||||
}
|
||||
|
||||
addSystemChatMessage(msg);
|
||||
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId);
|
||||
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId,
|
||||
" Race:", raceId, " Zone:", zoneId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16425,6 +16627,11 @@ void GameHandler::handleFriendList(network::Packet& packet) {
|
|||
if (rem() < 1) return;
|
||||
uint8_t count = packet.readUInt8();
|
||||
LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries");
|
||||
|
||||
// Rebuild friend contacts (keep ignores from previous contact_ entries)
|
||||
contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(),
|
||||
[](const ContactEntry& e){ return e.isFriend(); }), contacts_.end());
|
||||
|
||||
for (uint8_t i = 0; i < count && rem() >= 9; ++i) {
|
||||
uint64_t guid = packet.readUInt64();
|
||||
uint8_t status = packet.readUInt8();
|
||||
|
|
@ -16434,18 +16641,28 @@ void GameHandler::handleFriendList(network::Packet& packet) {
|
|||
level = packet.readUInt32();
|
||||
classId = packet.readUInt32();
|
||||
}
|
||||
(void)area; (void)level; (void)classId;
|
||||
// Track as a friend GUID; resolve name via name query
|
||||
friendGuids_.insert(guid);
|
||||
auto nit = playerNameCache.find(guid);
|
||||
std::string name;
|
||||
if (nit != playerNameCache.end()) {
|
||||
friendsCache[nit->second] = guid;
|
||||
LOG_INFO(" Friend: ", nit->second, " status=", (int)status);
|
||||
name = nit->second;
|
||||
friendsCache[name] = guid;
|
||||
LOG_INFO(" Friend: ", name, " status=", (int)status);
|
||||
} else {
|
||||
LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec,
|
||||
" status=", (int)status, " (name pending)");
|
||||
queryPlayerName(guid);
|
||||
}
|
||||
ContactEntry entry;
|
||||
entry.guid = guid;
|
||||
entry.name = name;
|
||||
entry.flags = 0x1; // friend
|
||||
entry.status = status;
|
||||
entry.areaId = area;
|
||||
entry.level = level;
|
||||
entry.classId = classId;
|
||||
contacts_.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -16469,19 +16686,23 @@ void GameHandler::handleContactList(network::Packet& packet) {
|
|||
}
|
||||
lastContactListMask_ = packet.readUInt32();
|
||||
lastContactListCount_ = packet.readUInt32();
|
||||
contacts_.clear();
|
||||
for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) {
|
||||
uint64_t guid = packet.readUInt64();
|
||||
if (rem() < 4) break;
|
||||
uint32_t flags = packet.readUInt32();
|
||||
std::string note = packet.readString(); // may be empty
|
||||
(void)note;
|
||||
uint8_t status = 0;
|
||||
uint32_t areaId = 0;
|
||||
uint32_t level = 0;
|
||||
uint32_t classId = 0;
|
||||
if (flags & 0x1) { // SOCIAL_FLAG_FRIEND
|
||||
if (rem() < 1) break;
|
||||
uint8_t status = packet.readUInt8();
|
||||
status = packet.readUInt8();
|
||||
if (status != 0 && rem() >= 12) {
|
||||
packet.readUInt32(); // area
|
||||
packet.readUInt32(); // level
|
||||
packet.readUInt32(); // class
|
||||
areaId = packet.readUInt32();
|
||||
level = packet.readUInt32();
|
||||
classId = packet.readUInt32();
|
||||
}
|
||||
friendGuids_.insert(guid);
|
||||
auto nit = playerNameCache.find(guid);
|
||||
|
|
@ -16492,6 +16713,17 @@ void GameHandler::handleContactList(network::Packet& packet) {
|
|||
}
|
||||
}
|
||||
// ignore / mute entries: no additional fields beyond guid+flags+note
|
||||
ContactEntry entry;
|
||||
entry.guid = guid;
|
||||
entry.flags = flags;
|
||||
entry.note = std::move(note);
|
||||
entry.status = status;
|
||||
entry.areaId = areaId;
|
||||
entry.level = level;
|
||||
entry.classId = classId;
|
||||
auto nit = playerNameCache.find(guid);
|
||||
if (nit != playerNameCache.end()) entry.name = nit->second;
|
||||
contacts_.push_back(std::move(entry));
|
||||
}
|
||||
LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_,
|
||||
" count=", lastContactListCount_);
|
||||
|
|
@ -16520,6 +16752,28 @@ void GameHandler::handleFriendStatus(network::Packet& packet) {
|
|||
friendsCache.erase(playerName);
|
||||
}
|
||||
|
||||
// Mirror into contacts_: update existing entry or add/remove as needed
|
||||
if (data.status == 0) { // Removed from friends list
|
||||
contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(),
|
||||
[&](const ContactEntry& e){ return e.guid == data.guid; }), contacts_.end());
|
||||
} else {
|
||||
auto cit = std::find_if(contacts_.begin(), contacts_.end(),
|
||||
[&](const ContactEntry& e){ return e.guid == data.guid; });
|
||||
if (cit != contacts_.end()) {
|
||||
if (!playerName.empty() && playerName != "Unknown") cit->name = playerName;
|
||||
// status: 2=online→1, 3=offline→0, 1=added→1 (online on add)
|
||||
if (data.status == 2) cit->status = 1;
|
||||
else if (data.status == 3) cit->status = 0;
|
||||
} else {
|
||||
ContactEntry entry;
|
||||
entry.guid = data.guid;
|
||||
entry.name = playerName;
|
||||
entry.flags = 0x1; // friend
|
||||
entry.status = (data.status == 2) ? 1 : 0;
|
||||
contacts_.push_back(std::move(entry));
|
||||
}
|
||||
}
|
||||
|
||||
// Status messages
|
||||
switch (data.status) {
|
||||
case 0:
|
||||
|
|
@ -17361,7 +17615,7 @@ void GameHandler::handleShowBank(network::Packet& packet) {
|
|||
for (int i = 0; i < effectiveBankBagSlots_; i++) {
|
||||
if (inventory.getBankBagSize(i) > 0) filledBags++;
|
||||
}
|
||||
LOG_WARNING("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec,
|
||||
LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec,
|
||||
" purchased=", static_cast<int>(inventory.getPurchasedBankBagSlots()),
|
||||
" filledBags=", filledBags,
|
||||
" effectiveBankBagSlots=", effectiveBankBagSlots_);
|
||||
|
|
@ -17370,7 +17624,7 @@ void GameHandler::handleShowBank(network::Packet& packet) {
|
|||
void GameHandler::handleBuyBankSlotResult(network::Packet& packet) {
|
||||
if (packet.getSize() - packet.getReadPos() < 4) return;
|
||||
uint32_t result = packet.readUInt32();
|
||||
LOG_WARNING("SMSG_BUY_BANK_SLOT_RESULT: result=", result);
|
||||
LOG_INFO("SMSG_BUY_BANK_SLOT_RESULT: result=", result);
|
||||
// AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK
|
||||
if (result == 3) {
|
||||
addSystemChatMessage("Bank slot purchased.");
|
||||
|
|
@ -18124,6 +18378,45 @@ const std::string& GameHandler::getFactionNamePublic(uint32_t factionId) const {
|
|||
return empty;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Area name cache (lazy-loaded from WorldMapArea.dbc)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void GameHandler::loadAreaNameCache() {
|
||||
if (areaNameCacheLoaded_) return;
|
||||
areaNameCacheLoaded_ = true;
|
||||
|
||||
auto* am = core::Application::getInstance().getAssetManager();
|
||||
if (!am || !am->isInitialized()) return;
|
||||
|
||||
auto dbc = am->loadDBC("WorldMapArea.dbc");
|
||||
if (!dbc || !dbc->isLoaded()) return;
|
||||
|
||||
const auto* layout = pipeline::getActiveDBCLayout()
|
||||
? pipeline::getActiveDBCLayout()->getLayout("WorldMapArea") : nullptr;
|
||||
const uint32_t areaIdField = layout ? (*layout)["AreaID"] : 2;
|
||||
const uint32_t areaNameField = layout ? (*layout)["AreaName"] : 3;
|
||||
|
||||
if (dbc->getFieldCount() <= areaNameField) return;
|
||||
|
||||
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
|
||||
uint32_t areaId = dbc->getUInt32(i, areaIdField);
|
||||
if (areaId == 0) continue;
|
||||
std::string name = dbc->getString(i, areaNameField);
|
||||
if (!name.empty() && !areaNameCache_.count(areaId)) {
|
||||
areaNameCache_[areaId] = std::move(name);
|
||||
}
|
||||
}
|
||||
LOG_INFO("WorldMapArea.dbc: loaded ", areaNameCache_.size(), " area names");
|
||||
}
|
||||
|
||||
std::string GameHandler::getAreaName(uint32_t areaId) const {
|
||||
if (areaId == 0) return {};
|
||||
const_cast<GameHandler*>(this)->loadAreaNameCache();
|
||||
auto it = areaNameCache_.find(areaId);
|
||||
return (it != areaNameCache_.end()) ? it->second : std::string{};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Aura duration update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
|
|||
/*float turnRate =*/ packet.readFloat();
|
||||
|
||||
block.runSpeed = runSpeed;
|
||||
block.moveFlags = moveFlags;
|
||||
|
||||
// Spline data (Classic: SPLINE_ENABLED=0x00400000)
|
||||
if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) {
|
||||
|
|
@ -447,9 +448,9 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att
|
|||
data.blocked = packet.readUInt32();
|
||||
}
|
||||
|
||||
LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
data.isMiss() ? " (MISS)" : "");
|
||||
LOG_DEBUG("[Classic] Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
data.isMiss() ? " (MISS)" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -484,8 +485,8 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam
|
|||
data.isCrit = (flags & 0x02) != 0;
|
||||
data.overkill = 0; // no overkill field in Vanilla (same as TBC)
|
||||
|
||||
LOG_INFO("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
LOG_DEBUG("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -510,8 +511,8 @@ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealL
|
|||
data.overheal = packet.readUInt32();
|
||||
data.isCrit = (packet.readUInt8() != 0);
|
||||
|
||||
LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
LOG_DEBUG("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -700,13 +701,9 @@ bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumRespon
|
|||
character.equipment.push_back(item);
|
||||
}
|
||||
|
||||
LOG_INFO(" Character ", (int)(i + 1), ": ", character.name);
|
||||
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
|
||||
LOG_INFO(" ", getRaceName(character.race), " ",
|
||||
getClassName(character.characterClass), " (",
|
||||
getGenderName(character.gender), ")");
|
||||
LOG_INFO(" Level: ", (int)character.level);
|
||||
LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId);
|
||||
LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name,
|
||||
" (", getRaceName(character.race), " ", getClassName(character.characterClass),
|
||||
" level ", (int)character.level, " zone ", character.zoneId, ")");
|
||||
|
||||
response.characters.push_back(character);
|
||||
}
|
||||
|
|
@ -1016,7 +1013,7 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
|
|||
data.quests.push_back(quest);
|
||||
}
|
||||
|
||||
LOG_INFO("Classic Gossip: ", optionCount, " options, ", questCount, " quests");
|
||||
LOG_DEBUG("Classic Gossip: ", optionCount, " options, ", questCount, " quests");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1507,7 +1504,7 @@ bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveD
|
|||
|
||||
packet.setReadPos(start);
|
||||
if (MonsterMoveParser::parse(packet, data)) {
|
||||
LOG_WARNING("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout");
|
||||
LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1561,66 +1558,6 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u
|
|||
return packet;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Classic SMSG_QUESTGIVER_QUEST_DETAILS — Vanilla 1.12 format
|
||||
// WotLK inserts an informUnit GUID (8 bytes) between npcGuid and questId.
|
||||
// Vanilla has: npcGuid(8) + questId(4) + title + details + objectives + ...
|
||||
// ============================================================================
|
||||
bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) {
|
||||
if (packet.getSize() < 16) return false;
|
||||
|
||||
data.npcGuid = packet.readUInt64();
|
||||
// Vanilla: questId follows immediately — no informUnit GUID
|
||||
data.questId = packet.readUInt32();
|
||||
data.title = normalizeWowTextTokens(packet.readString());
|
||||
data.details = normalizeWowTextTokens(packet.readString());
|
||||
data.objectives = normalizeWowTextTokens(packet.readString());
|
||||
|
||||
if (packet.getReadPos() + 5 > packet.getSize()) {
|
||||
LOG_INFO("Quest details classic (short): id=", data.questId, " title='", data.title, "'");
|
||||
return !data.title.empty() || data.questId != 0;
|
||||
}
|
||||
|
||||
/*activateAccept*/ packet.readUInt8();
|
||||
data.suggestedPlayers = packet.readUInt32();
|
||||
|
||||
// Vanilla 1.12: emote section before reward items
|
||||
// Format: emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount
|
||||
if (packet.getReadPos() + 4 <= packet.getSize()) {
|
||||
uint32_t emoteCount = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
|
||||
packet.readUInt32(); // delay
|
||||
packet.readUInt32(); // type
|
||||
}
|
||||
}
|
||||
|
||||
// Choice reward items: variable count + 3 uint32s each
|
||||
if (packet.getReadPos() + 4 <= packet.getSize()) {
|
||||
uint32_t choiceCount = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
|
||||
packet.readUInt32(); // itemId
|
||||
packet.readUInt32(); // count
|
||||
packet.readUInt32(); // displayInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed reward items: variable count + 3 uint32s each
|
||||
if (packet.getReadPos() + 4 <= packet.getSize()) {
|
||||
uint32_t rewardCount = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
|
||||
packet.readUInt32(); // itemId
|
||||
packet.readUInt32(); // count
|
||||
packet.readUInt32(); // displayInfo
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.getReadPos() + 4 <= packet.getSize())
|
||||
data.rewardMoney = packet.readUInt32();
|
||||
|
||||
LOG_INFO("Quest details classic: id=", data.questId, " title='", data.title, "'");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ClassicPacketParsers::parseCreatureQueryResponse
|
||||
//
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
|
|||
/*float turnRate =*/ packet.readFloat();
|
||||
|
||||
block.runSpeed = runSpeed;
|
||||
block.moveFlags = moveFlags;
|
||||
|
||||
// Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000)
|
||||
if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) {
|
||||
|
|
@ -355,13 +356,9 @@ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse&
|
|||
character.equipment.push_back(item);
|
||||
}
|
||||
|
||||
LOG_INFO(" Character ", (int)(i + 1), ": ", character.name);
|
||||
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
|
||||
LOG_INFO(" ", getRaceName(character.race), " ",
|
||||
getClassName(character.characterClass), " (",
|
||||
getGenderName(character.gender), ")");
|
||||
LOG_INFO(" Level: ", (int)character.level);
|
||||
LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId);
|
||||
LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name,
|
||||
" (", getRaceName(character.race), " ", getClassName(character.characterClass),
|
||||
" level ", (int)character.level, " zone ", character.zoneId, ")");
|
||||
|
||||
response.characters.push_back(character);
|
||||
}
|
||||
|
|
@ -488,7 +485,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa
|
|||
|
||||
packet.setReadPos(startPos);
|
||||
if (parseWithLayout(false, parsed)) {
|
||||
LOG_WARNING("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback");
|
||||
LOG_DEBUG("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback");
|
||||
data = std::move(parsed);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -540,7 +537,7 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage
|
|||
data.quests.push_back(quest);
|
||||
}
|
||||
|
||||
LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests");
|
||||
LOG_DEBUG("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -698,6 +695,75 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3
|
|||
return packet;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TBC 2.4.3 SMSG_QUESTGIVER_QUEST_DETAILS
|
||||
//
|
||||
// TBC and Classic share the same format — neither has the WotLK-specific fields
|
||||
// (informUnit GUID, flags uint32, isFinished uint8) that were added in 3.x.
|
||||
//
|
||||
// Format:
|
||||
// npcGuid(8) + questId(4) + title + details + objectives
|
||||
// + activateAccept(1) + suggestedPlayers(4)
|
||||
// + emoteCount(4) + [delay(4)+type(4)] × emoteCount
|
||||
// + choiceCount(4) + [itemId(4)+count(4)+displayInfo(4)] × choiceCount
|
||||
// + rewardCount(4) + [itemId(4)+count(4)+displayInfo(4)] × rewardCount
|
||||
// + rewardMoney(4) + rewardXp(4)
|
||||
// ============================================================================
|
||||
bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) {
|
||||
if (packet.getSize() < 16) return false;
|
||||
|
||||
data.npcGuid = packet.readUInt64();
|
||||
data.questId = packet.readUInt32();
|
||||
data.title = normalizeWowTextTokens(packet.readString());
|
||||
data.details = normalizeWowTextTokens(packet.readString());
|
||||
data.objectives = normalizeWowTextTokens(packet.readString());
|
||||
|
||||
if (packet.getReadPos() + 5 > packet.getSize()) {
|
||||
LOG_DEBUG("Quest details tbc/classic (short): id=", data.questId, " title='", data.title, "'");
|
||||
return !data.title.empty() || data.questId != 0;
|
||||
}
|
||||
|
||||
/*activateAccept*/ packet.readUInt8();
|
||||
data.suggestedPlayers = packet.readUInt32();
|
||||
|
||||
// TBC/Classic: emote section before reward items
|
||||
if (packet.getReadPos() + 4 <= packet.getSize()) {
|
||||
uint32_t emoteCount = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
|
||||
packet.readUInt32(); // delay
|
||||
packet.readUInt32(); // type
|
||||
}
|
||||
}
|
||||
|
||||
// Choice reward items (variable count, up to QUEST_REWARD_CHOICES_COUNT)
|
||||
if (packet.getReadPos() + 4 <= packet.getSize()) {
|
||||
uint32_t choiceCount = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
|
||||
packet.readUInt32(); // itemId
|
||||
packet.readUInt32(); // count
|
||||
packet.readUInt32(); // displayInfo
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed reward items (variable count, up to QUEST_REWARDS_COUNT)
|
||||
if (packet.getReadPos() + 4 <= packet.getSize()) {
|
||||
uint32_t rewardCount = packet.readUInt32();
|
||||
for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
|
||||
packet.readUInt32(); // itemId
|
||||
packet.readUInt32(); // count
|
||||
packet.readUInt32(); // displayInfo
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.getReadPos() + 4 <= packet.getSize())
|
||||
data.rewardMoney = packet.readUInt32();
|
||||
if (packet.getReadPos() + 4 <= packet.getSize())
|
||||
data.rewardXp = packet.readUInt32();
|
||||
|
||||
LOG_DEBUG("Quest details tbc/classic: id=", data.questId, " title='", data.title, "'");
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST
|
||||
//
|
||||
|
|
@ -718,7 +784,7 @@ network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32
|
|||
// SMSG_SET_EXTRA_AURA_INFO_OBSOLETE (0x3A4) instead
|
||||
// ============================================================================
|
||||
bool TbcPacketParsers::parseAuraUpdate(network::Packet& /*packet*/, AuraUpdateData& /*data*/, bool /*isAll*/) {
|
||||
LOG_WARNING("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3");
|
||||
LOG_DEBUG("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1131,9 +1197,9 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke
|
|||
data.blocked = packet.readUInt32();
|
||||
}
|
||||
|
||||
LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
data.isMiss() ? " (MISS)" : "");
|
||||
LOG_DEBUG("[TBC] Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
data.isMiss() ? " (MISS)" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1163,8 +1229,8 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL
|
|||
// TBC does not have an overkill field here
|
||||
data.overkill = 0;
|
||||
|
||||
LOG_INFO("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
LOG_DEBUG("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -1187,8 +1253,8 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa
|
|||
data.isCrit = (critFlag != 0);
|
||||
}
|
||||
|
||||
LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
LOG_DEBUG("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ namespace game {
|
|||
#ifdef HAVE_UNICORN
|
||||
|
||||
// Memory layout for emulated environment
|
||||
// Note: heap must not overlap the module region (typically loaded at 0x400000)
|
||||
// or the stack. Keep heap above 0x02000000 (32MB) to leave space for module + padding.
|
||||
constexpr uint32_t STACK_BASE = 0x00100000; // 1MB
|
||||
constexpr uint32_t STACK_SIZE = 0x00100000; // 1MB stack
|
||||
constexpr uint32_t HEAP_BASE = 0x00200000; // 2MB
|
||||
constexpr uint32_t HEAP_BASE = 0x02000000; // 32MB — well above typical module base (0x400000)
|
||||
constexpr uint32_t HEAP_SIZE = 0x01000000; // 16MB heap
|
||||
constexpr uint32_t API_STUB_BASE = 0x70000000; // API stub area (high memory)
|
||||
|
||||
|
|
@ -58,6 +60,17 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
|
|||
moduleBase_ = baseAddress;
|
||||
moduleSize_ = (moduleSize + 0xFFF) & ~0xFFF; // Align to 4KB
|
||||
|
||||
// Detect overlap between module and heap/stack regions early.
|
||||
uint32_t modEnd = moduleBase_ + moduleSize_;
|
||||
if (modEnd > heapBase_ && moduleBase_ < heapBase_ + heapSize_) {
|
||||
std::cerr << "[WardenEmulator] Module [0x" << std::hex << moduleBase_
|
||||
<< ", 0x" << modEnd << ") overlaps heap [0x" << heapBase_
|
||||
<< ", 0x" << (heapBase_ + heapSize_) << ") — adjust HEAP_BASE\n" << std::dec;
|
||||
uc_close(uc_);
|
||||
uc_ = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Map module memory (code + data)
|
||||
err = uc_mem_map(uc_, moduleBase_, moduleSize_, UC_PROT_ALL);
|
||||
if (err != UC_ERR_OK) {
|
||||
|
|
@ -108,6 +121,15 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
|
|||
return false;
|
||||
}
|
||||
|
||||
// Map a null guard page at address 0 (read-only, zeroed) so that NULL-pointer
|
||||
// dereferences in the module don't crash the emulator with UC_ERR_MAP.
|
||||
// This allows execution to continue past NULL reads, making diagnostics easier.
|
||||
err = uc_mem_map(uc_, 0x0, 0x1000, UC_PROT_READ);
|
||||
if (err != UC_ERR_OK) {
|
||||
// Non-fatal — just log it; the emulator will still function
|
||||
std::cerr << "[WardenEmulator] Note: could not map null guard page: " << uc_strerror(err) << '\n';
|
||||
}
|
||||
|
||||
// Add hooks for debugging and invalid memory access
|
||||
uc_hook hh;
|
||||
uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0);
|
||||
|
|
|
|||
|
|
@ -228,10 +228,10 @@ std::vector<uint8_t> AuthSessionPacket::computeAuthHash(
|
|||
}
|
||||
return s;
|
||||
};
|
||||
LOG_INFO("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed,
|
||||
" serverSeed=0x", serverSeed, std::dec);
|
||||
LOG_INFO("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size()));
|
||||
LOG_INFO("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size()));
|
||||
LOG_DEBUG("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed,
|
||||
" serverSeed=0x", serverSeed, std::dec);
|
||||
LOG_DEBUG("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size()));
|
||||
LOG_DEBUG("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size()));
|
||||
}
|
||||
|
||||
// Compute SHA1 hash
|
||||
|
|
@ -245,7 +245,7 @@ std::vector<uint8_t> AuthSessionPacket::computeAuthHash(
|
|||
}
|
||||
return s;
|
||||
};
|
||||
LOG_INFO("AUTH HASH: digest=", toHex(result.data(), result.size()));
|
||||
LOG_DEBUG("AUTH HASH: digest=", toHex(result.data(), result.size()));
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
@ -265,22 +265,22 @@ bool AuthChallengeParser::parse(network::Packet& packet, AuthChallengeData& data
|
|||
// Original vanilla/TBC format: just the server seed (4 bytes)
|
||||
data.unknown1 = 0;
|
||||
data.serverSeed = packet.readUInt32();
|
||||
LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (TBC format, 4 bytes):");
|
||||
LOG_INFO("SMSG_AUTH_CHALLENGE: TBC format (", packet.getSize(), " bytes)");
|
||||
} else if (packet.getSize() < 40) {
|
||||
// Vanilla with encryption seeds (36 bytes): serverSeed + 32 bytes seeds
|
||||
// No "unknown1" prefix — first uint32 IS the server seed
|
||||
data.unknown1 = 0;
|
||||
data.serverSeed = packet.readUInt32();
|
||||
LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (Classic+seeds format, ", packet.getSize(), " bytes):");
|
||||
LOG_INFO("SMSG_AUTH_CHALLENGE: Classic+seeds format (", packet.getSize(), " bytes)");
|
||||
} else {
|
||||
// WotLK format (40+ bytes): unknown1 + serverSeed + 32 bytes encryption seeds
|
||||
data.unknown1 = packet.readUInt32();
|
||||
data.serverSeed = packet.readUInt32();
|
||||
LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (WotLK format, ", packet.getSize(), " bytes):");
|
||||
LOG_INFO(" Unknown1: 0x", std::hex, data.unknown1, std::dec);
|
||||
LOG_INFO("SMSG_AUTH_CHALLENGE: WotLK format (", packet.getSize(), " bytes)");
|
||||
LOG_DEBUG(" Unknown1: 0x", std::hex, data.unknown1, std::dec);
|
||||
}
|
||||
|
||||
LOG_INFO(" Server seed: 0x", std::hex, data.serverSeed, std::dec);
|
||||
LOG_DEBUG(" Server seed: 0x", std::hex, data.serverSeed, std::dec);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -480,21 +480,9 @@ bool CharEnumParser::parse(network::Packet& packet, CharEnumResponse& response)
|
|||
character.equipment.push_back(item);
|
||||
}
|
||||
|
||||
LOG_INFO(" Character ", (int)(i + 1), ": ", character.name);
|
||||
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
|
||||
LOG_INFO(" ", getRaceName(character.race), " ",
|
||||
getClassName(character.characterClass), " (",
|
||||
getGenderName(character.gender), ")");
|
||||
LOG_INFO(" Level: ", (int)character.level);
|
||||
LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId);
|
||||
LOG_INFO(" Position: (", character.x, ", ", character.y, ", ", character.z, ")");
|
||||
if (character.hasGuild()) {
|
||||
LOG_INFO(" Guild ID: ", character.guildId);
|
||||
}
|
||||
if (character.hasPet()) {
|
||||
LOG_INFO(" Pet: Model ", character.pet.displayModel,
|
||||
", Level ", character.pet.level);
|
||||
}
|
||||
LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name,
|
||||
" (", getRaceName(character.race), " ", getClassName(character.characterClass),
|
||||
" level ", (int)character.level, " zone ", character.zoneId, ")");
|
||||
|
||||
response.characters.push_back(character);
|
||||
}
|
||||
|
|
@ -598,8 +586,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) {
|
|||
|
||||
uint32_t lineCount = packet.readUInt32();
|
||||
|
||||
LOG_INFO("Parsed SMSG_MOTD:");
|
||||
LOG_INFO(" Line count: ", lineCount);
|
||||
LOG_INFO("Parsed SMSG_MOTD: ", lineCount, " line(s)");
|
||||
|
||||
data.lines.clear();
|
||||
data.lines.reserve(lineCount);
|
||||
|
|
@ -607,7 +594,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) {
|
|||
for (uint32_t i = 0; i < lineCount; ++i) {
|
||||
std::string line = packet.readString();
|
||||
data.lines.push_back(line);
|
||||
LOG_INFO(" [", i + 1, "] ", line);
|
||||
LOG_DEBUG(" MOTD[", i + 1, "]: ", line);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -878,7 +865,19 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
|
|||
}
|
||||
|
||||
// Swimming/flying pitch
|
||||
if ((moveFlags & 0x02000000) || (moveFlags2 & 0x0010)) { // MOVEMENTFLAG_SWIMMING or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING
|
||||
// WotLK 3.3.5a movement flags relevant here:
|
||||
// SWIMMING = 0x00200000
|
||||
// FLYING = 0x01000000 (player/creature actively flying)
|
||||
// SPLINE_ELEVATION = 0x02000000 (smooth vertical spline offset — no pitch field)
|
||||
// MovementFlags2:
|
||||
// MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING = 0x0010
|
||||
//
|
||||
// Pitch is present when SWIMMING or FLYING are set, or the always-allow flag is set.
|
||||
// The original code checked 0x02000000 (SPLINE_ELEVATION) which neither covers SWIMMING
|
||||
// nor FLYING, causing misaligned reads for swimming/flying entities in SMSG_UPDATE_OBJECT.
|
||||
if ((moveFlags & 0x00200000) /* SWIMMING */ ||
|
||||
(moveFlags & 0x01000000) /* FLYING */ ||
|
||||
(moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) {
|
||||
/*float pitch =*/ packet.readFloat();
|
||||
}
|
||||
|
||||
|
|
@ -910,6 +909,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
|
|||
/*float pitchRate =*/ packet.readFloat();
|
||||
|
||||
block.runSpeed = runSpeed;
|
||||
block.moveFlags = moveFlags;
|
||||
|
||||
// Spline data
|
||||
if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED
|
||||
|
|
@ -1033,9 +1033,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
|
|||
block.hasMovement = true;
|
||||
|
||||
if (block.onTransport) {
|
||||
LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec,
|
||||
" pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation,
|
||||
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
|
||||
LOG_DEBUG(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec,
|
||||
" pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation,
|
||||
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
|
||||
}
|
||||
}
|
||||
else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) {
|
||||
|
|
@ -1265,11 +1265,14 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data)
|
|||
if (!parseUpdateBlock(packet, block)) {
|
||||
static int parseBlockErrors = 0;
|
||||
if (++parseBlockErrors <= 5) {
|
||||
LOG_ERROR("Failed to parse update block ", i + 1);
|
||||
LOG_ERROR("Failed to parse update block ", i + 1, " of ", data.blockCount,
|
||||
" (", i, " blocks parsed successfully before failure)");
|
||||
if (parseBlockErrors == 5)
|
||||
LOG_ERROR("(suppressing further update block parse errors)");
|
||||
}
|
||||
return false;
|
||||
// Cannot reliably re-sync to the next block after a parse failure,
|
||||
// but still return true so the blocks already parsed are processed.
|
||||
break;
|
||||
}
|
||||
|
||||
data.blocks.emplace_back(std::move(block));
|
||||
|
|
@ -2241,7 +2244,7 @@ bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseDa
|
|||
data.gender = packet.readUInt8();
|
||||
data.classId = packet.readUInt8();
|
||||
|
||||
LOG_INFO("Name query response: ", data.name, " (race=", (int)data.race,
|
||||
LOG_DEBUG("Name query response: ", data.name, " (race=", (int)data.race,
|
||||
" class=", (int)data.classId, ")");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -2731,7 +2734,7 @@ bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) {
|
|||
if (packet.getSize() < 16) return false;
|
||||
data.attackerGuid = packet.readUInt64();
|
||||
data.victimGuid = packet.readUInt64();
|
||||
LOG_INFO("Attack started: 0x", std::hex, data.attackerGuid,
|
||||
LOG_DEBUG("Attack started: 0x", std::hex, data.attackerGuid,
|
||||
" -> 0x", data.victimGuid, std::dec);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -2742,7 +2745,7 @@ bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) {
|
|||
if (packet.getReadPos() < packet.getSize()) {
|
||||
data.unknown = packet.readUInt32();
|
||||
}
|
||||
LOG_INFO("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec);
|
||||
LOG_DEBUG("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2771,9 +2774,9 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
|
|||
data.blocked = packet.readUInt32();
|
||||
}
|
||||
|
||||
LOG_INFO("Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
data.isMiss() ? " (MISS)" : "");
|
||||
LOG_DEBUG("Melee hit: ", data.totalDamage, " damage",
|
||||
data.isCrit() ? " (CRIT)" : "",
|
||||
data.isMiss() ? " (MISS)" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2797,8 +2800,8 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da
|
|||
// Check crit flag
|
||||
data.isCrit = (flags & 0x02) != 0;
|
||||
|
||||
LOG_INFO("Spell damage: spellId=", data.spellId, " dmg=", data.damage,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
LOG_DEBUG("Spell damage: spellId=", data.spellId, " dmg=", data.damage,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2812,8 +2815,8 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data)
|
|||
uint8_t critFlag = packet.readUInt8();
|
||||
data.isCrit = (critFlag != 0);
|
||||
|
||||
LOG_INFO("Spell heal: spellId=", data.spellId, " heal=", data.heal,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
LOG_DEBUG("Spell heal: spellId=", data.spellId, " heal=", data.heal,
|
||||
data.isCrit ? " CRIT" : "");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -2834,7 +2837,7 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) {
|
|||
data.groupBonus = data.totalXp - static_cast<uint32_t>(data.totalXp / groupRate);
|
||||
}
|
||||
}
|
||||
LOG_INFO("XP gain: ", data.totalXp, " xp (type=", static_cast<int>(data.type), ")");
|
||||
LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast<int>(data.type), ")");
|
||||
return data.totalXp > 0;
|
||||
}
|
||||
|
||||
|
|
@ -2852,8 +2855,8 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data
|
|||
size_t remainingAfterHeader = packetSize - 3; // subtract talentSpec(1) + spellCount(2)
|
||||
bool vanillaFormat = remainingAfterHeader < static_cast<size_t>(spellCount) * 6 + 2;
|
||||
|
||||
LOG_INFO("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount,
|
||||
vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)");
|
||||
LOG_DEBUG("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount,
|
||||
vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)");
|
||||
|
||||
data.spellIds.reserve(spellCount);
|
||||
for (uint16_t i = 0; i < spellCount; ++i) {
|
||||
|
|
@ -2889,14 +2892,13 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data
|
|||
LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ",
|
||||
data.cooldowns.size(), " cooldowns");
|
||||
|
||||
// Log first 10 spell IDs for debugging
|
||||
if (!data.spellIds.empty()) {
|
||||
std::string first10;
|
||||
for (size_t i = 0; i < std::min(size_t(10), data.spellIds.size()); ++i) {
|
||||
if (!first10.empty()) first10 += ", ";
|
||||
first10 += std::to_string(data.spellIds[i]);
|
||||
}
|
||||
LOG_INFO("First spells: ", first10);
|
||||
LOG_DEBUG("Initial spell IDs (first 10): ", first10);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -3187,7 +3189,7 @@ bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResult
|
|||
data.command = static_cast<PartyCommand>(packet.readUInt32());
|
||||
data.name = packet.readString();
|
||||
data.result = static_cast<PartyResult>(packet.readUInt32());
|
||||
LOG_INFO("Party command result: ", (int)data.result);
|
||||
LOG_DEBUG("Party command result: ", (int)data.result);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3334,7 +3336,7 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data)
|
|||
}
|
||||
}
|
||||
|
||||
LOG_INFO("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount,
|
||||
LOG_DEBUG("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount,
|
||||
" quest items, ", data.gold, " copper");
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3403,7 +3405,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
|
|||
data.objectives = normalizeWowTextTokens(packet.readString());
|
||||
|
||||
if (packet.getReadPos() + 10 > packet.getSize()) {
|
||||
LOG_INFO("Quest details (short): id=", data.questId, " title='", data.title, "'");
|
||||
LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3440,7 +3442,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
|
|||
if (packet.getReadPos() + 4 <= packet.getSize())
|
||||
data.rewardXp = packet.readUInt32();
|
||||
|
||||
LOG_INFO("Quest details: id=", data.questId, " title='", data.title, "'");
|
||||
LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3477,7 +3479,7 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data
|
|||
data.quests.push_back(quest);
|
||||
}
|
||||
|
||||
LOG_INFO("Gossip: ", optionCount, " options, ", questCount, " quests");
|
||||
LOG_DEBUG("Gossip: ", optionCount, " options, ", questCount, " quests");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3509,7 +3511,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa
|
|||
data.completionText = normalizeWowTextTokens(packet.readString());
|
||||
|
||||
if (packet.getReadPos() + 9 > packet.getSize()) {
|
||||
LOG_INFO("Quest request items (short): id=", data.questId, " title='", data.title, "'");
|
||||
LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3585,7 +3587,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa
|
|||
data.completableFlags = chosen->completableFlags;
|
||||
data.requiredItems = chosen->requiredItems;
|
||||
|
||||
LOG_INFO("Quest request items: id=", data.questId, " title='", data.title,
|
||||
LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title,
|
||||
"' items=", data.requiredItems.size(), " completable=", data.isCompletable());
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3598,7 +3600,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
|
|||
data.rewardText = normalizeWowTextTokens(packet.readString());
|
||||
|
||||
if (packet.getReadPos() + 10 > packet.getSize()) {
|
||||
LOG_INFO("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");
|
||||
LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -3712,7 +3714,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
|
|||
data.rewardXp = best->rewardXp;
|
||||
}
|
||||
|
||||
LOG_INFO("Quest offer reward: id=", data.questId, " title='", data.title,
|
||||
LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title,
|
||||
"' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size());
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3825,7 +3827,7 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data
|
|||
data.items.push_back(item);
|
||||
}
|
||||
|
||||
LOG_INFO("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")");
|
||||
LOG_DEBUG("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -137,8 +137,31 @@ std::string AssetManager::resolveFile(const std::string& normalizedPath) const {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fall back to base manifest
|
||||
return manifest_.resolveFilesystemPath(normalizedPath);
|
||||
// Primary manifest
|
||||
std::string primaryPath = manifest_.resolveFilesystemPath(normalizedPath);
|
||||
if (!primaryPath.empty()) return primaryPath;
|
||||
|
||||
// If a base-path fallback is configured (expansion-specific primary that only
|
||||
// holds DBC overrides), retry against the base extraction.
|
||||
if (!baseFallbackDataPath_.empty()) {
|
||||
return baseFallbackManifest_.resolveFilesystemPath(normalizedPath);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
void AssetManager::setBaseFallbackPath(const std::string& basePath) {
|
||||
if (basePath.empty() || basePath == dataPath) return; // nothing to do
|
||||
std::string manifestPath = basePath + "/manifest.json";
|
||||
if (!std::filesystem::exists(manifestPath)) {
|
||||
LOG_DEBUG("AssetManager: base fallback manifest not found at ", manifestPath,
|
||||
" — fallback disabled");
|
||||
return;
|
||||
}
|
||||
if (baseFallbackManifest_.load(manifestPath)) {
|
||||
baseFallbackDataPath_ = basePath;
|
||||
LOG_INFO("AssetManager: base fallback path set to '", basePath,
|
||||
"' (", baseFallbackManifest_.getEntryCount(), " files)");
|
||||
}
|
||||
}
|
||||
|
||||
BLPImage AssetManager::loadTexture(const std::string& path) {
|
||||
|
|
@ -296,6 +319,55 @@ std::shared_ptr<DBCFile> AssetManager::loadDBC(const std::string& name) {
|
|||
return dbc;
|
||||
}
|
||||
|
||||
std::shared_ptr<DBCFile> AssetManager::loadDBCOptional(const std::string& name) {
|
||||
// Check cache first
|
||||
auto it = dbcCache.find(name);
|
||||
if (it != dbcCache.end()) return it->second;
|
||||
|
||||
// Try binary DBC
|
||||
std::vector<uint8_t> dbcData;
|
||||
{
|
||||
std::string dbcPath = "DBFilesClient\\" + name;
|
||||
dbcData = readFile(dbcPath);
|
||||
}
|
||||
|
||||
// Fall back to expansion-specific CSV
|
||||
if (dbcData.empty() && !expansionDataPath_.empty()) {
|
||||
std::string baseName = name;
|
||||
auto dot = baseName.rfind('.');
|
||||
if (dot != std::string::npos) baseName = baseName.substr(0, dot);
|
||||
std::string csvPath = expansionDataPath_ + "/db/" + baseName + ".csv";
|
||||
if (std::filesystem::exists(csvPath)) {
|
||||
std::ifstream f(csvPath, std::ios::binary | std::ios::ate);
|
||||
if (f) {
|
||||
auto size = f.tellg();
|
||||
if (size > 0) {
|
||||
f.seekg(0);
|
||||
dbcData.resize(static_cast<size_t>(size));
|
||||
f.read(reinterpret_cast<char*>(dbcData.data()), size);
|
||||
LOG_INFO("Binary DBC not found, using CSV fallback: ", csvPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dbcData.empty()) {
|
||||
// Expected on some expansions — log at debug level only.
|
||||
LOG_DEBUG("Optional DBC not found (expected on some expansions): ", name);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto dbc = std::make_shared<DBCFile>();
|
||||
if (!dbc->load(dbcData)) {
|
||||
LOG_ERROR("Failed to load DBC: ", name);
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
dbcCache[name] = dbc;
|
||||
LOG_INFO("Loaded optional DBC: ", name, " (", dbc->getRecordCount(), " records)");
|
||||
return dbc;
|
||||
}
|
||||
|
||||
std::shared_ptr<DBCFile> AssetManager::getDBC(const std::string& name) const {
|
||||
auto it = dbcCache.find(name);
|
||||
if (it != dbcCache.end()) {
|
||||
|
|
|
|||
|
|
@ -1316,12 +1316,36 @@ void CameraController::update(float deltaTime) {
|
|||
}
|
||||
}
|
||||
|
||||
// ===== Camera collision (sphere sweep approximation) =====
|
||||
// Find max safe distance using raycast + sphere radius
|
||||
// ===== Camera collision (WMO raycast) =====
|
||||
// Cast a ray from the pivot toward the camera direction to find the
|
||||
// nearest WMO wall. Uses asymmetric smoothing: pull-in is fast (so
|
||||
// the camera never visibly clips through a wall) but recovery is slow
|
||||
// (so passing through a doorway doesn't cause a zoom-out snap).
|
||||
collisionDistance = currentDistance;
|
||||
|
||||
// WMO/M2 camera collision disabled — was pulling camera through
|
||||
// geometry at doorway transitions and causing erratic zoom behaviour.
|
||||
if (wmoRenderer && currentDistance > MIN_DISTANCE) {
|
||||
float rawHitDist = wmoRenderer->raycastBoundingBoxes(pivot, camDir, currentDistance);
|
||||
// rawHitDist == currentDistance means no hit (function returns maxDistance on miss)
|
||||
float rawLimit = (rawHitDist < currentDistance)
|
||||
? std::max(MIN_DISTANCE, rawHitDist - CAM_SPHERE_RADIUS - CAM_EPSILON)
|
||||
: currentDistance;
|
||||
|
||||
// Initialise smoothed state on first use.
|
||||
if (smoothedCollisionDist_ < 0.0f) {
|
||||
smoothedCollisionDist_ = rawLimit;
|
||||
}
|
||||
|
||||
// Asymmetric smoothing:
|
||||
// • Pull-in: τ ≈ 60 ms — react quickly to prevent clipping
|
||||
// • Recover: τ ≈ 400 ms — zoom out slowly after leaving geometry
|
||||
const float tau = (rawLimit < smoothedCollisionDist_) ? 0.06f : 0.40f;
|
||||
float alpha = 1.0f - std::exp(-deltaTime / tau);
|
||||
smoothedCollisionDist_ += (rawLimit - smoothedCollisionDist_) * alpha;
|
||||
|
||||
collisionDistance = std::min(collisionDistance, smoothedCollisionDist_);
|
||||
} else {
|
||||
smoothedCollisionDist_ = -1.0f; // Reset when wmoRenderer unavailable
|
||||
}
|
||||
|
||||
// Camera collision: terrain-only floor clamping
|
||||
auto getTerrainFloorAt = [&](float x, float y) -> std::optional<float> {
|
||||
|
|
@ -1421,6 +1445,9 @@ void CameraController::update(float deltaTime) {
|
|||
// Honor first-person intent even if anti-clipping pushes camera back slightly.
|
||||
bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f);
|
||||
characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer);
|
||||
|
||||
// Note: the Renderer's CharAnimState machine drives player character animations
|
||||
// (Run, Walk, Jump, Swim, etc.) — no additional animation driving needed here.
|
||||
}
|
||||
} else {
|
||||
// Free-fly camera mode (original behavior)
|
||||
|
|
|
|||
|
|
@ -547,20 +547,6 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
|
|||
return false;
|
||||
}
|
||||
|
||||
// Diagnostic: log equipment vector and DBC state
|
||||
LOG_INFO("applyEquipment: ", equipment.size(), " items, ItemDisplayInfo.dbc records=",
|
||||
displayInfoDbc->getRecordCount(), " fields=", displayInfoDbc->getFieldCount(),
|
||||
" bodySkin=", bodySkinPath_.empty() ? "(empty)" : bodySkinPath_);
|
||||
for (size_t ei = 0; ei < equipment.size(); ++ei) {
|
||||
const auto& it = equipment[ei];
|
||||
if (it.displayModel == 0) continue;
|
||||
int32_t dbcRec = displayInfoDbc->findRecordById(it.displayModel);
|
||||
LOG_INFO(" slot[", ei, "]: displayModel=", it.displayModel,
|
||||
" invType=", (int)it.inventoryType,
|
||||
" dbcRec=", dbcRec,
|
||||
(dbcRec >= 0 ? " (found)" : " (NOT FOUND in ItemDisplayInfo.dbc)"));
|
||||
}
|
||||
|
||||
auto hasInvType = [&](std::initializer_list<uint8_t> types) -> bool {
|
||||
for (const auto& it : equipment) {
|
||||
if (it.displayModel == 0) continue;
|
||||
|
|
@ -586,10 +572,6 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
|
|||
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
|
||||
if (recIdx < 0) return 0;
|
||||
uint32_t val = displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
|
||||
if (val > 0) {
|
||||
LOG_INFO(" getGeosetGroup: displayInfoId=", displayInfoId,
|
||||
" groupField=", groupField, " field=", (7 + groupField), " val=", val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
|
|
@ -661,6 +643,20 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
|
|||
"LegUpperTexture", "LegLowerTexture", "FootTexture",
|
||||
};
|
||||
|
||||
// Texture component region fields — use DBC layout when available, fall back to binary offsets.
|
||||
const auto* idiL = pipeline::getActiveDBCLayout()
|
||||
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
|
||||
const uint32_t texRegionFields[8] = {
|
||||
idiL ? (*idiL)["TextureArmUpper"] : 14u,
|
||||
idiL ? (*idiL)["TextureArmLower"] : 15u,
|
||||
idiL ? (*idiL)["TextureHand"] : 16u,
|
||||
idiL ? (*idiL)["TextureTorsoUpper"] : 17u,
|
||||
idiL ? (*idiL)["TextureTorsoLower"] : 18u,
|
||||
idiL ? (*idiL)["TextureLegUpper"] : 19u,
|
||||
idiL ? (*idiL)["TextureLegLower"] : 20u,
|
||||
idiL ? (*idiL)["TextureFoot"] : 21u,
|
||||
};
|
||||
|
||||
std::vector<std::pair<int, std::string>> regionLayers;
|
||||
regionLayers.reserve(32);
|
||||
|
||||
|
|
@ -670,13 +666,9 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
|
|||
if (recIdx < 0) continue;
|
||||
|
||||
for (int region = 0; region < 8; region++) {
|
||||
uint32_t fieldIdx = 14 + region; // texture_1..texture_8
|
||||
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), fieldIdx);
|
||||
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), texRegionFields[region]);
|
||||
if (texName.empty()) continue;
|
||||
|
||||
LOG_INFO(" texture region ", region, " (field ", fieldIdx, "): texName=", texName,
|
||||
" for displayModel=", it.displayModel);
|
||||
|
||||
std::string base = "Item\\TextureComponents\\" +
|
||||
std::string(componentDirs[region]) + "\\" + texName;
|
||||
|
||||
|
|
@ -692,7 +684,6 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
|
|||
} else if (assetManager_->fileExists(basePath)) {
|
||||
fullPath = basePath;
|
||||
} else {
|
||||
LOG_INFO(" texture path not found: ", base, " (_M/_F/_U/.blp)");
|
||||
continue;
|
||||
}
|
||||
regionLayers.emplace_back(region, fullPath);
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@
|
|||
#include <unordered_set>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <cstring>
|
||||
|
||||
|
|
@ -1061,19 +1060,6 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector<std::string>&
|
|||
}
|
||||
}
|
||||
|
||||
// Debug: dump composite to temp dir for visual inspection
|
||||
{
|
||||
std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" +
|
||||
std::to_string(width) + "x" + std::to_string(height) + ".raw")).string();
|
||||
std::ofstream dump(dumpPath, std::ios::binary);
|
||||
if (dump) {
|
||||
dump.write(reinterpret_cast<const char*>(composite.data()),
|
||||
static_cast<std::streamsize>(composite.size()));
|
||||
core::Logger::getInstance().info("Composite debug dump: ", dumpPath,
|
||||
" (", width, "x", height, ", ", composite.size(), " bytes)");
|
||||
}
|
||||
}
|
||||
|
||||
// Upload composite to GPU via VkTexture
|
||||
auto tex = std::make_unique<VkTexture>();
|
||||
tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true);
|
||||
|
|
@ -1673,7 +1659,13 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
|
|||
if (inst.animationLoop) {
|
||||
inst.animationTime = std::fmod(inst.animationTime, static_cast<float>(seq.duration));
|
||||
} else {
|
||||
inst.animationTime = static_cast<float>(seq.duration);
|
||||
// One-shot animation finished: return to Stand (0) unless dead
|
||||
if (inst.currentAnimationId != 1 /*Death*/) {
|
||||
playAnimation(pair.first, 0, true);
|
||||
} else {
|
||||
// Stay on last frame of death
|
||||
inst.animationTime = static_cast<float>(seq.duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2207,7 +2199,6 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
|
|||
return whiteTexture_.get();
|
||||
};
|
||||
|
||||
// One-time debug dump of rendered batches per model
|
||||
// Draw batches (submeshes) with per-batch textures
|
||||
for (const auto& batch : gpuModel.data.batches) {
|
||||
if (applyGeosetFilter) {
|
||||
|
|
@ -2885,6 +2876,15 @@ void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds)
|
|||
it->second.fadeInDuration = durationSeconds;
|
||||
}
|
||||
|
||||
void CharacterRenderer::setInstanceOpacity(uint32_t instanceId, float opacity) {
|
||||
auto it = instances.find(instanceId);
|
||||
if (it != instances.end()) {
|
||||
it->second.opacity = std::clamp(opacity, 0.0f, 1.0f);
|
||||
// Cancel any fade-in in progress to avoid overwriting the new opacity
|
||||
it->second.fadeInDuration = 0.0f;
|
||||
}
|
||||
}
|
||||
|
||||
void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets) {
|
||||
auto it = instances.find(instanceId);
|
||||
if (it != instances.end()) {
|
||||
|
|
@ -3175,6 +3175,13 @@ bool CharacterRenderer::getInstanceFootZ(uint32_t instanceId, float& outFootZ) c
|
|||
return true;
|
||||
}
|
||||
|
||||
bool CharacterRenderer::getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const {
|
||||
auto it = instances.find(instanceId);
|
||||
if (it == instances.end()) return false;
|
||||
outPos = it->second.position;
|
||||
return true;
|
||||
}
|
||||
|
||||
void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) {
|
||||
auto charIt = instances.find(charInstanceId);
|
||||
if (charIt == instances.end()) return;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -261,6 +261,18 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
ImGui::BeginChild("QuestListPane", ImVec2(paneW, 0), true);
|
||||
ImGui::TextColored(ImVec4(0.85f, 0.82f, 0.74f, 1.0f), "Quest List");
|
||||
ImGui::Separator();
|
||||
|
||||
// Resolve pending select from tracker click
|
||||
if (pendingSelectQuestId_ != 0) {
|
||||
for (size_t i = 0; i < quests.size(); i++) {
|
||||
if (quests[i].questId == pendingSelectQuestId_) {
|
||||
selectedIndex = static_cast<int>(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
pendingSelectQuestId_ = 0;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < quests.size(); i++) {
|
||||
const auto& q = quests[i];
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
|
|
@ -274,6 +286,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
if (rowW < 1.0f) rowW = 1.0f;
|
||||
bool clicked = ImGui::InvisibleButton("questRowBtn", ImVec2(rowW, rowH));
|
||||
bool hovered = ImGui::IsItemHovered();
|
||||
// Scroll to selected quest on the first frame after openAndSelectQuest()
|
||||
if (selected && scrollToSelected_) {
|
||||
ImGui::SetScrollHereY(0.5f);
|
||||
scrollToSelected_ = false;
|
||||
}
|
||||
|
||||
ImVec2 rowMin = ImGui::GetItemRectMin();
|
||||
ImVec2 rowMax = ImGui::GetItemRectMax();
|
||||
|
|
@ -373,11 +390,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
|
|||
}
|
||||
}
|
||||
|
||||
// Abandon button
|
||||
// Track / Abandon buttons
|
||||
ImGui::Separator();
|
||||
bool isTracked = gameHandler.isQuestTracked(sel.questId);
|
||||
if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) {
|
||||
gameHandler.setQuestTracked(sel.questId, !isTracked);
|
||||
}
|
||||
if (!sel.complete) {
|
||||
ImGui::Separator();
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) {
|
||||
gameHandler.abandonQuest(sel.questId);
|
||||
gameHandler.setQuestTracked(sel.questId, false);
|
||||
selectedIndex = -1;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,8 +76,29 @@ static std::vector<uint8_t> readFileBytes(const std::string& path) {
|
|||
return buf;
|
||||
}
|
||||
|
||||
static bool isValidStringOffset(const std::vector<uint8_t>& stringBlock, uint32_t offset) {
|
||||
// Precompute the set of valid string-boundary offsets in the string block.
|
||||
// An offset is a valid boundary if it is 0 or immediately follows a null byte.
|
||||
// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely
|
||||
// detected as string offsets just because they land in the middle of a longer
|
||||
// string that starts at a lower offset.
|
||||
static std::set<uint32_t> computeStringBoundaries(const std::vector<uint8_t>& stringBlock) {
|
||||
std::set<uint32_t> boundaries;
|
||||
if (stringBlock.empty()) return boundaries;
|
||||
boundaries.insert(0);
|
||||
for (size_t i = 0; i + 1 < stringBlock.size(); ++i) {
|
||||
if (stringBlock[i] == 0) {
|
||||
boundaries.insert(static_cast<uint32_t>(i + 1));
|
||||
}
|
||||
}
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
static bool isValidStringOffset(const std::vector<uint8_t>& stringBlock,
|
||||
const std::set<uint32_t>& boundaries,
|
||||
uint32_t offset) {
|
||||
if (offset >= stringBlock.size()) return false;
|
||||
// Must start at a string boundary (offset 0 or right after a null byte).
|
||||
if (!boundaries.count(offset)) return false;
|
||||
for (size_t i = offset; i < stringBlock.size(); ++i) {
|
||||
uint8_t c = stringBlock[i];
|
||||
if (c == 0) return true;
|
||||
|
|
@ -105,21 +126,33 @@ static std::set<uint32_t> detectStringColumns(const DBCFile& dbc,
|
|||
std::set<uint32_t> cols;
|
||||
if (stringBlock.size() <= 1) return cols;
|
||||
|
||||
auto boundaries = computeStringBoundaries(stringBlock);
|
||||
|
||||
for (uint32_t col = 0; col < fieldCount; ++col) {
|
||||
bool allZeroOrValid = true;
|
||||
bool hasNonZero = false;
|
||||
std::set<std::string> distinctStrings;
|
||||
|
||||
for (uint32_t row = 0; row < recordCount; ++row) {
|
||||
uint32_t val = dbc.getUInt32(row, col);
|
||||
if (val == 0) continue;
|
||||
hasNonZero = true;
|
||||
if (!isValidStringOffset(stringBlock, val)) {
|
||||
if (!isValidStringOffset(stringBlock, boundaries, val)) {
|
||||
allZeroOrValid = false;
|
||||
break;
|
||||
}
|
||||
// Collect distinct non-empty strings for diversity check.
|
||||
const char* s = reinterpret_cast<const char*>(stringBlock.data() + val);
|
||||
if (*s != '\0') {
|
||||
distinctStrings.insert(std::string(s, strnlen(s, 256)));
|
||||
}
|
||||
}
|
||||
|
||||
if (allZeroOrValid && hasNonZero) {
|
||||
// Require at least 2 distinct non-empty string values. Columns that
|
||||
// only ever point to a single string (e.g. SexID=1 always resolves to
|
||||
// the same path fragment at offset 1 in the block) are almost certainly
|
||||
// integer fields whose small values accidentally land at a string boundary.
|
||||
if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) {
|
||||
cols.insert(col);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,9 +41,31 @@ std::vector<uint8_t> readFileBytes(const std::string& path) {
|
|||
return buf;
|
||||
}
|
||||
|
||||
// Check whether offset points to a plausible string in the string block.
|
||||
bool isValidStringOffset(const std::vector<uint8_t>& stringBlock, uint32_t offset) {
|
||||
// Precompute the set of valid string-boundary offsets in the string block.
|
||||
// An offset is a valid boundary if it is 0 or immediately follows a null byte.
|
||||
// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely
|
||||
// detected as string offsets just because they land in the middle of a longer
|
||||
// string that starts at a lower offset.
|
||||
std::set<uint32_t> computeStringBoundaries(const std::vector<uint8_t>& stringBlock) {
|
||||
std::set<uint32_t> boundaries;
|
||||
if (stringBlock.empty()) return boundaries;
|
||||
boundaries.insert(0); // offset 0 is always a valid start
|
||||
for (size_t i = 0; i + 1 < stringBlock.size(); ++i) {
|
||||
if (stringBlock[i] == 0) {
|
||||
boundaries.insert(static_cast<uint32_t>(i + 1));
|
||||
}
|
||||
}
|
||||
return boundaries;
|
||||
}
|
||||
|
||||
// Check whether offset points to a valid string-boundary position in the block
|
||||
// and that the string there is printable and null-terminated.
|
||||
bool isValidStringOffset(const std::vector<uint8_t>& stringBlock,
|
||||
const std::set<uint32_t>& boundaries,
|
||||
uint32_t offset) {
|
||||
if (offset >= stringBlock.size()) return false;
|
||||
// Must start at a string boundary (offset 0 or right after a null byte).
|
||||
if (!boundaries.count(offset)) return false;
|
||||
// Must be null-terminated within the block and contain only printable/whitespace bytes.
|
||||
for (size_t i = offset; i < stringBlock.size(); ++i) {
|
||||
uint8_t c = stringBlock[i];
|
||||
|
|
@ -75,21 +97,35 @@ std::set<uint32_t> detectStringColumns(const DBCFile& dbc,
|
|||
// If no string block (or trivial size), no string columns.
|
||||
if (stringBlock.size() <= 1) return stringCols;
|
||||
|
||||
// Precompute valid string-start boundaries to avoid false positives from
|
||||
// integer fields whose small values accidentally land inside longer strings.
|
||||
auto boundaries = computeStringBoundaries(stringBlock);
|
||||
|
||||
for (uint32_t col = 0; col < fieldCount; ++col) {
|
||||
bool allZeroOrValid = true;
|
||||
bool hasNonZero = false;
|
||||
std::set<std::string> distinctStrings;
|
||||
|
||||
for (uint32_t row = 0; row < recordCount; ++row) {
|
||||
uint32_t val = dbc.getUInt32(row, col);
|
||||
if (val == 0) continue;
|
||||
hasNonZero = true;
|
||||
if (!isValidStringOffset(stringBlock, val)) {
|
||||
if (!isValidStringOffset(stringBlock, boundaries, val)) {
|
||||
allZeroOrValid = false;
|
||||
break;
|
||||
}
|
||||
// Collect distinct non-empty strings for diversity check.
|
||||
const char* s = reinterpret_cast<const char*>(stringBlock.data() + val);
|
||||
if (*s != '\0') {
|
||||
distinctStrings.insert(std::string(s, strnlen(s, 256)));
|
||||
}
|
||||
}
|
||||
|
||||
if (allZeroOrValid && hasNonZero) {
|
||||
// Require at least 2 distinct non-empty string values. Columns that
|
||||
// only ever point to a single string (e.g. SexID=1 always resolves to
|
||||
// the same path fragment at offset 1 in the block) are almost certainly
|
||||
// integer fields whose small values accidentally land at a string boundary.
|
||||
if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) {
|
||||
stringCols.insert(col);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue