Compare commits

...

58 commits

Author SHA1 Message Date
Kelsi
0b8e1834f6 feat: group dungeon finder list by expansion with separator headers
Some checks failed
Build / Build (arm64) (push) Has been cancelled
Build / Build (x86-64) (push) Has been cancelled
Build / Build (macOS arm64) (push) Has been cancelled
Build / Build (windows-arm64) (push) Has been cancelled
Build / Build (windows-x86-64) (push) Has been cancelled
Security / CodeQL (C/C++) (push) Has been cancelled
Security / Semgrep (push) Has been cancelled
Security / Sanitizer Build (ASan/UBSan) (push) Has been cancelled
Categorize dungeons into Random/Classic/TBC/WotLK sections with visual
separators in the dropdown for easier navigation.
2026-03-18 12:43:04 -07:00
Kelsi
86cc6e16a4 fix: correct PET_CAST_FAILED expansion format and parse LFG role choices
SMSG_PET_CAST_FAILED: Classic/TBC omit the castCount byte (matching
SMSG_CAST_FAILED pattern). Without this fix, TBC parsing reads garbage.
SMSG_LFG_ROLE_CHOSEN: surface role selection messages in chat during
dungeon finder role checks.
2026-03-18 12:40:20 -07:00
Kelsi
d149255c58 feat: implement petition signing flow for guild charter creation
Parse SMSG_PETITION_QUERY_RESPONSE, SMSG_PETITION_SHOW_SIGNATURES,
and SMSG_PETITION_SIGN_RESULTS. Add UI to view signatures, sign
petitions, and turn in completed charters. Send CMSG_PETITION_SIGN
and CMSG_TURN_IN_PETITION packets.
2026-03-18 12:31:48 -07:00
Kelsi
41e15349c5 feat: improve arena team UI with names, types, and roster requests
Store team name and type (2v2/3v3/5v5) from SMSG_ARENA_TEAM_QUERY_RESPONSE.
Display proper team labels instead of raw IDs. Add Load/Refresh roster
buttons and CMSG_ARENA_TEAM_ROSTER request support.
2026-03-18 12:26:23 -07:00
Kelsi
aed8c94544 feat: add instance difficulty indicator on minimap
Show Normal/Heroic/25-Man difficulty badge below zone name when inside
a dungeon or raid instance. Orange-highlighted for heroic modes.
2026-03-18 12:21:41 -07:00
Kelsi
801f29f043 fix: sync player appearance after barber shop or polymorph
PLAYER_BYTES and PLAYER_BYTES_2 changes in SMSG_UPDATE_OBJECT now
update the Character struct's appearanceBytes and facialFeatures,
and fire an appearance-changed callback that resets the inventory
screen preview so it reloads with the new hair/face values.
2026-03-18 12:17:00 -07:00
Kelsi
2e134b686d fix: correct BattlemasterList.dbc IDs for arenas and Isle of Conquest
Arena and BG type IDs now match actual 3.3.5a BattlemasterList.dbc:
Nagrand Arena=4, Blade's Edge=5, Ruins of Lordaeron=8, Dalaran
Sewers=10, Ring of Valor=11, Isle of Conquest=30, Random BG=32.
2026-03-18 12:04:38 -07:00
Kelsi
5d5083683f fix: correct Eye of the Storm bgTypeId and simplify BG invite popup
Eye of the Storm uses bgTypeId 7 (from BattlemasterList.dbc), not 6.
BG invite popup now uses the stored bgName from the queue slot instead
of re-deriving the name with a duplicate switch statement.
2026-03-18 12:03:36 -07:00
Kelsi
64fd7eddf8 feat: implement barber shop UI with hair/facial customization
Adds a functional barber shop window triggered by SMSG_ENABLE_BARBER_SHOP.
Players can adjust hair style, hair color, and facial features using
sliders bounded by race/gender max values. Sends CMSG_ALTER_APPEARANCE
on confirm; server result closes the window on success. Escape key
also closes the barber shop.
2026-03-18 11:58:01 -07:00
Kelsi
8dfd916fe4 feat: add right-click context menu to target and focus frames
Right-clicking the target or focus frame name now opens a context
menu with Set Focus/Target, Clear Focus, Whisper, Invite, Trade,
Duel, Inspect, Add Friend, and Ignore options (player-specific
options only shown for player targets).
2026-03-18 11:48:22 -07:00
Kelsi
bf8710d6a4 feat: add Shift+V toggle for friendly player nameplates
V key now toggles enemy/NPC nameplates, while Shift+V independently
toggles friendly player nameplates. Setting is persisted to config.
2026-03-18 11:43:39 -07:00
Kelsi
d6c752fba5 feat: Escape key closes topmost open window before showing menu
Escape now closes UI windows in priority order (vendor, bank, trainer,
who, combat log, social, talents, spellbook, quest log, character,
inventory, world map) before falling through to the escape menu, matching
standard WoW behavior.
2026-03-18 11:35:05 -07:00
Kelsi
f283f9eb86 fix: show equipment durability summary in repair button tooltip
The Repair All button tooltip now shows how many items are damaged or
broken instead of a generic message, helping players gauge repair need.
2026-03-18 11:30:34 -07:00
Kelsi
4a30fdf9f6 feat: add spell icon to nameplate cast bars
Nameplate cast bars now display the spell icon to the left of the bar,
matching the visual treatment of target frame and party cast bars.
2026-03-18 11:29:08 -07:00
Kelsi
0caf945a44 feat: add NumLock auto-run toggle and HUD indicator
NumLock now toggles auto-run alongside the existing tilde key. A cyan
[Auto-Run] indicator appears in the player info area when active.
2026-03-18 11:25:35 -07:00
Kelsi
9368c8a715 feat: add confirmation dialog before spending talent points
Clicking a learnable talent now opens a modal confirmation popup
showing the spell name and rank, preventing accidental talent spending.
2026-03-18 11:23:35 -07:00
Kelsi
ef4cf461a5 feat: add duration countdown and stack count to nameplate debuff dots
Nameplate debuff indicators now show: clock-sweep overlay for elapsed
duration, countdown text below each dot (color-coded red < 5s, yellow
< 15s), stack count badge, and duration in hover tooltip.
2026-03-18 11:21:14 -07:00
Kelsi
d4c7157208 feat: add vendor purchase confirmation for expensive items
Shows a confirmation dialog before buying items costing 1 gold or more,
preventing accidental purchases. Displays item name, quantity, and
total cost in gold/silver/copper.
2026-03-18 11:16:43 -07:00
Kelsi
9b32a328c3 feat: add item stack splitting via Shift+right-click
Implements CMSG_SPLIT_ITEM (0x10E) with a slider popup for choosing
split count. Auto-finds empty destination slot across backpack and bags.
Shift+right-click on stackable items (count > 1) opens split dialog;
non-stackable items still get the destroy confirmation.
2026-03-18 11:07:27 -07:00
Kelsi
17d652947c feat: extend cursor hover to NPCs and players
Hand cursor now shows when hovering over any interactive entity in the
3D world (NPCs, players, game objects), not just game objects. Helps
identify clickable targets at a glance.
2026-03-18 10:56:44 -07:00
Kelsi
1cff1a03a5 feat: add clock display on minimap
Show local time (12-hour AM/PM) at the bottom-right corner of the
minimap with a semi-transparent background.
2026-03-18 10:54:03 -07:00
Kelsi
7f2ee8aa7e fix: add error sound on cast failure and AFK/DND whisper auto-reply
Play UI error sound on SMSG_CAST_FAILED for consistent audio feedback,
matching other error handlers (vendor, inventory, trainer).
Auto-reply to incoming whispers with AFK/DND message when player has
set /afk or /dnd status.
2026-03-18 10:50:42 -07:00
Kelsi
2dc5b21341 feat: add screenshot capture (PrintScreen key and /screenshot command)
Captures the Vulkan swapchain image to PNG via stb_image_write.
Screenshots saved to ~/.wowee/screenshots/ with timestamped filenames.
Cross-platform: BGRA→RGBA swizzle, localtime_r/localtime_s.
2026-03-18 10:47:34 -07:00
Kelsi
a417a00d3a feat: add FPS counter to latency meter
Display color-coded FPS alongside latency at top of screen.
Green >=60, yellow >=30, red <30. Shows FPS even without latency data.
2026-03-18 10:27:25 -07:00
Kelsi
6a0b0a99d1 fix: add /loc to /help command listing 2026-03-18 10:23:42 -07:00
Kelsi
09860e5fc6 feat: add /loc command to show player coordinates
Type /loc, /coords, or /whereami in chat to display current position
(X, Y, Z) and zone name as a system message. Useful for sharing
locations or debugging position issues.
2026-03-18 10:22:39 -07:00
Kelsi
dfddc71ebb docs: update status with nameplate and combat text features 2026-03-18 10:19:25 -07:00
Kelsi
355b75c3c7 feat: add creature type and guild name to focus frame
Show creature type (Beast, Humanoid, etc.) on the focus frame next to
the rank badge, matching the target frame. Also display player guild
names on focus frame for player targets.
2026-03-18 10:14:09 -07:00
Kelsi
c8f80339f1 feat: display creature type on target frame
Show creature classification (Beast, Humanoid, Demon, etc.) next to the
level on the target frame. Useful for knowing which CC abilities apply
(Polymorph → Humanoid/Beast, Banish → Demon/Elemental, etc.).
2026-03-18 10:12:03 -07:00
Kelsi
1ea9334eca feat: enable login screen background music
Re-enable the login screen music system that was previously disabled.
Randomly selects from available tracks in assets/Original Music/ and
plays them at 80% volume during authentication.
2026-03-18 10:08:44 -07:00
Kelsi
402bbc2f14 feat: elite/boss/rare border decorations on nameplates
Add rank-specific outer borders on NPC nameplates: gold for Elite and
Rare Elite, red for Boss, silver for Rare. Provides immediate visual
identification of dangerous mobs without needing to target them.
2026-03-18 10:07:40 -07:00
Kelsi
fd7886f4ce feat: show NPC subtitle on nameplates
Display creature subtitles (e.g. <Reagent Vendor>, <Innkeeper>) below
NPC names on nameplates, mirroring the guild tag display for players.
The subtitle is fetched from the creature info cache populated by
SMSG_CREATURE_QUERY_RESPONSE.
2026-03-18 10:05:49 -07:00
Kelsi
209f60031e feat: respect loot roll voteMask for button visibility
Store the voteMask from SMSG_LOOT_START_ROLL and use it to conditionally
show Need/Greed/Disenchant/Pass buttons. Previously all four buttons were
always shown regardless of the server's allowed roll types.
2026-03-18 10:01:53 -07:00
Kelsi
02a1b5cbf3 fix: show reflected spell name in combat text
SMSG_SPELL_MISS_LOG REFLECT entries include a reflectSpellId field that
was parsed but discarded. Now store it in SpellMissLogEntry and pass it
to addCombatText, so floating combat text shows the actual reflected
spell name instead of the original cast spell.
2026-03-18 09:59:54 -07:00
Kelsi
63b4394e3e feat: world-space floating combat text above entities
Combat text (damage, heals, misses, crits, etc.) now floats above the
target entity in 3D space instead of appearing at fixed screen positions.
Text rises upward from the entity's head, with random horizontal stagger
to prevent stacking. HUD-only types (XP, Honor, Procs) and entries
without a valid entity anchor fall back to the original screen overlay.
2026-03-18 09:54:52 -07:00
Kelsi
6aea48aea9 feat: show guild name on target frame for players
Display <GuildName> below the player name in the target frame,
using the same guild name cache as nameplates.
2026-03-18 09:48:03 -07:00
Kelsi
e572cdfb4a feat: show guild names on player nameplates
Read PLAYER_GUILDID from entity update fields (UNIT_END + 3) and query
guild names via CMSG_GUILD_QUERY. Cache results in guildNameCache_ so
each guild ID is queried only once. Display <Guild Name> in grey below
the player name on nameplates. Fix handleGuildQueryResponse to not
overwrite the local player's guild data when querying other guilds.
2026-03-18 09:44:43 -07:00
Kelsi
003ad8b20c fix: read WotLK periodic damage isCrit byte in SMSG_PERIODICAURALOG
The WotLK periodic damage format includes an isCrit byte after resisted
(21 bytes total, not 20). Missing this byte caused parse misalignment
for multi-effect periodicauralog packets. Also use the already-read
isCrit on periodic heals to display critical HoT ticks distinctly.
2026-03-18 09:17:00 -07:00
Kelsi
8b7786f2b3 feat: display combo points on target frame
Add 5-dot combo point indicator between target power bar and cast bar.
Lit dots are yellow (1-4 CP) or red (5 CP) with glow effect; unlit
dots show as dark outlines. Only visible when the player's combo target
matches the current target.
2026-03-18 09:08:46 -07:00
Kelsi
6d9adc547a fix: extend world-load animation callbacks to handle online players
loadOnlineWorldTerrain re-registers the death/respawn/swing callbacks,
overriding the ones from setupUICallbacks. The world-load versions only
checked creatureInstances_, so the player lookup fix from the previous
commit was silently reverted whenever the world loaded. Now both
registration sites check playerInstances_ as a fallback.
2026-03-18 08:52:00 -07:00
Kelsi
1af5acba3f fix: show real player names on nameplates instead of "Player"
Player class declared its own 'name' member and getName()/setName()
that shadowed the inherited Unit::name. Since getName() is non-virtual,
code using Unit* pointers (nameplates, target frame, entity list) read
Unit::name (always empty) while Player::setName() wrote to the shadowed
Player::name. Removed the redundant declaration so Player inherits
name storage from Unit.
2026-03-18 08:49:16 -07:00
Kelsi
100d66d18b fix: play death/attack animations for online players, not just NPCs
Death, respawn, and melee swing callbacks only checked
creatureInstances_, so online players never played death animation when
killed, never returned to idle on resurrect, and never showed attack
swings. Extended all three callbacks to also check playerInstances_.

Also extended the game_handler death/respawn callback triggers to fire
for PLAYER entities (not just UNIT), and added spawn-time death
detection for players that are already dead when first seen.
2026-03-18 08:43:19 -07:00
Kelsi
e54ed1d46f fix: pass correct offset to setPlayerOnTransport on transport boarding
Both CREATE_OBJECT and MOVEMENT update paths called
setPlayerOnTransport(guid, vec3(0)) then immediately overwrote
playerTransportOffset_ on the next line. This left a one-frame window
where the composed world position used (0,0,0) as the local offset,
causing the player to visually snap to the transport origin. Compute the
canonical offset first and pass it directly.
2026-03-18 08:39:35 -07:00
Kelsi
a619f44dfb fix: add per-frame animation sync for online players
Online players had no animation state machine — once Run started from a
movement packet, it never transitioned back to Stand/Idle. This mirrors
the creature sync loop: position, orientation, and locomotion animation
(Run/Walk/Swim/Fly ↔ Stand/SwimIdle/FlyIdle) are now driven per-frame
based on Entity::isActivelyMoving() state transitions.

Also cleans up creatureRenderPosCache_ on player despawn.
2026-03-18 08:33:45 -07:00
Kelsi
18c06d98ac fix: stop creature run animation when movement interpolation completes
Creatures were stuck in Run/Walk animation during the dead-reckoning
overrun window (up to 2x movement duration). The animation check used
isEntityMoving() which stays true through dead reckoning, causing
creatures to "run in place" after reaching their destination.

Add isActivelyMoving() which is true only during the active
interpolation phase (moveElapsed < moveDuration), and use it for
animation state transitions. Dead reckoning still works for position
extrapolation — only the animation now correctly stops at arrival.
2026-03-18 08:22:50 -07:00
Kelsi
0b33bcbe53 fix: reject oversized MonsterMove spline and fix loot format comment
Change WotLK MonsterMove pointCount > 1000 from cap-to-1000 to return
false. Capping caused the parser to read only 1000 of N points, leaving
the remaining point data unread and misaligning subsequent reads.

Also correct misleading loot response comment: Classic/TBC DO include
randomSuffix and randomPropertyId (22 bytes/item, same as WotLK). The
only WotLK difference is the quest item list appended after regular
items.
2026-03-18 08:18:21 -07:00
Kelsi
64b03ffdf5 fix: add bounds checks to update block and field parsers
Check remaining packet data before reading update type, GUIDs, object
type, and block count in parseUpdateBlock and parseUpdateFields. Prevents
silent garbage reads when the parser reaches the end of a truncated or
misaligned packet.
2026-03-18 08:08:08 -07:00
Kelsi
d1c99b1c0e fix: add bounds checks to WotLK movement block parser
Complete the parser hardening across all expansions. Check remaining
bytes before every conditional read in the WotLK base
UpdateObjectParser::parseMovementBlock: LIVING entry (66-byte minimum),
transport, pitch, fall time, jumping, spline elevation, speeds,
POSITION, STATIONARY, and all tail flags (HAS_TARGET, TRANSPORT,
VEHICLE, ROTATION, LOWGUID, HIGHGUID). Prevents silent garbage reads
when Packet::readUInt8/readFloat return 0 past EOF.
2026-03-18 08:04:00 -07:00
Kelsi
e802decc84 fix: add bounds checks to TBC movement block parser
Same hardening as the Classic and Turtle parsers: check remaining bytes
before every conditional read in TbcPacketParsers::parseMovementBlock.
Change spline pointCount > 256 to return false instead of capping to
zero (which silently consumed wrong bytes for the endPoint).
2026-03-18 08:01:39 -07:00
Kelsi
eca570140a fix: eliminate 8-second teleport freeze on same-map teleport
Replace processAllReadyTiles() with bounded processReadyTiles() in the
same-map teleport and reconnect paths. processAllReadyTiles finalizes
every pending tile synchronously with a GPU sync wait, which caused
8+ second main-thread stalls when many tiles were queued. The bounded
version processes 1-4 tiles per call with async GPU upload — remaining
tiles finalize incrementally over subsequent frames.
2026-03-18 07:54:05 -07:00
Kelsi
14cd6c82b2 fix: add bounds checks to Classic movement block parser
Mirror the Turtle parser hardening: check remaining bytes before every
conditional read in ClassicPacketParsers::parseMovementBlock. Prevents
silent garbage reads (readUInt8 returns 0 past EOF) that corrupt
subsequent update fields and lose NPC data in multi-block packets.
2026-03-18 07:47:46 -07:00
Kelsi
0a04a00234 fix: harden Turtle movement block parser with bounds checks
The Turtle parseMovementBlock had no bounds checking on any reads.
Since Packet::readUInt8() returns 0 past the end without failing, the
parser could "succeed" with all-zero garbage data, then subsequent
parseUpdateFields would read from wrong positions, producing
"truncated field value" and "truncated update mask" errors.

Added bounds checks before every conditional read section (transport,
swimming pitch, fall time, jumping, spline elevation, speeds, spline
data, tail flags). Also removed the WotLK movement block fallback from
the Turtle parser chain — WotLK format is fundamentally incompatible
(uint16 flags, 9 speeds) and false-positive parses corrupt NPC data.
Also changed spline pointCount > 256 from cap-to-zero to return false
so the parser correctly fails instead of silently dropping waypoints.
2026-03-18 07:39:40 -07:00
Kelsi
ce3caf0438 fix: auto-detect Classic vs WotLK spline format in UPDATE_OBJECT
The spline parser assumed WotLK format (durationMod, durationModNext,
conditional PARABOLIC fields) for all expansions. Classic/Turtle has a
simpler layout: timePassed+duration+splineId+pointCount directly.
Reading WotLK-specific fields from Classic data consumed wrong bytes,
causing pointCount to read garbage and the entire update block to fail
— losing dozens of NPC spawns in multi-block packets.

Now tries Classic format first (pointCount at offset 12), then WotLK
(offset 20+), then compact fallback. Also fixes WotLK SMSG_SPELL_GO
hit/miss targets to use full uint64 GUIDs instead of PackedGuid, which
was the root cause of garbage missCount values (46, 64, 241).
2026-03-18 07:23:51 -07:00
Kelsi
6484dfc32d fix: gate spline verticalAccel/effectStartTime on PARABOLIC flag
The legacy UPDATE_OBJECT spline path was reading verticalAccel (float)
and effectStartTime (uint32) unconditionally, but these 8 bytes are
only present when SPLINEFLAG_PARABOLIC (0x00000800) is set. Without
the flag, the extra reads shifted the stream by 8 bytes, causing
pointCount to read garbage (e.g. 3323328650) and failing the entire
update block parse.
2026-03-18 07:05:17 -07:00
Kelsi
f78d885e13 fix: add 60-second grace period to M2 model cleanup
Models that lose all instances are no longer immediately evicted from
GPU memory. Instead they get a 60-second grace period, preventing the
thrash cycle where GO models (barrels, chests, herbs) were evicted
every 5 seconds and re-loaded when the same object type respawned.
2026-03-18 07:00:50 -07:00
Kelsi
3c60ef8464 fix: add hex dump diagnostics to spell-go missCount parsing
When SMSG_SPELL_GO reads a suspiciously high missCount (>20), log
the surrounding packet bytes, castFlags, and position for debugging
the persistent offset error causing garbage miss counts (46, 48, 241).
2026-03-18 06:57:15 -07:00
Kelsi
c8922e4826 fix: stop player movement before game object interaction
Servers may reject CMSG_GAMEOBJ_USE or cancel the resulting pickup
spell cast if movement flags are still active. Now sends MSG_MOVE_STOP
to clear directional movement before the interaction packet. Also adds
diagnostic logging for GO interactions to help trace collection issues.
2026-03-18 06:49:43 -07:00
Kelsi
f8f514d28c fix: add $C (class) and $R (race) quest text placeholders
Quest dialogs were showing literal "$C" instead of the player's class
name. Added support for $c/$C (class) and $r/$R (race) placeholders
in both game_screen and quest_log_screen substitution functions.
2026-03-18 06:49:37 -07:00
24 changed files with 2286 additions and 544 deletions

View file

@ -25,6 +25,9 @@ Implemented (working in normal use):
- Talent tree UI with proper visuals and functionality
- Pet tracking (SMSG_PET_SPELLS), dismiss pet button
- Party: group invites, party list, out-of-range member health (SMSG_PARTY_MEMBER_STATS)
- Nameplates: NPC subtitles, guild names, elite/boss/rare borders, quest/raid indicators, cast bars, debuff dots
- Floating combat text: world-space damage/heal numbers above entities with 3D projection
- Target/focus frames: guild name, creature type, rank badges, combo points, cast bars
- Map exploration: subzone-level fog-of-war reveal
- Warden anti-cheat: full module execution via Unicorn Engine x86 emulation; module caching
- Audio: ambient, movement, combat, spell, and UI sound systems

View file

@ -135,6 +135,13 @@ public:
bool isEntityMoving() const { return isMoving_; }
/// True only during the active interpolation phase (before reaching destination).
/// Unlike isEntityMoving(), this does NOT include the dead-reckoning overrun window,
/// so animations (Run/Walk) should use this to avoid "running in place" after arrival.
bool isActivelyMoving() const {
return isMoving_ && moveElapsed_ < moveDuration_;
}
// Returns the latest server-authoritative position: destination if moving, current if not.
// Unlike getX/Y/Z (which only update via updateMovement), this always reflects the
// last known server position regardless of distance culling.
@ -277,18 +284,14 @@ protected:
/**
* Player entity
* Name is inherited from Unit do NOT redeclare it here or the
* shadowed field will diverge from Unit::name, causing nameplates
* and other Unit*-based lookups to read an empty string.
*/
class Player : public Unit {
public:
Player() { type = ObjectType::PLAYER; }
explicit Player(uint64_t guid) : Unit(guid) { type = ObjectType::PLAYER; }
// Name
const std::string& getName() const { return name; }
void setName(const std::string& n) { name = n; }
protected:
std::string name;
};
/**

View file

@ -453,6 +453,7 @@ public:
uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE)
uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE)
std::chrono::steady_clock::time_point inviteReceivedTime{};
std::string bgName; // human-readable BG/arena name
};
// Available BG list (populated by SMSG_BATTLEFIELD_LIST)
@ -608,6 +609,33 @@ public:
uint32_t getPetitionCost() const { return petitionCost_; }
uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; }
// Petition signatures (guild charter signing flow)
struct PetitionSignature {
uint64_t playerGuid = 0;
std::string playerName; // resolved later or empty
};
struct PetitionInfo {
uint64_t petitionGuid = 0;
uint64_t ownerGuid = 0;
std::string guildName;
uint32_t signatureCount = 0;
uint32_t signaturesRequired = 9; // guild default; arena teams differ
std::vector<PetitionSignature> signatures;
bool showUI = false;
};
const PetitionInfo& getPetitionInfo() const { return petitionInfo_; }
bool hasPetitionSignaturesUI() const { return petitionInfo_.showUI; }
void clearPetitionSignaturesUI() { petitionInfo_.showUI = false; }
void signPetition(uint64_t petitionGuid);
void turnInPetition(uint64_t petitionGuid);
// Guild name lookup for other players' nameplates
// Returns the guild name for a given guildId, or empty if unknown.
// Automatically queries the server for unknown guild IDs.
const std::string& lookupGuildName(uint32_t guildId);
// Returns the guildId for a player entity (from PLAYER_GUILDID update field).
uint32_t getEntityGuildId(uint64_t guid) const;
// Ready check
struct ReadyCheckResult {
std::string name;
@ -667,6 +695,16 @@ public:
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? static_cast<int>(it->second.rank) : -1;
}
// Returns creature type (1=Beast,2=Dragonkin,...,7=Humanoid,...) or 0 if not cached
uint32_t getCreatureType(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.creatureType : 0;
}
// Returns creature family (e.g. pet family for beasts) or 0
uint32_t getCreatureFamily(uint32_t entry) const {
auto it = creatureInfoCache.find(entry);
return (it != creatureInfoCache.end()) ? it->second.family : 0;
}
// ---- Phase 2: Combat ----
void startAutoAttack(uint64_t targetGuid);
@ -931,6 +969,10 @@ public:
using StandStateCallback = std::function<void(uint8_t standState)>;
void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); }
// Appearance changed callback — fired when PLAYER_BYTES or facial features update (barber shop, etc.)
using AppearanceChangedCallback = std::function<void()>;
void setAppearanceChangedCallback(AppearanceChangedCallback cb) { appearanceChangedCallback_ = 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); }
@ -1202,6 +1244,17 @@ public:
uint32_t getPetUnlearnCost() const { return petUnlearnCost_; }
void confirmPetUnlearn();
void cancelPetUnlearn() { petUnlearnPending_ = false; }
// Barber shop
bool isBarberShopOpen() const { return barberShopOpen_; }
void closeBarberShop() { barberShopOpen_ = false; }
void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair);
// Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK)
uint32_t getInstanceDifficulty() const { return instanceDifficulty_; }
bool isInstanceHeroic() const { return instanceIsHeroic_; }
bool isInInstance() const { return inInstance_; }
/** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const;
/** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */
@ -1398,8 +1451,11 @@ public:
uint32_t seasonGames = 0;
uint32_t seasonWins = 0;
uint32_t rank = 0;
std::string teamName;
uint32_t teamType = 0; // 2, 3, or 5
};
const std::vector<ArenaTeamStats>& getArenaTeamStats() const { return arenaTeamStats_; }
void requestArenaTeamRoster(uint32_t teamId);
// ---- Arena Team Roster ----
struct ArenaTeamMember {
@ -1451,6 +1507,7 @@ public:
std::string itemName;
uint8_t itemQuality = 0;
uint32_t rollCountdownMs = 60000; // Duration of roll window in ms
uint8_t voteMask = 0xFF; // Bitmask: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant
std::chrono::steady_clock::time_point rollStartedAt{};
struct PlayerRollResult {
@ -2011,6 +2068,7 @@ public:
void openItemBySlot(int backpackIndex);
void openItemInBag(int bagIndex, int slotIndex);
void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1);
void splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count);
void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot);
void swapBagSlots(int srcBagIndex, int dstBagIndex);
void useItemById(uint32_t itemId);
@ -2337,6 +2395,9 @@ private:
void handleGuildInvite(network::Packet& packet);
void handleGuildCommandResult(network::Packet& packet);
void handlePetitionShowlist(network::Packet& packet);
void handlePetitionQueryResponse(network::Packet& packet);
void handlePetitionShowSignatures(network::Packet& packet);
void handlePetitionSignResults(network::Packet& packet);
void handlePetSpells(network::Packet& packet);
void handleTurnInPetitionResults(network::Packet& packet);
@ -2837,6 +2898,7 @@ private:
// Instance difficulty
uint32_t instanceDifficulty_ = 0;
bool instanceIsHeroic_ = false;
bool inInstance_ = false;
// Raid target markers (icon 0-7 -> guid; 0 = empty slot)
std::array<uint64_t, kRaidMarkCount> raidTargetGuids_ = {};
@ -2952,16 +3014,22 @@ private:
GuildInfoData guildInfoData_;
GuildQueryResponseData guildQueryData_;
bool hasGuildRoster_ = false;
std::unordered_map<uint32_t, std::string> guildNameCache_; // guildId → guild name
std::unordered_set<uint32_t> pendingGuildNameQueries_; // in-flight guild queries
bool pendingGuildInvite_ = false;
std::string pendingGuildInviterName_;
std::string pendingGuildInviteGuildName_;
bool showPetitionDialog_ = false;
uint32_t petitionCost_ = 0;
uint64_t petitionNpcGuid_ = 0;
PetitionInfo petitionInfo_;
uint64_t activeCharacterGuid_ = 0;
Race playerRace_ = Race::HUMAN;
// Barber shop
bool barberShopOpen_ = false;
// ---- Phase 5: Loot ----
bool lootWindowOpen = false;
bool autoLoot_ = false;
@ -3317,6 +3385,7 @@ private:
NpcAggroCallback npcAggroCallback_;
NpcRespawnCallback npcRespawnCallback_;
StandStateCallback standStateCallback_;
AppearanceChangedCallback appearanceChangedCallback_;
GhostStateCallback ghostStateCallback_;
MeleeSwingCallback meleeSwingCallback_;
uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing

View file

@ -61,6 +61,9 @@ struct CombatTextEntry {
float age = 0.0f; // Seconds since creation (for fadeout)
bool isPlayerSource = false; // True if player dealt this
uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower
uint64_t srcGuid = 0; // Source entity (attacker/caster)
uint64_t dstGuid = 0; // Destination entity (victim/target) — used for world-space positioning
float xSeed = 0.0f; // Random horizontal offset seed (-1..1) to stagger overlapping text
static constexpr float LIFETIME = 2.5f;
bool isExpired() const { return age >= LIFETIME; }

View file

@ -2046,6 +2046,13 @@ public:
static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot);
};
/** CMSG_SPLIT_ITEM packet builder */
class SplitItemPacket {
public:
static network::Packet build(uint8_t srcBag, uint8_t srcSlot,
uint8_t dstBag, uint8_t dstSlot, uint8_t count);
};
/** CMSG_SWAP_INV_ITEM packet builder */
class SwapInvItemPacket {
public:
@ -2789,5 +2796,12 @@ public:
static network::Packet build(int32_t titleBit);
};
/** CMSG_ALTER_APPEARANCE barber shop: change hair style, color, facial hair.
* Payload: uint32 hairStyle, uint32 hairColor, uint32 facialHair. */
class AlterAppearancePacket {
public:
static network::Packet build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair);
};
} // namespace game
} // namespace wowee

View file

@ -13,6 +13,7 @@
#include <string>
#include <optional>
#include <random>
#include <chrono>
#include <future>
namespace wowee {
@ -434,6 +435,9 @@ private:
void* glowVBMapped_ = nullptr;
std::unordered_map<uint32_t, M2ModelGPU> models;
// Grace period for model cleanup: track when a model first became instanceless.
// Models are only evicted after 60 seconds with no instances.
std::unordered_map<uint32_t, std::chrono::steady_clock::time_point> modelUnusedSince_;
std::vector<M2Instance> instances;
// O(1) dedup: key = (modelId, quantized x, quantized y, quantized z) → instanceId

View file

@ -154,6 +154,9 @@ public:
void triggerLevelUpEffect(const glm::vec3& position);
void cancelEmote();
// Screenshot capture — copies swapchain image to PNG file
bool captureScreenshot(const std::string& outputPath);
// Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT)
// useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path
void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,

View file

@ -279,6 +279,9 @@ public:
/** Process one ready tile (for loading screens with per-tile progress updates) */
void processOneReadyTile();
/** Process a bounded batch of ready tiles with async GPU upload (no sync wait) */
void processReadyTiles();
private:
/**
* Get tile coordinates from GL world position
@ -317,10 +320,6 @@ private:
*/
void workerLoop();
/**
* Main thread: poll for completed tiles and upload to GPU
*/
void processReadyTiles();
void ensureGroundEffectTablesLoaded();
void generateGroundClutterPlacements(std::shared_ptr<PendingTile>& pending,
std::unordered_set<uint32_t>& preparedModelIds);

View file

@ -86,7 +86,8 @@ private:
bool showEntityWindow = false;
bool showChatWindow = true;
bool showMinimap_ = true; // M key toggles minimap
bool showNameplates_ = true; // V key toggles nameplates
bool showNameplates_ = true; // V key toggles enemy/NPC nameplates
bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates
float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions
uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none)
ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click
@ -365,6 +366,7 @@ private:
void renderQuestOfferRewardWindow(game::GameHandler& gameHandler);
void renderVendorWindow(game::GameHandler& gameHandler);
void renderTrainerWindow(game::GameHandler& gameHandler);
void renderBarberShopWindow(game::GameHandler& gameHandler);
void renderStableWindow(game::GameHandler& gameHandler);
void renderTaxiWindow(game::GameHandler& gameHandler);
void renderLogoutCountdown(game::GameHandler& gameHandler);
@ -398,6 +400,7 @@ private:
void renderBattlegroundScore(game::GameHandler& gameHandler);
void renderDPSMeter(game::GameHandler& gameHandler);
void renderDurabilityWarning(game::GameHandler& gameHandler);
void takeScreenshot(game::GameHandler& gameHandler);
/**
* Inventory screen
@ -532,6 +535,24 @@ private:
// Vendor search filter
char vendorSearchFilter_[128] = "";
// Vendor purchase confirmation for expensive items
bool vendorConfirmOpen_ = false;
uint64_t vendorConfirmGuid_ = 0;
uint32_t vendorConfirmItemId_ = 0;
uint32_t vendorConfirmSlot_ = 0;
uint32_t vendorConfirmQty_ = 1;
uint32_t vendorConfirmPrice_ = 0;
std::string vendorConfirmItemName_;
// Barber shop UI state
int barberHairStyle_ = 0;
int barberHairColor_ = 0;
int barberFacialHair_ = 0;
int barberOrigHairStyle_ = 0;
int barberOrigHairColor_ = 0;
int barberOrigFacialHair_ = 0;
bool barberInitialized_ = false;
// Trainer search filter
char trainerSearchFilter_[128] = "";
@ -644,6 +665,7 @@ private:
float resurrectFlashTimer_ = 0.0f;
static constexpr float kResurrectFlashDuration = 3.0f;
bool ghostStateCallbackSet_ = false;
bool appearanceCallbackSet_ = false;
bool ghostOpacityStateKnown_ = false;
bool ghostOpacityLastState_ = false;
uint32_t ghostOpacityLastInstanceId_ = 0;

View file

@ -187,6 +187,14 @@ private:
uint8_t destroyCount_ = 1;
std::string destroyItemName_;
// Stack split popup state
bool splitConfirmOpen_ = false;
uint8_t splitBag_ = 0xFF;
uint8_t splitSlot_ = 0;
int splitMax_ = 1;
int splitCount_ = 1;
std::string splitItemName_;
// Pending chat item link from shift-click
std::string pendingChatItemLink_;

View file

@ -45,6 +45,12 @@ private:
std::unordered_map<uint32_t, std::string> spellTooltips; // spellId -> description
std::unordered_map<uint32_t, VkDescriptorSet> bgTextureCache_; // tabId -> bg texture
// Talent learn confirmation
bool talentConfirmOpen_ = false;
uint32_t pendingTalentId_ = 0;
uint32_t pendingTalentRank_ = 0;
std::string pendingTalentName_;
// GlyphProperties.dbc cache: glyphId -> { spellId, isMajor }
struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; };
std::unordered_map<uint32_t, GlyphInfo> glyphProperties_; // glyphId -> info

View file

@ -1647,7 +1647,11 @@ void Application::update(float deltaTime) {
// startMoveTo() in handleMonsterMove, regardless of distance-cull.
// This correctly detects movement for distant creatures (> 150u)
// where updateMovement() is not called and getX/Y/Z() stays stale.
const bool entityIsMoving = entity->isEntityMoving();
// Use isActivelyMoving() (not isEntityMoving()) so the
// Run/Walk animation stops when the creature reaches its
// destination, rather than persisting through the dead-
// reckoning overrun window.
const bool entityIsMoving = entity->isActivelyMoving();
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
@ -1716,6 +1720,110 @@ void Application::update(float deltaTime) {
}
}
// --- Online player render sync (position, orientation, animation) ---
// Mirrors the creature sync loop above but without collision guard or
// weapon-attach logic. Without this, online players never transition
// back to Stand after movement stops ("run in place" bug).
auto playerSyncStart = std::chrono::steady_clock::now();
if (renderer && gameHandler && renderer->getCharacterRenderer()) {
auto* charRenderer = renderer->getCharacterRenderer();
glm::vec3 pPos(0.0f);
bool havePPos = false;
if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) {
pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ());
havePPos = true;
}
const float pSyncRadiusSq = 320.0f * 320.0f;
for (const auto& [guid, instanceId] : playerInstances_) {
auto entity = gameHandler->getEntityManager().getEntity(guid);
if (!entity || entity->getType() != game::ObjectType::PLAYER) continue;
// Distance cull
if (havePPos) {
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
glm::vec3 d = latestCanonical - pPos;
if (glm::dot(d, d) > pSyncRadiusSq) continue;
}
// Position sync
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
auto posIt = creatureRenderPosCache_.find(guid);
if (posIt == creatureRenderPosCache_.end()) {
charRenderer->setInstancePosition(instanceId, renderPos);
creatureRenderPosCache_[guid] = renderPos;
} else {
const glm::vec3 prevPos = posIt->second;
const glm::vec2 delta2(renderPos.x - prevPos.x, renderPos.y - prevPos.y);
float planarDist = glm::length(delta2);
float dz = std::abs(renderPos.z - prevPos.z);
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 entityIsMoving = entity->isActivelyMoving();
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
} else if (planarDist > 0.03f || dz > 0.08f) {
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
}
posIt->second = renderPos;
// Drive movement animation (same logic as creatures)
const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0;
const bool isWalkingNow = creatureWalkingState_.count(guid) > 0;
const bool isFlyingNow = creatureFlyingState_.count(guid) > 0;
bool prevMoving = creatureWasMoving_[guid];
bool prevSwimming = creatureWasSwimming_[guid];
bool prevFlying = creatureWasFlying_[guid];
bool prevWalking = creatureWasWalking_[guid];
const bool stateChanged = (isMovingNow != prevMoving) ||
(isSwimmingNow != prevSwimming) ||
(isFlyingNow != prevFlying) ||
(isWalkingNow != prevWalking && isMovingNow);
if (stateChanged) {
creatureWasMoving_[guid] = isMovingNow;
creatureWasSwimming_[guid] = isSwimmingNow;
creatureWasFlying_[guid] = isFlyingNow;
creatureWasWalking_[guid] = isWalkingNow;
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) {
if (isFlyingNow) targetAnim = 159u; // FlyForward
else if (isSwimmingNow) targetAnim = 42u; // Swim
else if (isWalkingNow) targetAnim = 4u; // Walk
else targetAnim = 5u; // Run
} else {
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
else targetAnim = 0u; // Stand
}
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
}
}
}
// Orientation sync
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
}
}
{
float psMs = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - playerSyncStart).count();
if (psMs > 5.0f) {
LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (",
playerInstances_.size(), " players)");
}
}
// Movement heartbeat is sent from GameHandler::update() to avoid
// duplicate packets from multiple update loops.
@ -1990,7 +2098,7 @@ void Application::setupUICallbacks() {
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
renderer->getTerrainManager()->processAllReadyTiles();
renderer->getTerrainManager()->processReadyTiles();
{
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> nearbyTiles;
@ -2023,10 +2131,12 @@ void Application::setupUICallbacks() {
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(0.5f);
}
// Flush any tiles that finished background parsing during the cast
// (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before
// the first frame at the new position.
renderer->getTerrainManager()->processAllReadyTiles();
// Kick off async upload for any tiles that finished background
// parsing. Use the bounded processReadyTiles() instead of
// processAllReadyTiles() to avoid multi-second main-thread stalls
// when many tiles are ready (the rest will finalize over subsequent
// frames via the normal terrain update loop).
renderer->getTerrainManager()->processReadyTiles();
// Queue all remaining tiles within the load radius (8 tiles = 17x17)
// at the new position. precacheTiles skips already-loaded/pending tiles,
@ -2889,29 +2999,50 @@ void Application::setupUICallbacks() {
}
});
// NPC death callback (online mode) - play death animation
// NPC/player death callback (online mode) - play death animation
gameHandler->setNpcDeathCallback([this](uint64_t guid) {
deadCreatureGuids_.insert(guid);
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death
if (it != creatureInstances_.end()) instanceId = it->second;
else {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death
}
});
// NPC respawn callback (online mode) - reset to idle animation
// NPC/player respawn callback (online mode) - reset to idle animation
gameHandler->setNpcRespawnCallback([this](uint64_t guid) {
deadCreatureGuids_.erase(guid);
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->playAnimation(it->second, 0, true); // Idle
if (it != creatureInstances_.end()) instanceId = it->second;
else {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle
}
});
// NPC swing callback (online mode) - play attack animation
// NPC/player swing callback (online mode) - play attack animation
gameHandler->setNpcSwingCallback([this](uint64_t guid) {
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) {
renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack
if (it != creatureInstances_.end()) instanceId = it->second;
else {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0) {
renderer->getCharacterRenderer()->playAnimation(instanceId, 16, false); // Attack
}
});
@ -4723,24 +4854,42 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) {
app->deadCreatureGuids_.insert(guid);
uint32_t instanceId = 0;
auto it = app->creatureInstances_.find(guid);
if (it != app->creatureInstances_.end() && cr) {
cr->playAnimation(it->second, 1, false); // animation ID 1 = Death
if (it != app->creatureInstances_.end()) instanceId = it->second;
else {
auto pit = app->playerInstances_.find(guid);
if (pit != app->playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0 && cr) {
cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death
}
});
gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) {
app->deadCreatureGuids_.erase(guid);
uint32_t instanceId = 0;
auto it = app->creatureInstances_.find(guid);
if (it != app->creatureInstances_.end() && cr) {
cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle
if (it != app->creatureInstances_.end()) instanceId = it->second;
else {
auto pit = app->playerInstances_.find(guid);
if (pit != app->playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0 && cr) {
cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle
}
});
gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) {
uint32_t instanceId = 0;
auto it = app->creatureInstances_.find(guid);
if (it != app->creatureInstances_.end() && cr) {
cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1
if (it != app->creatureInstances_.end()) instanceId = it->second;
else {
auto pit = app->playerInstances_.find(guid);
if (pit != app->playerInstances_.end()) instanceId = pit->second;
}
if (instanceId != 0 && cr) {
cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1
}
});
}
@ -7044,6 +7193,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) {
playerInstances_.erase(it);
onlinePlayerAppearance_.erase(guid);
pendingOnlinePlayerEquipment_.erase(guid);
creatureRenderPosCache_.erase(guid);
creatureSwimmingState_.erase(guid);
creatureWalkingState_.erase(guid);
creatureFlyingState_.erase(guid);

View file

@ -759,6 +759,8 @@ void GameHandler::disconnect() {
activeCharacterGuid_ = 0;
playerNameCache.clear();
pendingNameQueries.clear();
guildNameCache_.clear();
pendingGuildNameQueries_.clear();
friendGuids_.clear();
contacts_.clear();
transportAttachments_.clear();
@ -2342,7 +2344,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint32_t randProp =*/ packet.readUInt32();
}
uint32_t countdown = packet.readUInt32();
/*uint8_t voteMask =*/ packet.readUInt8();
uint8_t voteMask = packet.readUInt8();
// Trigger the roll popup for local player
pendingLootRollActive_ = true;
pendingLootRoll_.objectGuid = objectGuid;
@ -2356,9 +2358,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId);
pendingLootRoll_.itemQuality = info ? static_cast<uint8_t>(info->quality) : 0;
pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000;
pendingLootRoll_.voteMask = voteMask;
pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now();
LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName,
") slot=", slot);
") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec);
break;
}
@ -2678,8 +2681,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
case Opcode::SMSG_ENABLE_BARBER_SHOP:
// Sent by server when player sits in barber chair — triggers barber shop UI
// No payload; we don't have barber shop UI yet, so just log
LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available");
barberShopOpen_ = true;
break;
case Opcode::SMSG_FEIGN_DEATH_RESISTED:
addUIError("Your Feign Death was resisted.");
@ -2949,6 +2952,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
struct SpellMissLogEntry {
uint64_t victimGuid = 0;
uint8_t missInfo = 0;
uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT)
};
std::vector<SpellMissLogEntry> parsedMisses;
parsedMisses.reserve(storedLimit);
@ -2967,17 +2971,18 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
const uint8_t missInfo = packet.readUInt8();
// REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult
uint32_t reflectSpellId = 0;
if (missInfo == 11) {
if (packet.getSize() - packet.getReadPos() >= 5) {
/*uint32_t reflectSpellId =*/ packet.readUInt32();
/*uint8_t reflectResult =*/ packet.readUInt8();
reflectSpellId = packet.readUInt32();
/*uint8_t reflectResult =*/ packet.readUInt8();
} else {
truncated = true;
break;
}
}
if (i < storedLimit) {
parsedMisses.push_back({victimGuid, missInfo});
parsedMisses.push_back({victimGuid, missInfo, reflectSpellId});
}
}
@ -2990,12 +2995,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
const uint64_t victimGuid = miss.victimGuid;
const uint8_t missInfo = miss.missInfo;
CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo);
// For REFLECT, use the reflected spell ID so combat text shows the spell name
uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0)
? miss.reflectSpellId : spellId;
if (casterGuid == playerGuid) {
// We cast a spell and it missed the target
addCombatText(ct, 0, spellId, true, 0, casterGuid, victimGuid);
addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid);
} else if (victimGuid == playerGuid) {
// Enemy spell missed us (we dodged/parried/blocked/resisted/etc.)
addCombatText(ct, 0, spellId, false, 0, casterGuid, victimGuid);
addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid);
}
}
break;
@ -4250,17 +4258,20 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint8_t auraType = packet.readUInt8();
if (auraType == 3 || auraType == 89) {
// Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes
// WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4) = 20 bytes
// WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes
const bool periodicWotlk = isActiveExpansion("wotlk");
const size_t dotSz = periodicWotlk ? 20u : 16u;
const size_t dotSz = periodicWotlk ? 21u : 16u;
if (packet.getSize() - packet.getReadPos() < dotSz) break;
uint32_t dmg = packet.readUInt32();
if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32();
/*uint32_t school=*/ packet.readUInt32();
uint32_t abs = packet.readUInt32();
uint32_t res = packet.readUInt32();
bool dotCrit = false;
if (periodicWotlk) dotCrit = (packet.readUInt8() != 0);
if (dmg > 0)
addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast<int32_t>(dmg),
addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE,
static_cast<int32_t>(dmg),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (abs > 0)
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(abs),
@ -4278,11 +4289,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint32_t max=*/ packet.readUInt32();
/*uint32_t over=*/ packet.readUInt32();
uint32_t hotAbs = 0;
bool hotCrit = false;
if (healWotlk) {
hotAbs = packet.readUInt32();
/*uint8_t isCrit=*/ packet.readUInt8();
hotCrit = (packet.readUInt8() != 0);
}
addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast<int32_t>(heal),
addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL,
static_cast<int32_t>(heal),
spellId, isPlayerCaster, 0, casterGuid, victimGuid);
if (hotAbs > 0)
addCombatText(CombatTextEntry::ABSORB, static_cast<int32_t>(hotAbs),
@ -4889,6 +4902,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t result = packet.readUInt32();
if (result == 0) {
addSystemChatMessage("Hairstyle changed.");
barberShopOpen_ = false;
} else {
const char* msg = (result == 1) ? "Not enough money for new hairstyle."
: (result == 2) ? "You are not at a barber shop."
@ -5792,7 +5806,31 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_LFG_OFFER_CONTINUE:
addSystemChatMessage("Dungeon Finder: You may continue your dungeon.");
break;
case Opcode::SMSG_LFG_ROLE_CHOSEN:
case Opcode::SMSG_LFG_ROLE_CHOSEN: {
// uint64 guid + uint8 ready + uint32 roles
if (packet.getSize() - packet.getReadPos() >= 13) {
uint64_t roleGuid = packet.readUInt64();
uint8_t ready = packet.readUInt8();
uint32_t roles = packet.readUInt32();
// Build a descriptive message for group chat
std::string roleName;
if (roles & 0x02) roleName += "Tank ";
if (roles & 0x04) roleName += "Healer ";
if (roles & 0x08) roleName += "DPS ";
if (roleName.empty()) roleName = "None";
// Find player name
std::string pName = "A player";
if (auto e = entityManager.getEntity(roleGuid))
if (auto u = std::dynamic_pointer_cast<Unit>(e))
pName = u->getName();
if (ready)
addSystemChatMessage(pName + " has chosen: " + roleName);
LOG_DEBUG("SMSG_LFG_ROLE_CHOSEN: guid=", roleGuid,
" ready=", (int)ready, " roles=", roles);
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_LFG_UPDATE_SEARCH:
case Opcode::SMSG_UPDATE_LFG_LIST:
case Opcode::SMSG_LFG_PLAYER_INFO:
@ -7616,9 +7654,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_PETITION_QUERY_RESPONSE:
handlePetitionQueryResponse(packet);
break;
case Opcode::SMSG_PETITION_SHOW_SIGNATURES:
handlePetitionShowSignatures(packet);
break;
case Opcode::SMSG_PETITION_SIGN_RESULTS:
packet.setReadPos(packet.getSize());
handlePetitionSignResults(packet);
break;
// ---- Pet system ----
@ -7672,13 +7714,17 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_PET_CAST_FAILED: {
if (packet.getSize() - packet.getReadPos() >= 5) {
uint8_t castCount = packet.readUInt8();
// WotLK: castCount(1) + spellId(4) + reason(1)
// Classic/TBC: spellId(4) + reason(1) (no castCount)
const bool hasCount = isActiveExpansion("wotlk");
const size_t minSize = hasCount ? 6u : 5u;
if (packet.getSize() - packet.getReadPos() >= minSize) {
if (hasCount) /*uint8_t castCount =*/ packet.readUInt8();
uint32_t spellId = packet.readUInt32();
uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1)
? packet.readUInt8() : 0;
LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId,
" reason=", (int)reason, " castCount=", (int)castCount);
" reason=", (int)reason);
if (reason != 0) {
const char* reasonStr = getSpellCastResultString(reason);
const std::string& sName = getSpellName(spellId);
@ -11293,10 +11339,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
// Track player-on-transport state
if (block.guid == playerGuid) {
if (block.onTransport) {
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
setPlayerOnTransport(block.transportGuid, canonicalOffset);
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
@ -11562,6 +11608,9 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
" displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render");
}
}
if (unitInitiallyDead && npcDeathCallback_) {
npcDeathCallback_(block.guid);
}
} else if (creatureSpawnCallback_) {
LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec,
" displayId=", unit->getDisplayId(), " at (",
@ -11908,7 +11957,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
corpseX_, ",", corpseY_, ",", corpseZ_,
") map=", corpseMapId_);
}
if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) {
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
@ -11921,7 +11970,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
LOG_INFO("Player entered ghost form");
}
}
if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) {
if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) {
npcRespawnCallback_(block.guid);
npcRespawnNotified = true;
}
@ -11952,7 +12001,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
selfResAvailable_ = false;
LOG_INFO("Player resurrected (dynamic flags)");
}
} else if (entity->getType() == ObjectType::UNIT) {
} else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) {
bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0;
bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0;
if (!wasDead && nowDead) {
@ -12088,6 +12137,12 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
" displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render");
}
}
bool isDeadNow = (unit->getHealth() == 0) ||
((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0);
if (isDeadNow && !npcDeathNotified && npcDeathCallback_) {
npcDeathCallback_(block.guid);
npcDeathNotified = true;
}
} else if (creatureSpawnCallback_) {
float unitScale2 = 1.0f;
{
@ -12154,6 +12209,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES);
const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2);
const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE);
const uint16_t ufStatsV[5] = {
@ -12204,15 +12260,38 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) {
playerResistances_[key - ufArmor - 1] = static_cast<int32_t>(val);
}
else if (ufPBytesV != 0xFFFF && key == ufPBytesV) {
// PLAYER_BYTES changed (barber shop, polymorph, etc.)
// Update the Character struct so inventory preview refreshes
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.appearanceBytes = val;
break;
}
}
if (appearanceChangedCallback_)
appearanceChangedCallback_();
}
else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) {
// Byte 0 (bits 0-7): facial hair / piercings
uint8_t facialHair = static_cast<uint8_t>(val & 0xFF);
for (auto& ch : characters) {
if (ch.guid == playerGuid) {
ch.facialFeatures = facialHair;
break;
}
}
uint8_t bankBagSlots = static_cast<uint8_t>((val >> 16) & 0xFF);
LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots));
LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots),
" facial=", static_cast<int>(facialHair));
inventory.setPurchasedBankBagSlots(bankBagSlots);
// Byte 3 (bits 24-31): REST_STATE
// 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY
uint8_t restStateByte = static_cast<uint8_t>((val >> 24) & 0xFF);
isResting_ = (restStateByte != 0);
if (appearanceChangedCallback_)
appearanceChangedCallback_();
}
else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) {
chosenTitleBit_ = static_cast<int32_t>(val);
@ -12430,10 +12509,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
// Track player-on-transport state from MOVEMENT updates
if (block.onTransport) {
setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f));
// Convert transport offset from server → canonical coordinates
glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ);
playerTransportOffset_ = core::coords::serverToCanonical(serverOffset);
glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset);
setPlayerOnTransport(block.transportGuid, canonicalOffset);
if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) {
glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_);
entity->setPosition(composed.x, composed.y, composed.z, oCanonical);
@ -12737,6 +12816,15 @@ void GameHandler::handleMessageChat(network::Packet& packet) {
// Track whisper sender for /r command
if (data.type == ChatType::WHISPER && !data.senderName.empty()) {
lastWhisperSender_ = data.senderName;
// Auto-reply if AFK or DND
if (afkStatus_ && !data.senderName.empty()) {
std::string reply = afkMessage_.empty() ? "Away from Keyboard" : afkMessage_;
sendChatMessage(ChatType::WHISPER, "<AFK> " + reply, data.senderName);
} else if (dndStatus_ && !data.senderName.empty()) {
std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_;
sendChatMessage(ChatType::WHISPER, "<DND> " + reply, data.senderName);
}
}
// Trigger chat bubble for SAY/YELL messages from others
@ -15544,6 +15632,12 @@ void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint
entry.age = 0.0f;
entry.isPlayerSource = isPlayerSource;
entry.powerType = powerType;
entry.srcGuid = srcGuid;
entry.dstGuid = dstGuid;
// Random horizontal stagger so simultaneous hits don't stack vertically
static std::mt19937 rng(std::random_device{}());
std::uniform_real_distribution<float> dist(-1.0f, 1.0f);
entry.xSeed = dist(rng);
combatText.push_back(entry);
// Persistent combat log — use explicit GUIDs if provided, else fall back to
@ -16077,18 +16171,21 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
uint32_t statusId = packet.readUInt32();
// Map BG type IDs to their names (stable across all three expansions)
// BattlemasterList.dbc IDs (3.3.5a)
static const std::pair<uint32_t, const char*> kBgNames[] = {
{1, "Alterac Valley"},
{2, "Warsong Gulch"},
{3, "Arathi Basin"},
{6, "Eye of the Storm"},
{4, "Nagrand Arena"},
{5, "Blade's Edge Arena"},
{6, "All Arenas"},
{7, "Eye of the Storm"},
{8, "Ruins of Lordaeron"},
{9, "Strand of the Ancients"},
{11, "Isle of Conquest"},
{30, "Nagrand Arena"},
{31, "Blade's Edge Arena"},
{32, "Dalaran Sewers"},
{33, "Ring of Valor"},
{34, "Ruins of Lordaeron"},
{10, "Dalaran Sewers"},
{11, "Ring of Valor"},
{30, "Isle of Conquest"},
{32, "Random Battleground"},
};
std::string bgName = "Battleground";
for (const auto& kv : kBgNames) {
@ -16139,6 +16236,7 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) {
bgQueues_[queueSlot].bgTypeId = bgTypeId;
bgQueues_[queueSlot].arenaType = arenaType;
bgQueues_[queueSlot].statusId = statusId;
bgQueues_[queueSlot].bgName = bgName;
if (statusId == 1) {
bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec;
bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec;
@ -16392,6 +16490,7 @@ void GameHandler::handleInstanceDifficulty(network::Packet& packet) {
} else {
instanceIsHeroic_ = (instanceDifficulty_ == 1);
}
inInstance_ = true;
LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_);
// Announce difficulty change to the player (only when it actually changes)
@ -16933,7 +17032,25 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t teamId = packet.readUInt32();
std::string teamName = packet.readString();
LOG_INFO("Arena team query response: id=", teamId, " name=", teamName);
uint32_t teamType = 0;
if (packet.getSize() - packet.getReadPos() >= 4)
teamType = packet.readUInt32();
LOG_INFO("Arena team query response: id=", teamId, " name=", teamName, " type=", teamType);
// Store name and type in matching ArenaTeamStats entry
for (auto& s : arenaTeamStats_) {
if (s.teamId == teamId) {
s.teamName = teamName;
s.teamType = teamType;
return;
}
}
// No stats entry yet — create a placeholder so we can show the name
ArenaTeamStats stub;
stub.teamId = teamId;
stub.teamName = teamName;
stub.teamType = teamType;
arenaTeamStats_.push_back(std::move(stub));
}
void GameHandler::handleArenaTeamRoster(network::Packet& packet) {
@ -17077,18 +17194,29 @@ void GameHandler::handleArenaTeamStats(network::Packet& packet) {
stats.seasonWins = packet.readUInt32();
stats.rank = packet.readUInt32();
// Update or insert for this team
// Update or insert for this team (preserve name/type from query response)
for (auto& s : arenaTeamStats_) {
if (s.teamId == stats.teamId) {
s = stats;
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
" rating=", stats.rating, " rank=", stats.rank);
stats.teamName = std::move(s.teamName);
stats.teamType = s.teamType;
s = std::move(stats);
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", s.teamId,
" rating=", s.rating, " rank=", s.rank);
return;
}
}
arenaTeamStats_.push_back(stats);
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", stats.teamId,
" rating=", stats.rating, " rank=", stats.rank);
arenaTeamStats_.push_back(std::move(stats));
LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", arenaTeamStats_.back().teamId,
" rating=", arenaTeamStats_.back().rating,
" rank=", arenaTeamStats_.back().rank);
}
void GameHandler::requestArenaTeamRoster(uint32_t teamId) {
if (!socket) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_ARENA_TEAM_ROSTER));
pkt.writeUInt32(teamId);
socket->send(pkt);
LOG_INFO("Requesting arena team roster for teamId=", teamId);
}
void GameHandler::handleArenaError(network::Packet& packet) {
@ -18505,6 +18633,12 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
msg.language = ChatLanguage::UNIVERSAL;
msg.message = errMsg;
addLocalChatMessage(msg);
// Play error sound for cast failure feedback
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playError();
}
}
static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) {
@ -19137,6 +19271,13 @@ void GameHandler::confirmTalentWipe() {
talentWipeCost_ = 0;
}
void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) {
if (state != WorldState::IN_WORLD || !socket) return;
auto pkt = AlterAppearancePacket::build(hairStyle, hairColor, facialHair);
socket->send(pkt);
LOG_INFO("sendAlterAppearance: hair=", hairStyle, " color=", hairColor, " facial=", facialHair);
}
// ============================================================
// Phase 4: Group/Party
// ============================================================
@ -19563,6 +19704,28 @@ void GameHandler::queryGuildInfo(uint32_t guildId) {
LOG_INFO("Querying guild info: guildId=", guildId);
}
static const std::string kEmptyString;
const std::string& GameHandler::lookupGuildName(uint32_t guildId) {
if (guildId == 0) return kEmptyString;
auto it = guildNameCache_.find(guildId);
if (it != guildNameCache_.end()) return it->second;
// Query the server if we haven't already
if (pendingGuildNameQueries_.insert(guildId).second) {
queryGuildInfo(guildId);
}
return kEmptyString;
}
uint32_t GameHandler::getEntityGuildId(uint64_t guid) const {
auto entity = entityManager.getEntity(guid);
if (!entity || entity->getType() != ObjectType::PLAYER) return 0;
// PLAYER_GUILDID = UNIT_END + 3 across all expansions
const uint16_t ufUnitEnd = fieldIndex(UF::UNIT_END);
if (ufUnitEnd == 0xFFFF) return 0;
return entity->getField(ufUnitEnd + 3);
}
void GameHandler::createGuild(const std::string& guildName) {
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = GuildCreatePacket::build(guildName);
@ -19611,6 +19774,118 @@ void GameHandler::handlePetitionShowlist(network::Packet& packet) {
LOG_INFO("Petition showlist: cost=", data.cost);
}
void GameHandler::handlePetitionQueryResponse(network::Packet& packet) {
// SMSG_PETITION_QUERY_RESPONSE (3.3.5a):
// uint32 petitionEntry, uint64 petitionGuid, string guildName,
// string bodyText (empty), uint32 flags, uint32 minSignatures,
// uint32 maxSignatures, ...plus more fields we can skip
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 12) return;
/*uint32_t entry =*/ packet.readUInt32();
uint64_t petGuid = packet.readUInt64();
std::string guildName = packet.readString();
/*std::string body =*/ packet.readString();
// Update petition info if it matches our current petition
if (petitionInfo_.petitionGuid == petGuid) {
petitionInfo_.guildName = guildName;
}
LOG_INFO("SMSG_PETITION_QUERY_RESPONSE: guid=", petGuid, " name=", guildName);
packet.setReadPos(packet.getSize()); // skip remaining fields
}
void GameHandler::handlePetitionShowSignatures(network::Packet& packet) {
// SMSG_PETITION_SHOW_SIGNATURES (3.3.5a):
// uint64 itemGuid (petition item in inventory)
// uint64 ownerGuid
// uint32 petitionGuid (low part / entry)
// uint8 signatureCount
// For each signature:
// uint64 playerGuid
// uint32 unk (always 0)
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 21) return;
petitionInfo_ = PetitionInfo{};
petitionInfo_.petitionGuid = packet.readUInt64();
petitionInfo_.ownerGuid = packet.readUInt64();
/*uint32_t petEntry =*/ packet.readUInt32();
uint8_t sigCount = packet.readUInt8();
petitionInfo_.signatureCount = sigCount;
petitionInfo_.signatures.reserve(sigCount);
for (uint8_t i = 0; i < sigCount; ++i) {
if (rem() < 12) break;
PetitionSignature sig;
sig.playerGuid = packet.readUInt64();
/*uint32_t unk =*/ packet.readUInt32();
petitionInfo_.signatures.push_back(sig);
}
petitionInfo_.showUI = true;
LOG_INFO("SMSG_PETITION_SHOW_SIGNATURES: petGuid=", petitionInfo_.petitionGuid,
" owner=", petitionInfo_.ownerGuid,
" sigs=", sigCount);
}
void GameHandler::handlePetitionSignResults(network::Packet& packet) {
// SMSG_PETITION_SIGN_RESULTS (3.3.5a):
// uint64 petitionGuid, uint64 playerGuid, uint32 result
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 20) return;
uint64_t petGuid = packet.readUInt64();
uint64_t playerGuid = packet.readUInt64();
uint32_t result = packet.readUInt32();
switch (result) {
case 0: // PETITION_SIGN_OK
addSystemChatMessage("Petition signed successfully.");
// Increment local count
if (petitionInfo_.petitionGuid == petGuid) {
petitionInfo_.signatureCount++;
PetitionSignature sig;
sig.playerGuid = playerGuid;
petitionInfo_.signatures.push_back(sig);
}
break;
case 1: // PETITION_SIGN_ALREADY_SIGNED
addSystemChatMessage("You have already signed that petition.");
break;
case 2: // PETITION_SIGN_ALREADY_IN_GUILD
addSystemChatMessage("You are already in a guild.");
break;
case 3: // PETITION_SIGN_CANT_SIGN_OWN
addSystemChatMessage("You cannot sign your own petition.");
break;
default:
addSystemChatMessage("Cannot sign petition (error " + std::to_string(result) + ").");
break;
}
LOG_INFO("SMSG_PETITION_SIGN_RESULTS: pet=", petGuid, " player=", playerGuid,
" result=", result);
}
void GameHandler::signPetition(uint64_t petitionGuid) {
if (!socket || state != WorldState::IN_WORLD) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_PETITION_SIGN));
pkt.writeUInt64(petitionGuid);
pkt.writeUInt8(0); // unk
socket->send(pkt);
LOG_INFO("Signing petition: ", petitionGuid);
}
void GameHandler::turnInPetition(uint64_t petitionGuid) {
if (!socket || state != WorldState::IN_WORLD) return;
network::Packet pkt(wireOpcode(Opcode::CMSG_TURN_IN_PETITION));
pkt.writeUInt64(petitionGuid);
socket->send(pkt);
LOG_INFO("Turning in petition: ", petitionGuid);
}
void GameHandler::handleTurnInPetitionResults(network::Packet& packet) {
uint32_t result = 0;
if (!TurnInPetitionResultsParser::parse(packet, result)) return;
@ -19647,18 +19922,30 @@ void GameHandler::handleGuildQueryResponse(network::Packet& packet) {
GuildQueryResponseData data;
if (!packetParsers_->parseGuildQueryResponse(packet, data)) return;
const bool wasUnknown = guildName_.empty();
guildName_ = data.guildName;
guildQueryData_ = data;
guildRankNames_.clear();
for (uint32_t i = 0; i < 10; ++i) {
guildRankNames_.push_back(data.rankNames[i]);
// Always cache the guild name for nameplate lookups
if (data.guildId != 0 && !data.guildName.empty()) {
guildNameCache_[data.guildId] = data.guildName;
pendingGuildNameQueries_.erase(data.guildId);
}
// Check if this is the local player's guild
const Character* ch = getActiveCharacter();
bool isLocalGuild = (ch && ch->hasGuild() && ch->guildId == data.guildId);
if (isLocalGuild) {
const bool wasUnknown = guildName_.empty();
guildName_ = data.guildName;
guildQueryData_ = data;
guildRankNames_.clear();
for (uint32_t i = 0; i < 10; ++i) {
guildRankNames_.push_back(data.rankNames[i]);
}
LOG_INFO("Guild name set to: ", guildName_);
if (wasUnknown && !guildName_.empty())
addSystemChatMessage("Guild: <" + guildName_ + ">");
} else {
LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName);
}
LOG_INFO("Guild name set to: ", guildName_);
// Only announce once — when we first learn our own guild name at login.
// Subsequent queries (e.g. querying other players' guilds) are silent.
if (wasUnknown && !guildName_.empty())
addSystemChatMessage("Guild: <" + guildName_ + ">");
}
void GameHandler::handleGuildEvent(network::Packet& packet) {
@ -19932,6 +20219,17 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
addSystemChatMessage("Too far away.");
return;
}
// Stop movement before interacting — servers may reject GO use or
// immediately cancel the resulting spell cast if the player is moving.
const uint32_t moveFlags = movementInfo.flags;
const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD
(moveFlags & 0x00000002u) || // BACKWARD
(moveFlags & 0x00000004u) || // STRAFE_LEFT
(moveFlags & 0x00000008u); // STRAFE_RIGHT
if (isMoving) {
movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags
sendMovement(Opcode::MSG_MOVE_STOP);
}
if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) {
movementInfo.orientation = std::atan2(-dy, dx);
sendMovement(Opcode::MSG_MOVE_SET_FACING);
@ -19939,6 +20237,12 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
sendMovement(Opcode::MSG_MOVE_HEARTBEAT);
}
LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec,
" entry=", goEntry, " type=", goType,
" name='", goName, "' dist=", entity ? std::sqrt(
(entity->getX() - movementInfo.x) * (entity->getX() - movementInfo.x) +
(entity->getY() - movementInfo.y) * (entity->getY() - movementInfo.y) +
(entity->getZ() - movementInfo.z) * (entity->getZ() - movementInfo.z)) : -1.0f);
auto packet = GameObjectUsePacket::build(guid);
socket->send(packet);
lastInteractedGoGuid_ = guid;
@ -21083,6 +21387,40 @@ void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) {
socket->send(packet);
}
void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) {
if (state != WorldState::IN_WORLD || !socket) return;
if (count == 0) return;
// Find a free slot for the split destination: try backpack first, then bags
int freeBp = inventory.findFreeBackpackSlot();
if (freeBp >= 0) {
uint8_t dstBag = 0xFF;
uint8_t dstSlot = static_cast<uint8_t>(23 + freeBp);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
socket->send(packet);
return;
}
// Try equipped bags
for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) {
int bagSize = inventory.getBagSize(b);
for (int s = 0; s < bagSize; s++) {
if (inventory.getBagSlot(b, s).empty()) {
uint8_t dstBag = static_cast<uint8_t>(19 + b);
uint8_t dstSlot = static_cast<uint8_t>(s);
LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot,
") count=", (int)count, " -> dst(bag=", (int)dstBag,
" slot=", (int)dstSlot, ")");
auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count);
socket->send(packet);
return;
}
}
}
addSystemChatMessage("Cannot split: no free inventory slots.");
}
void GameHandler::useItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
const auto& slot = inventory.getBackpackSlot(backpackIndex);
@ -21236,8 +21574,8 @@ void GameHandler::unstuckHearth() {
}
void GameHandler::handleLootResponse(network::Packet& packet) {
// Classic 1.12 and TBC 2.4.3 use 14 bytes/item (no randomSuffix/randomProp fields);
// WotLK 3.3.5a uses 22 bytes/item.
// All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType).
// WotLK adds a quest item list after the regular items.
const bool wotlkLoot = isActiveExpansion("wotlk");
if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return;
const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0;
@ -22222,6 +22560,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
}
currentMapId_ = mapId;
inInstance_ = false; // cleared on map change; re-set if SMSG_INSTANCE_DIFFICULTY follows
if (socket) {
socket->tracePacketsFor(std::chrono::seconds(12), "new_world");
}

View file

@ -189,11 +189,8 @@ uint32_t classicWireMoveFlags(uint32_t internalFlags) {
// Same as TBC: u8 UpdateFlags, JUMPING=0x2000, 8 speeds, no pitchRate
// ============================================================================
bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
// Validate minimum packet size for updateFlags byte
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[Classic] Movement block packet too small (need at least 1 byte for updateFlags)");
return false;
}
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
// Classic: UpdateFlags is uint8 (same as TBC)
uint8_t updateFlags = packet.readUInt8();
@ -209,6 +206,9 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
const uint8_t UPDATEFLAG_HAS_POSITION = 0x40;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes
if (rem() < 52) return false;
// Movement flags (u32 only — NO extra flags byte in Classic)
uint32_t moveFlags = packet.readUInt32();
/*uint32_t time =*/ packet.readUInt32();
@ -225,26 +225,29 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// Transport data (Classic: ONTRANSPORT=0x02000000, no timestamp)
if (moveFlags & ClassicMoveFlags::ONTRANSPORT) {
if (rem() < 1) return false;
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 16) return false; // 4 floats
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
block.transportO = packet.readFloat();
// Classic: NO transport timestamp (TBC adds u32 timestamp)
// Classic: NO transport seat byte
}
// Pitch (Classic: only SWIMMING, no FLYING or ONTRANSPORT pitch)
if (moveFlags & ClassicMoveFlags::SWIMMING) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jumping (Classic: JUMPING=0x2000, same as TBC)
if (moveFlags & ClassicMoveFlags::JUMPING) {
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -253,12 +256,12 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// Spline elevation
if (moveFlags & ClassicMoveFlags::SPLINE_ELEVATION) {
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Speeds (Classic: 6 values — no flight speeds, no pitchRate)
// TBC added flying_speed + backwards_flying_speed (8 total)
// WotLK added pitchRate (9 total)
if (rem() < 24) return false;
/*float walkSpeed =*/ packet.readFloat();
float runSpeed = packet.readFloat();
/*float runBackSpeed =*/ packet.readFloat();
@ -271,34 +274,34 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// Spline data (Classic: SPLINE_ENABLED=0x00400000)
if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) {
if (rem() < 4) return false;
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [Classic] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) { // FINAL_POINT
if (rem() < 12) return false;
/*float finalX =*/ packet.readFloat();
/*float finalY =*/ packet.readFloat();
/*float finalZ =*/ packet.readFloat();
} else if (splineFlags & 0x00020000) { // FINAL_TARGET
if (rem() < 8) return false;
/*uint64_t finalTarget =*/ packet.readUInt64();
} else if (splineFlags & 0x00040000) { // FINAL_ANGLE
if (rem() < 4) return false;
/*float finalAngle =*/ packet.readFloat();
}
// Classic spline: timePassed, duration, id, nodes, finalNode (same as TBC)
// Classic spline: timePassed, duration, id, pointCount
if (rem() < 16) return false;
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
if (pointCount > 256) {
static uint32_t badClassicSplineCount = 0;
++badClassicSplineCount;
if (badClassicSplineCount <= 5 || (badClassicSplineCount % 100) == 0) {
LOG_WARNING(" [Classic] Spline pointCount=", pointCount,
" exceeds max, capping (occurrence=", badClassicSplineCount, ")");
}
pointCount = 0;
}
if (pointCount > 256) return false;
// points + endPoint (no splineMode in Classic)
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
for (uint32_t i = 0; i < pointCount; i++) {
/*float px =*/ packet.readFloat();
/*float py =*/ packet.readFloat();
@ -312,6 +315,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
}
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
if (rem() < 16) return false;
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -323,21 +327,25 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
// High GUID
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t highGuid =*/ packet.readUInt32();
}
// ALL/SELF extra uint32
if (updateFlags & UPDATEFLAG_ALL) {
if (rem() < 4) return false;
/*uint32_t unkAll =*/ packet.readUInt32();
}
// Current melee target as packed guid
if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) {
if (rem() < 1) return false;
/*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
// Transport progress / world time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
@ -1918,6 +1926,9 @@ namespace TurtleMoveFlags {
}
bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
uint8_t updateFlags = packet.readUInt8();
block.updateFlags = static_cast<uint16_t>(updateFlags);
@ -1931,6 +1942,8 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
const uint8_t UPDATEFLAG_HAS_POSITION = 0x40;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes
if (rem() < 52) return false;
size_t livingStart = packet.getReadPos();
uint32_t moveFlags = packet.readUInt32();
@ -1949,8 +1962,10 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// Transport — Classic flag position 0x02000000
if (moveFlags & TurtleMoveFlags::ONTRANSPORT) {
if (rem() < 1) return false; // PackedGuid mask byte
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 20) return false; // 4 floats + u32 timestamp
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
@ -1960,14 +1975,17 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// Pitch (swimming only, Classic-style)
if (moveFlags & TurtleMoveFlags::SWIMMING) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jump data
if (moveFlags & TurtleMoveFlags::JUMPING) {
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -1976,10 +1994,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// Spline elevation
if (moveFlags & TurtleMoveFlags::SPLINE_ELEVATION) {
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Turtle: 6 speeds (same as Classic — no flight speeds)
if (rem() < 24) return false; // 6 × float
float walkSpeed = packet.readFloat();
float runSpeed = packet.readFloat();
float runBackSpeed = packet.readFloat();
@ -1997,17 +2017,23 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) ||
(moveFlags & TurtleMoveFlags::SPLINE_TBC);
if (hasSpline) {
if (rem() < 4) return false;
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [Turtle] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) {
if (rem() < 12) return false;
packet.readFloat(); packet.readFloat(); packet.readFloat();
} else if (splineFlags & 0x00020000) {
if (rem() < 8) return false;
packet.readUInt64();
} else if (splineFlags & 0x00040000) {
if (rem() < 4) return false;
packet.readFloat();
}
// timePassed + duration + splineId + pointCount = 16 bytes
if (rem() < 16) return false;
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
@ -2018,10 +2044,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
++badTurtleSplineCount;
if (badTurtleSplineCount <= 5 || (badTurtleSplineCount % 100) == 0) {
LOG_WARNING(" [Turtle] Spline pointCount=", pointCount,
" exceeds max, capping (occurrence=", badTurtleSplineCount, ")");
" exceeds max (occurrence=", badTurtleSplineCount, ")");
}
pointCount = 0;
return false;
}
// points + endPoint
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
for (uint32_t i = 0; i < pointCount; i++) {
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
@ -2034,6 +2062,7 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
" bytes, readPos now=", packet.getReadPos());
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
if (rem() < 16) return false;
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -2045,18 +2074,22 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
// High GUID — 1×u32
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t highGuid =*/ packet.readUInt32();
}
if (updateFlags & UPDATEFLAG_ALL) {
if (rem() < 4) return false;
/*uint32_t unkAll =*/ packet.readUInt32();
}
if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) {
if (rem() < 1) return false;
/*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
@ -2185,12 +2218,10 @@ bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjec
return this->TbcPacketParsers::parseMovementBlock(p, b);
}, "tbc");
}
if (!ok) {
ok = parseMovementVariant(
[](network::Packet& p, UpdateBlock& b) {
return UpdateObjectParser::parseMovementBlock(p, b);
}, "wotlk");
}
// NOTE: Do NOT fall back to WotLK parseMovementBlock here.
// WotLK uses uint16 updateFlags and 9 speeds vs Classic's uint8
// and 6 speeds. A false-positive WotLK parse consumes wrong bytes,
// corrupting subsequent update fields and losing NPC data.
break;
case UpdateType::OUT_OF_RANGE_OBJECTS:
case UpdateType::NEAR_OBJECTS:

View file

@ -30,11 +30,8 @@ namespace TbcMoveFlags {
// - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32)
// ============================================================================
bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) {
// Validate minimum packet size for updateFlags byte
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[TBC] Movement block packet too small (need at least 1 byte for updateFlags)");
return false;
}
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
// TBC 2.4.3: UpdateFlags is uint8 (1 byte)
uint8_t updateFlags = packet.readUInt8();
@ -58,6 +55,9 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
const uint8_t UPDATEFLAG_HIGHGUID = 0x10;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+moveFlags2(1)+time(4)+position(16)+fallTime(4)+speeds(32) = 61
if (rem() < 61) return false;
// Full movement block for living units
uint32_t moveFlags = packet.readUInt32();
uint8_t moveFlags2 = packet.readUInt8(); // TBC: uint8, not uint16
@ -76,29 +76,33 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
// Transport data
if (moveFlags & TbcMoveFlags::ON_TRANSPORT) {
if (rem() < 1) return false;
block.onTransport = true;
block.transportGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 20) return false; // 4 floats + 1 uint32
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
block.transportO = packet.readFloat();
/*uint32_t tTime =*/ packet.readUInt32();
// TBC: NO transport seat byte
// TBC: NO interpolated movement check
}
// Pitch: SWIMMING, or else ONTRANSPORT (TBC-specific secondary pitch)
if (moveFlags & TbcMoveFlags::SWIMMING) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
} else if (moveFlags & TbcMoveFlags::ONTRANSPORT) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time (always present)
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jumping (TBC: JUMPING=0x2000, WotLK: FALLING=0x1000)
if (moveFlags & TbcMoveFlags::JUMPING) {
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -107,11 +111,12 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
// Spline elevation (TBC: 0x02000000, WotLK: 0x04000000)
if (moveFlags & TbcMoveFlags::SPLINE_ELEVATION) {
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Speeds (TBC: 8 values — walk, run, runBack, swim, fly, flyBack, swimBack, turn)
// WotLK adds pitchRate (9 total)
if (rem() < 32) return false;
/*float walkSpeed =*/ packet.readFloat();
float runSpeed = packet.readFloat();
/*float runBackSpeed =*/ packet.readFloat();
@ -126,49 +131,47 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
// Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000)
if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) {
if (rem() < 4) return false;
uint32_t splineFlags = packet.readUInt32();
LOG_DEBUG(" [TBC] Spline: flags=0x", std::hex, splineFlags, std::dec);
if (splineFlags & 0x00010000) { // FINAL_POINT
if (rem() < 12) return false;
/*float finalX =*/ packet.readFloat();
/*float finalY =*/ packet.readFloat();
/*float finalZ =*/ packet.readFloat();
} else if (splineFlags & 0x00020000) { // FINAL_TARGET
if (rem() < 8) return false;
/*uint64_t finalTarget =*/ packet.readUInt64();
} else if (splineFlags & 0x00040000) { // FINAL_ANGLE
if (rem() < 4) return false;
/*float finalAngle =*/ packet.readFloat();
}
// TBC spline: timePassed, duration, id, nodes, finalNode
// (no durationMod, durationModNext, verticalAccel, effectStartTime, splineMode)
// TBC spline: timePassed, duration, id, pointCount
if (rem() < 16) return false;
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
if (pointCount > 256) {
static uint32_t badTbcSplineCount = 0;
++badTbcSplineCount;
if (badTbcSplineCount <= 5 || (badTbcSplineCount % 100) == 0) {
LOG_WARNING(" [TBC] Spline pointCount=", pointCount,
" exceeds max, capping (occurrence=", badTbcSplineCount, ")");
}
pointCount = 0;
}
if (pointCount > 256) return false;
// points + endPoint (no splineMode in TBC)
if (rem() < static_cast<size_t>(pointCount) * 12 + 12) return false;
for (uint32_t i = 0; i < pointCount; i++) {
/*float px =*/ packet.readFloat();
/*float py =*/ packet.readFloat();
/*float pz =*/ packet.readFloat();
}
// TBC: NO splineMode byte (WotLK adds it)
/*float endPointX =*/ packet.readFloat();
/*float endPointY =*/ packet.readFloat();
/*float endPointZ =*/ packet.readFloat();
}
}
else if (updateFlags & UPDATEFLAG_HAS_POSITION) {
// TBC: Simple stationary position (same as WotLK STATIONARY)
if (rem() < 16) return false;
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -177,29 +180,29 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
LOG_DEBUG(" [TBC] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")");
}
// TBC: No UPDATEFLAG_POSITION (0x0100) code path
// Target GUID
if (updateFlags & UPDATEFLAG_HAS_TARGET) {
if (rem() < 1) return false;
/*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
// Transport time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
// TBC: No VEHICLE flag (WotLK 0x0080)
// TBC: No ROTATION flag (WotLK 0x0200)
// HIGH_GUID (0x08) — TBC has 2 u32s, Classic has 1 u32
// LOWGUID (0x08) — TBC has 2 u32s, Classic has 1 u32
if (updateFlags & UPDATEFLAG_LOWGUID) {
if (rem() < 8) return false;
/*uint32_t unknown0 =*/ packet.readUInt32();
/*uint32_t unknown1 =*/ packet.readUInt32();
}
// ALL (0x10)
// HIGHGUID (0x10)
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t unknown2 =*/ packet.readUInt32();
}

View file

@ -913,6 +913,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// 1. UpdateFlags (1 byte, sometimes 2)
// 2. Movement data depends on update flags
auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); };
if (rem() < 2) return false;
// Update flags (3.3.5a uses 2 bytes for flags)
uint16_t updateFlags = packet.readUInt16();
block.updateFlags = updateFlags;
@ -957,6 +960,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
const uint16_t UPDATEFLAG_HIGHGUID = 0x0010;
if (updateFlags & UPDATEFLAG_LIVING) {
// Minimum: moveFlags(4)+moveFlags2(2)+time(4)+position(16)+fallTime(4)+speeds(36) = 66
if (rem() < 66) return false;
// Full movement block for living units
uint32_t moveFlags = packet.readUInt32();
uint16_t moveFlags2 = packet.readUInt16();
@ -974,8 +980,10 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Transport data (if on transport)
if (moveFlags & 0x00000200) { // MOVEMENTFLAG_ONTRANSPORT
if (rem() < 1) return false;
block.onTransport = true;
block.transportGuid = readPackedGuid(packet);
if (rem() < 21) return false; // 4 floats + uint32 + uint8
block.transportX = packet.readFloat();
block.transportY = packet.readFloat();
block.transportZ = packet.readFloat();
@ -987,6 +995,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
if (moveFlags2 & 0x0200) { // MOVEMENTFLAG2_INTERPOLATED_MOVEMENT
if (rem() < 4) return false;
/*uint32_t tTime2 =*/ packet.readUInt32();
}
}
@ -1005,14 +1014,17 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
if ((moveFlags & 0x00200000) /* SWIMMING */ ||
(moveFlags & 0x01000000) /* FLYING */ ||
(moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) {
if (rem() < 4) return false;
/*float pitch =*/ packet.readFloat();
}
// Fall time
if (rem() < 4) return false;
/*uint32_t fallTime =*/ packet.readUInt32();
// Jumping
if (moveFlags & 0x00001000) { // MOVEMENTFLAG_FALLING
if (rem() < 16) return false;
/*float jumpVelocity =*/ packet.readFloat();
/*float jumpSinAngle =*/ packet.readFloat();
/*float jumpCosAngle =*/ packet.readFloat();
@ -1021,10 +1033,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Spline elevation
if (moveFlags & 0x04000000) { // MOVEMENTFLAG_SPLINE_ELEVATION
if (rem() < 4) return false;
/*float splineElevation =*/ packet.readFloat();
}
// Speeds (7 speed values)
// Speeds (9 values in WotLK: walk/run/runBack/swim/swimBack/flight/flightBack/turn/pitch)
if (rem() < 36) return false;
/*float walkSpeed =*/ packet.readFloat();
float runSpeed = packet.readFloat();
/*float runBackSpeed =*/ packet.readFloat();
@ -1058,46 +1072,60 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
/*float finalAngle =*/ packet.readFloat();
}
// Legacy UPDATE_OBJECT spline layout used by many servers:
// timePassed, duration, splineId, durationMod, durationModNext,
// [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)],
// verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint.
// Spline data layout varies by expansion:
// Classic/Vanilla: timePassed(4)+duration(4)+splineId(4)+pointCount(4)+points+mode(1)+endPoint(12)
// WotLK: timePassed(4)+duration(4)+splineId(4)+durationMod(4)+durationModNext(4)
// +[ANIMATION(5)]+[PARABOLIC(8)]+pointCount(4)+points+mode(1)+endPoint(12)
// Since the parser has no expansion context, auto-detect by trying Classic first.
const size_t legacyStart = packet.getReadPos();
if (!bytesAvailable(12 + 8 + 8 + 4)) return false;
if (!bytesAvailable(16)) return false; // minimum: 12 common + 4 pointCount
/*uint32_t timePassed =*/ packet.readUInt32();
/*uint32_t duration =*/ packet.readUInt32();
/*uint32_t splineId =*/ packet.readUInt32();
/*float durationMod =*/ packet.readFloat();
/*float durationModNext =*/ packet.readFloat();
// Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
if (!bytesAvailable(5)) return false;
packet.readUInt8(); // animationType
packet.readUInt32(); // animTime
}
/*float verticalAccel =*/ packet.readFloat();
/*uint32_t effectStartTime =*/ packet.readUInt32();
uint32_t pointCount = packet.readUInt32();
const size_t afterSplineId = packet.getReadPos();
const size_t remainingAfterCount = packet.getSize() - packet.getReadPos();
const bool legacyCountLooksValid = (pointCount <= 256);
const size_t legacyPointsBytes = static_cast<size_t>(pointCount) * 12ull;
const bool legacyPayloadFits = (legacyPointsBytes + 13ull) <= remainingAfterCount;
if (legacyCountLooksValid && legacyPayloadFits) {
for (uint32_t i = 0; i < pointCount; i++) {
/*float px =*/ packet.readFloat();
/*float py =*/ packet.readFloat();
/*float pz =*/ packet.readFloat();
// Helper: try to parse uncompressed spline points from current read position.
auto tryParseUncompressedSpline = [&](const char* tag) -> bool {
if (!bytesAvailable(4)) return false;
uint32_t pc = packet.readUInt32();
if (pc > 256) return false;
size_t needed = static_cast<size_t>(pc) * 12ull + 13ull;
if (!bytesAvailable(needed)) return false;
for (uint32_t i = 0; i < pc; i++) {
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
/*uint8_t splineMode =*/ packet.readUInt8();
/*float endPointX =*/ packet.readFloat();
/*float endPointY =*/ packet.readFloat();
/*float endPointZ =*/ packet.readFloat();
LOG_DEBUG(" Spline pointCount=", pointCount, " (legacy)");
} else {
// Legacy pointCount looks invalid; try compact WotLK layout as recovery.
// This keeps malformed/variant packets from desyncing the whole update block.
packet.readUInt8(); // splineMode
packet.readFloat(); packet.readFloat(); packet.readFloat(); // endPoint
LOG_DEBUG(" Spline pointCount=", pc, " (", tag, ")");
return true;
};
// --- Try 1: Classic format (pointCount immediately after splineId) ---
bool splineParsed = tryParseUncompressedSpline("classic");
// --- Try 2: WotLK format (durationMod+durationModNext+conditional+pointCount) ---
if (!splineParsed) {
packet.setReadPos(afterSplineId);
bool wotlkOk = bytesAvailable(8); // durationMod + durationModNext
if (wotlkOk) {
/*float durationMod =*/ packet.readFloat();
/*float durationModNext =*/ packet.readFloat();
if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION
if (!bytesAvailable(5)) { wotlkOk = false; }
else { packet.readUInt8(); packet.readUInt32(); }
}
}
if (wotlkOk && (splineFlags & 0x00000800)) { // SPLINEFLAG_PARABOLIC
if (!bytesAvailable(8)) { wotlkOk = false; }
else { packet.readFloat(); packet.readUInt32(); }
}
if (wotlkOk) {
splineParsed = tryParseUncompressedSpline("wotlk");
}
}
// --- Try 3: Compact layout (compressed points) as final recovery ---
if (!splineParsed) {
packet.setReadPos(legacyStart);
const size_t afterFinalFacingPos = packet.getReadPos();
if (splineFlags & 0x00400000) { // Animation
@ -1118,8 +1146,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
static uint32_t badSplineCount = 0;
++badSplineCount;
if (badSplineCount <= 5 || (badSplineCount % 100) == 0) {
LOG_WARNING(" Spline pointCount=", pointCount,
" invalid (legacy+compact) at readPos=",
LOG_WARNING(" Spline invalid (classic+wotlk+compact) at readPos=",
afterFinalFacingPos, "/", packet.getSize(),
", occurrence=", badSplineCount);
}
@ -1139,12 +1166,14 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
if (!bytesAvailable(compactPayloadBytes)) return false;
packet.setReadPos(packet.getReadPos() + compactPayloadBytes);
}
} // end else (compact fallback)
} // end compact fallback
}
}
else if (updateFlags & UPDATEFLAG_POSITION) {
// Transport position update (UPDATEFLAG_POSITION = 0x0100)
if (rem() < 1) return false;
uint64_t transportGuid = readPackedGuid(packet);
if (rem() < 32) return false; // 8 floats
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -1173,7 +1202,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
}
}
else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) {
// Simple stationary position (4 floats)
if (rem() < 16) return false;
block.x = packet.readFloat();
block.y = packet.readFloat();
block.z = packet.readFloat();
@ -1185,32 +1214,38 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
// Target GUID (for units with target)
if (updateFlags & UPDATEFLAG_HAS_TARGET) {
if (rem() < 1) return false;
/*uint64_t targetGuid =*/ readPackedGuid(packet);
}
// Transport time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
if (rem() < 4) return false;
/*uint32_t transportTime =*/ packet.readUInt32();
}
// Vehicle
if (updateFlags & UPDATEFLAG_VEHICLE) {
if (rem() < 8) return false;
/*uint32_t vehicleId =*/ packet.readUInt32();
/*float vehicleOrientation =*/ packet.readFloat();
}
// Rotation (GameObjects)
if (updateFlags & UPDATEFLAG_ROTATION) {
if (rem() < 8) return false;
/*int64_t rotation =*/ packet.readUInt64();
}
// Low GUID
if (updateFlags & UPDATEFLAG_LOWGUID) {
if (rem() < 4) return false;
/*uint32_t lowGuid =*/ packet.readUInt32();
}
// High GUID
if (updateFlags & UPDATEFLAG_HIGHGUID) {
if (rem() < 4) return false;
/*uint32_t highGuid =*/ packet.readUInt32();
}
@ -1220,6 +1255,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& block) {
size_t startPos = packet.getReadPos();
if (packet.getReadPos() >= packet.getSize()) return false;
// Read number of blocks (each block is 32 fields = 32 bits)
uint8_t blockCount = packet.readUInt8();
@ -1307,6 +1344,8 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
}
bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& block) {
if (packet.getReadPos() >= packet.getSize()) return false;
// Read update type
uint8_t updateTypeVal = packet.readUInt8();
block.updateType = static_cast<UpdateType>(updateTypeVal);
@ -1316,6 +1355,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
switch (block.updateType) {
case UpdateType::VALUES: {
// Partial update - changed fields only
if (packet.getReadPos() >= packet.getSize()) return false;
block.guid = readPackedGuid(packet);
LOG_DEBUG(" VALUES update for GUID: 0x", std::hex, block.guid, std::dec);
@ -1324,6 +1364,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
case UpdateType::MOVEMENT: {
// Movement update
if (packet.getReadPos() + 8 > packet.getSize()) return false;
block.guid = packet.readUInt64();
LOG_DEBUG(" MOVEMENT update for GUID: 0x", std::hex, block.guid, std::dec);
@ -1333,10 +1374,12 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
case UpdateType::CREATE_OBJECT:
case UpdateType::CREATE_OBJECT2: {
// Create new object with full data
if (packet.getReadPos() >= packet.getSize()) return false;
block.guid = readPackedGuid(packet);
LOG_DEBUG(" CREATE_OBJECT for GUID: 0x", std::hex, block.guid, std::dec);
// Read object type
if (packet.getReadPos() >= packet.getSize()) return false;
uint8_t objectTypeVal = packet.readUInt8();
block.objectType = static_cast<ObjectType>(objectTypeVal);
LOG_DEBUG(" Object type: ", (int)objectTypeVal);
@ -3209,12 +3252,11 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) {
if (pointCount == 0) return true;
// Cap pointCount to prevent excessive iteration from malformed packets.
constexpr uint32_t kMaxSplinePoints = 1000;
if (pointCount > kMaxSplinePoints) {
LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints,
" (guid=0x", std::hex, data.guid, std::dec, "), capping");
pointCount = kMaxSplinePoints;
" (guid=0x", std::hex, data.guid, std::dec, ")");
return false;
}
// Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed).
@ -3865,13 +3907,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
data.hitTargets.reserve(storedHitLimit);
for (uint16_t i = 0; i < rawHitCount; ++i) {
// WotLK hit targets are packed GUIDs, like the caster and miss targets.
if (!hasFullPackedGuid(packet)) {
// WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid).
if (packet.getSize() - packet.getReadPos() < 8) {
LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount);
truncatedTargets = true;
break;
}
const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet);
const uint64_t targetGuid = packet.readUInt64();
if (i < storedHitLimit) {
data.hitTargets.push_back(targetGuid);
}
@ -3889,7 +3931,27 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
return false;
}
const size_t missCountPos = packet.getReadPos();
const uint8_t rawMissCount = packet.readUInt8();
if (rawMissCount > 20) {
// Likely offset error — dump context bytes for diagnostics.
const auto& raw = packet.getData();
std::string hexCtx;
size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos;
size_t dumpEnd = std::min(missCountPos + 16, raw.size());
for (size_t i = dumpStart; i < dumpEnd; ++i) {
char buf[4];
std::snprintf(buf, sizeof(buf), "%02x ", raw[i]);
hexCtx += buf;
if (i == missCountPos - 1) hexCtx += "[";
if (i == missCountPos) hexCtx += "] ";
}
LOG_WARNING("Spell go: suspect missCount=", (int)rawMissCount,
" spell=", data.spellId, " hits=", (int)data.hitCount,
" castFlags=0x", std::hex, data.castFlags, std::dec,
" missCountPos=", missCountPos, " pktSize=", packet.getSize(),
" ctx=", hexCtx);
}
if (rawMissCount > 128) {
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount,
") spell=", data.spellId, " hits=", (int)data.hitCount,
@ -3899,22 +3961,16 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
data.missTargets.reserve(storedMissLimit);
for (uint16_t i = 0; i < rawMissCount; ++i) {
// Each miss entry: packed GUID(1-8 bytes) + missType(1 byte).
// WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType.
// REFLECT additionally appends uint8 reflectResult.
if (!hasFullPackedGuid(packet)) {
if (packet.getSize() - packet.getReadPos() < 9) { // 8 GUID + 1 missType
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount,
" spell=", data.spellId, " hits=", (int)data.hitCount);
truncatedTargets = true;
break;
}
SpellGoMissEntry m;
m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount,
" spell=", data.spellId);
truncatedTargets = true;
break;
}
m.targetGuid = packet.readUInt64();
m.missType = packet.readUInt8();
if (m.missType == 11) { // SPELL_MISS_REFLECT
if (packet.getSize() - packet.getReadPos() < 1) {
@ -4302,6 +4358,17 @@ network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t s
return packet;
}
network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot,
uint8_t dstBag, uint8_t dstSlot, uint8_t count) {
network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM));
packet.writeUInt8(srcBag);
packet.writeUInt8(srcSlot);
packet.writeUInt8(dstBag);
packet.writeUInt8(dstSlot);
packet.writeUInt8(count);
return packet;
}
network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM));
packet.writeUInt8(srcSlot);
@ -5811,5 +5878,14 @@ network::Packet SetTitlePacket::build(int32_t titleBit) {
return p;
}
network::Packet AlterAppearancePacket::build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) {
// CMSG_ALTER_APPEARANCE: uint32 hairStyle + uint32 hairColor + uint32 facialHair
network::Packet p(wireOpcode(Opcode::CMSG_ALTER_APPEARANCE));
p.writeUInt32(hairStyle);
p.writeUInt32(hairColor);
p.writeUInt32(facialHair);
return p;
}
} // namespace game
} // namespace wowee

View file

@ -273,8 +273,9 @@ void CameraController::update(float deltaTime) {
keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false;
}
// Tilde toggles auto-run; any forward/backward key cancels it
bool tildeDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_GRAVE);
// Tilde or NumLock toggles auto-run; any forward/backward key cancels it
bool tildeDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_GRAVE) ||
input.isKeyPressed(SDL_SCANCODE_NUMLOCKCLEAR));
if (tildeDown && !tildeWasDown) {
autoRunning = !autoRunning;
}

View file

@ -1753,6 +1753,7 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position,
return 0;
}
const auto& mdlRef = modelIt->second;
modelUnusedSince_.erase(modelId);
// Deduplicate: skip if same model already at nearly the same position.
// Uses hash map for O(1) lookup instead of O(N) scan.
@ -1864,6 +1865,7 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4&
LOG_WARNING("Cannot create instance: model ", modelId, " not loaded");
return 0;
}
modelUnusedSince_.erase(modelId);
// Deduplicate: O(1) hash lookup
{
@ -4276,11 +4278,28 @@ void M2Renderer::cleanupUnusedModels() {
usedModelIds.insert(instance.modelId);
}
// Find and remove models with no instances
const auto now = std::chrono::steady_clock::now();
constexpr auto kGracePeriod = std::chrono::seconds(60);
// Find models with no instances that have exceeded the grace period.
// Models that just lost their last instance get tracked but not evicted
// immediately — this prevents thrashing when GO models are briefly
// instance-free between despawn and respawn cycles.
std::vector<uint32_t> toRemove;
for (const auto& [id, model] : models) {
if (usedModelIds.find(id) == usedModelIds.end()) {
if (usedModelIds.find(id) != usedModelIds.end()) {
// Model still in use — clear any pending unused timestamp
modelUnusedSince_.erase(id);
continue;
}
auto unusedIt = modelUnusedSince_.find(id);
if (unusedIt == modelUnusedSince_.end()) {
// First cycle with no instances — start the grace timer
modelUnusedSince_[id] = now;
} else if (now - unusedIt->second >= kGracePeriod) {
// Grace period expired — mark for removal
toRemove.push_back(id);
modelUnusedSince_.erase(unusedIt);
}
}

View file

@ -67,6 +67,10 @@
#include <cctype>
#include <cmath>
#include <chrono>
#include <filesystem>
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image_write.h"
#include <cstdlib>
#include <optional>
#include <unordered_map>
@ -2574,6 +2578,101 @@ void Renderer::cancelEmote() {
emoteLoop = false;
}
bool Renderer::captureScreenshot(const std::string& outputPath) {
if (!vkCtx) return false;
VkDevice device = vkCtx->getDevice();
VmaAllocator alloc = vkCtx->getAllocator();
VkExtent2D extent = vkCtx->getSwapchainExtent();
const auto& images = vkCtx->getSwapchainImages();
if (images.empty() || currentImageIndex >= images.size()) return false;
VkImage srcImage = images[currentImageIndex];
uint32_t w = extent.width;
uint32_t h = extent.height;
VkDeviceSize bufSize = static_cast<VkDeviceSize>(w) * h * 4;
// Stall GPU so the swapchain image is idle
vkDeviceWaitIdle(device);
// Create staging buffer
VkBufferCreateInfo bufInfo{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO};
bufInfo.size = bufSize;
bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT;
VmaAllocationCreateInfo allocCI{};
allocCI.usage = VMA_MEMORY_USAGE_CPU_ONLY;
VkBuffer stagingBuf = VK_NULL_HANDLE;
VmaAllocation stagingAlloc = VK_NULL_HANDLE;
if (vmaCreateBuffer(alloc, &bufInfo, &allocCI, &stagingBuf, &stagingAlloc, nullptr) != VK_SUCCESS) {
LOG_WARNING("Screenshot: failed to create staging buffer");
return false;
}
// Record copy commands
VkCommandBuffer cmd = vkCtx->beginSingleTimeCommands();
// Transition swapchain image: PRESENT_SRC → TRANSFER_SRC
VkImageMemoryBarrier toTransfer{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER};
toTransfer.srcAccessMask = VK_ACCESS_MEMORY_READ_BIT;
toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
toTransfer.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
toTransfer.image = srcImage;
toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1};
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT,
0, 0, nullptr, 0, nullptr, 1, &toTransfer);
// Copy image to buffer
VkBufferImageCopy region{};
region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1};
region.imageExtent = {w, h, 1};
vkCmdCopyImageToBuffer(cmd, srcImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
stagingBuf, 1, &region);
// Transition back: TRANSFER_SRC → PRESENT_SRC
VkImageMemoryBarrier toPresent = toTransfer;
toPresent.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT;
toPresent.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT;
toPresent.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL;
toPresent.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR;
vkCmdPipelineBarrier(cmd,
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT,
0, 0, nullptr, 0, nullptr, 1, &toPresent);
vkCtx->endSingleTimeCommands(cmd);
// Map and convert BGRA → RGBA
void* mapped = nullptr;
vmaMapMemory(alloc, stagingAlloc, &mapped);
auto* pixels = static_cast<uint8_t*>(mapped);
for (uint32_t i = 0; i < w * h; ++i) {
std::swap(pixels[i * 4 + 0], pixels[i * 4 + 2]); // B ↔ R
}
// Ensure output directory exists
std::filesystem::path outPath(outputPath);
if (outPath.has_parent_path())
std::filesystem::create_directories(outPath.parent_path());
int ok = stbi_write_png(outputPath.c_str(),
static_cast<int>(w), static_cast<int>(h),
4, pixels, static_cast<int>(w * 4));
vmaUnmapMemory(alloc, stagingAlloc);
vmaDestroyBuffer(alloc, stagingBuf, stagingAlloc);
if (ok) {
LOG_INFO("Screenshot saved: ", outputPath);
} else {
LOG_WARNING("Screenshot: stbi_write_png failed for ", outputPath);
}
return ok != 0;
}
void Renderer::triggerLevelUpEffect(const glm::vec3& position) {
if (!levelUpEffect) return;

View file

@ -206,8 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) {
}
}
}
// Login screen music disabled
if (false && renderer) {
// Login screen music
if (renderer) {
auto* music = renderer->getMusicManager();
if (music) {
if (!loginMusicVolumeAdjusted_) {

File diff suppressed because it is too large Load diff

View file

@ -871,6 +871,35 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) {
ImGui::EndPopup();
}
// Stack split popup
if (splitConfirmOpen_) {
ImVec2 mousePos = ImGui::GetIO().MousePos;
ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always);
ImGui::OpenPopup("##SplitStack");
splitConfirmOpen_ = false;
}
if (ImGui::BeginPopup("##SplitStack", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Text("Split %s", splitItemName_.c_str());
ImGui::Spacing();
ImGui::SetNextItemWidth(120.0f);
ImGui::SliderInt("##splitcount", &splitCount_, 1, splitMax_ - 1);
ImGui::Spacing();
if (ImGui::Button("OK", ImVec2(55, 0))) {
if (gameHandler_ && splitCount_ > 0 && splitCount_ < splitMax_) {
gameHandler_->splitItem(splitBag_, splitSlot_, static_cast<uint8_t>(splitCount_));
}
splitItemName_.clear();
inventoryDirty = true;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(55, 0))) {
splitItemName_.clear();
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
// Draw held item at cursor
renderHeldItem();
}
@ -2302,22 +2331,39 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
}
}
// Shift+right-click: open destroy confirmation for non-quest items
// Shift+right-click: split stack (if stackable >1) or destroy confirmation
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) &&
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0 && item.bindType != 4) {
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
!holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0) {
if (item.stackCount > 1 && item.maxStack > 1) {
// Open split popup for stackable items
splitConfirmOpen_ = true;
splitItemName_ = item.name;
splitMax_ = static_cast<int>(item.stackCount);
splitCount_ = splitMax_ / 2;
if (splitCount_ < 1) splitCount_ = 1;
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
splitBag_ = 0xFF;
splitSlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
splitBag_ = static_cast<uint8_t>(19 + bagIndex);
splitSlot_ = static_cast<uint8_t>(bagSlotIndex);
}
} else if (item.bindType != 4) {
// Destroy confirmation for non-quest, non-stackable items
destroyConfirmOpen_ = true;
destroyItemName_ = item.name;
destroyCount_ = static_cast<uint8_t>(std::clamp<uint32_t>(
std::max<uint32_t>(1u, item.stackCount), 1u, 255u));
if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(23 + backpackIndex);
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
destroyBag_ = static_cast<uint8_t>(19 + bagIndex);
destroySlot_ = static_cast<uint8_t>(bagSlotIndex);
} else if (kind == SlotKind::EQUIPMENT) {
destroyBag_ = 0xFF;
destroySlot_ = static_cast<uint8_t>(equipSlot);
}
}
}

View file

@ -82,6 +82,14 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler
pos += replacement.length();
}
// Resolve class and race names for $C and $R placeholders
std::string className = "Adventurer";
std::string raceName = "Unknown";
if (character) {
className = game::getClassName(character->characterClass);
raceName = game::getRaceName(character->race);
}
// Replace simple placeholders
pos = 0;
while ((pos = result.find('$', pos)) != std::string::npos) {
@ -92,11 +100,12 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler
switch (code) {
case 'n': case 'N': replacement = playerName; break;
case 'c': case 'C': replacement = className; break;
case 'r': case 'R': replacement = raceName; break;
case 'p': replacement = pronouns.subject; break;
case 'o': replacement = pronouns.object; break;
case 's': replacement = pronouns.possessive; break;
case 'S': replacement = pronouns.possessiveP; break;
case 'r': replacement = pronouns.object; break;
case 'b': case 'B': replacement = "\n"; break;
case 'g': case 'G': pos++; continue;
default: pos++; continue;

View file

@ -176,6 +176,29 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) {
ImGui::EndTabBar();
}
// Talent learn confirmation popup
if (talentConfirmOpen_) {
ImGui::OpenPopup("Learn Talent?##talent_confirm");
talentConfirmOpen_ = false;
}
if (ImGui::BeginPopupModal("Learn Talent?##talent_confirm", nullptr,
ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", pendingTalentName_.c_str());
ImGui::Text("Rank %u", pendingTalentRank_ + 1);
ImGui::Spacing();
ImGui::TextWrapped("Spend a talent point?");
ImGui::Spacing();
if (ImGui::Button("Learn", ImVec2(80, 0))) {
gameHandler.learnTalent(pendingTalentId_, pendingTalentRank_);
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel", ImVec2(80, 0))) {
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
}
void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId,
@ -574,10 +597,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler,
ImGui::EndTooltip();
}
// Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...)
// CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value)
// Handle click — open confirmation dialog instead of learning directly
if (clicked && canLearn && prereqsMet) {
gameHandler.learnTalent(talent.talentId, currentRank);
talentConfirmOpen_ = true;
pendingTalentId_ = talent.talentId;
pendingTalentRank_ = currentRank;
uint32_t nextSpell = (currentRank < 5) ? talent.rankSpells[currentRank] : 0;
pendingTalentName_ = nextSpell ? gameHandler.getSpellName(nextSpell) : "";
if (pendingTalentName_.empty())
pendingTalentName_ = spellId ? gameHandler.getSpellName(spellId) : "Talent";
}
ImGui::PopID();