Compare commits

...

71 commits

Author SHA1 Message Date
Kelsi
29ca9809b1 game: fix Classic parseQuestDetails missing emote section before reward items
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
Vanilla 1.12 SMSG_QUESTGIVER_QUEST_DETAILS includes an emote section
between suggestedPlayers and the choice/reward item lists:
  activateAccept(u8) + suggestedPlayers(u32) +
  emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount +
  choiceCount(u32) + choices + rewardCount(u32) + rewards + money(u32)

The parser was skipping the emote section, causing the emote count to
be misread as the choice item count. Quests with emotes would show
zero choice items and shifted/missing reward and money data.
2026-03-10 03:09:12 -07:00
Kelsi
16cdde82b3 rendering: add diagnostic logging to applyEquipment for Classic equipment debugging
Log each equipment item's displayModel, inventoryType, and DBC lookup result
to help identify why Classic character equipment does not render correctly.
Also log ItemDisplayInfo.dbc field count, found texture names per region,
and missing texture paths so the exact failure point is visible in logs.
2026-03-10 02:23:54 -07:00
Kelsi
967cedba0e game: fix Classic/TBC SMSG_QUESTGIVER_QUEST_LIST quest title and questCount parsing
Classic 1.12 and TBC 2.4.3 don't include questFlags(u32) + isRepeatable(u8)
before each quest title in SMSG_QUESTGIVER_QUEST_LIST. WotLK 3.3.5a added
those 5 bytes. The previous code read them speculatively for all expansions
and only rewound on empty title, which failed for any non-empty title.

Also fix questCount always reading as uint8 (all WoW versions use u8 here).
The old u32/u8 heuristic could misread 4 bytes instead of 1, misaligning all
subsequent quest item reads.
2026-03-10 01:54:01 -07:00
Kelsi
7270a4e690 game: fix TBC 2.4.3 SMSG_CAST_FAILED missing parseCastFailed override
TBC 2.4.3 SMSG_CAST_FAILED format is spellId(u32) + result(u8), same as
Classic. WotLK added a castCount(u8) prefix before spellId. TbcPacketParsers
lacked a parseCastFailed override, so it fell through to the WotLK base
which read one extra byte as castCount, shifting the spellId read by one
byte and corrupting the spell ID and result for every failed cast on TBC.

- Add TbcPacketParsers::parseCastFailed override: reads spellId(4)+result(1)
- ClassicPacketParsers already overrides this (enum shift +1), so Classic unaffected
2026-03-10 01:30:06 -07:00
Kelsi
528b796dff game: fix Classic 1.12 SMSG_AUCTION_LIST_RESULT enchant slot count
Classic 1.12 auction entries contain only 1 enchant slot (3 uint32s),
while TBC and WotLK expanded this to 3 enchant slots (9 uint32s). Parsing
Classic auction results with the WotLK parser consumed 24 extra bytes per
entry (two extra enchant slots), corrupting randomPropertyId, stackCount,
ownerGuid, pricing and expiry data for every auction item.

- AuctionListResultParser::parse() gains a numEnchantSlots parameter (default 3)
- Classic path reads 1 enchant slot; TBC/WotLK read 3
- handleAuctionListResult/OwnerList/BidderList pass isClassicLikeExpansion()?1:3
2026-03-10 01:25:27 -07:00
Kelsi
8dd4bc80ec game: fix Classic 1.12 SMSG_TRAINER_LIST per-spell field layout
Classic 1.12 trainer list entries lack the profDialog and profButton
uint32 fields (8 bytes) that TBC/WotLK added before reqLevel. Instead,
reqLevel immediately follows spellCost, and a trailing unk uint32 appears
at the end of each entry. Parsing the WotLK format for Classic caused
misalignment from the third field onward, corrupting state, cost, level,
skill, and chain data for all trainer spells.

- TrainerListParser::parse() gains a isClassic bool parameter (default false)
- Classic path: cost(4) → reqLevel(1) → reqSkill... → chainNode3 → unk(4)
- WotLK/TBC path: cost(4) → profDialog(4) → profButton(4) → reqLevel(1) → reqSkill...
- handleTrainerList() passes isClassicLikeExpansion() as the flag
2026-03-10 01:20:41 -07:00
Kelsi
23878e530f game: implement Classic SMSG_FRIEND_LIST and full SMSG_CONTACT_LIST parsing
Classic 1.12 and TBC use SMSG_FRIEND_LIST (not SMSG_CONTACT_LIST) to send
the initial friend list at login. Previously this packet was silently dropped,
leaving friendsCache empty and breaking /friend remove and note operations
for Classic players.

- Add handleFriendList(): parses Classic format (u8 count, then per-entry:
  u64 guid + u8 status + optional area/level/class if online)
- Add handleContactList(): fully parses WotLK SMSG_CONTACT_LIST entries
  (previously only read mask+count header and dropped all entries)
- Both handlers populate friendGuids_ and call queryPlayerName() for unknown
  GUIDs; handleNameQueryResponse() now backfills friendsCache when a name
  resolves for a known friend GUID
- Clear friendGuids_ on disconnect alongside playerNameCache
2026-03-10 01:15:51 -07:00
Kelsi
ab0828a4ce game: fix Classic 1.12 SMSG_WHO missing gender byte alignment
Vanilla 1.12 SMSG_WHO per-player format:
  name(CString) + guild(CString) + level(u32) + class(u32) + race(u32) + zone(u32)

WotLK 3.3.5a added a gender(u8) byte between race and zone. The previous
handleWho always read the gender byte, causing a one-byte misalignment for
Classic/TBC: the first byte of zoneId was consumed as gender, then zoneId
read from the next 4 bytes (spanning into the next player entry).

Now only reads the gender byte for WotLK (isActiveExpansion("wotlk")), and
adds bounds checks to prevent out-of-bounds reads on truncated packets.
2026-03-10 01:08:13 -07:00
Kelsi
c19edd407a game: fix Classic/TBC SMSG_TEXT_EMOTE field order
Classic 1.12 and TBC 2.4.3 send SMSG_TEXT_EMOTE with the field order:
  textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + nameLen(u32) + name

WotLK 3.3.5a swapped senderGuid to the front:
  senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + nameLen(u32) + name

The previous TextEmoteParser always used the WotLK order, causing senderGuid
to be read as a mashup of textEmoteId+emoteNum for Classic/TBC. Emote
animations and chat entries were associated with wrong GUIDs.

TextEmoteParser::parse now takes a legacyFormat parameter; handleTextEmote
passes it based on expansion detection.
2026-03-10 01:05:23 -07:00
Kelsi
a0979b9cd8 game: fix Classic/TBC SMSG_GROUP_LIST parsing - missing roles byte
WotLK 3.3.5a added a group-level and per-member roles byte (tank/healer/dps)
for the Dungeon Finder system. Classic 1.12 and TBC 2.4.3 do not send this byte.

The previous GroupListParser always read the roles byte, causing a one-byte
misalignment in Classic/TBC group lists that corrupted member GUID reads and
all subsequent fields (loot method, leader GUID, etc.).

GroupListParser::parse now takes a hasRoles parameter (default true for
backward compatibility). handleGroupList passes hasRoles=isActiveExpansion("wotlk").
Also adds range-checking throughout to prevent out-of-bounds reads on
malformed or unexpectedly short group list packets.
2026-03-10 00:58:56 -07:00
Kelsi
04f22376ce game: fix Classic 1.12 SMSG_NAME_QUERY_RESPONSE race/gender/class parsing
Classic 1.12 servers (vmangos/cmangos-classic) send:
  uint64 guid + CString name + CString realmName + uint32 race + uint32 gender + uint32 class

TBC's Variant A (which Classic inherited) skipped the realmName CString,
causing the null terminator of the empty realmName to be absorbed into the
low byte of the uint32 race read, producing race=0 and shifted gender/class.

Adds a ClassicPacketParsers::parseNameQueryResponse override that correctly
reads the realmName CString before the race/gender/class uint32 fields.
2026-03-10 00:53:03 -07:00
Kelsi
d3ec230cec game: fix Classic 1.12 packed GUID for SMSG_PARTY_MEMBER_STATS
Classic/Vanilla uses ObjectGuid::WriteAsPacked() for party member stats
packets (same packed format as WotLK), not full uint64 as TBC does.
Reading 8 fixed bytes for the GUID over-read the packed GUID field,
misaligning updateFlags and all subsequent stat fields, breaking party
frame HP/mana display in Classic.
2026-03-10 00:42:52 -07:00
Kelsi
8014f2650c game: fix Classic 1.12 GUID format for health/power/aura/energize packets
Classic 1.12 sends packed GUIDs (byte mask + non-zero bytes) for these
server packets, not full uint64 as TBC does. The previous fixes incorrectly
grouped Classic with TBC, causing the GUID readers to over-read 8 bytes
from what were 2-4 byte packed GUIDs, corrupting health values and spell
IDs parsed from subsequent bytes.

Verified from vmangos/cmangos-classic source code:
  SMSG_HEALTH_UPDATE:     data << GetPackGUID()
  SMSG_POWER_UPDATE:      data << GetPackGUID()
  SMSG_UPDATE_COMBO_POINTS: data << combotarget->GetPackGUID()
  SMSG_PERIODICAURALOG:   data << victim->GetPackGUID() + caster->GetPackGUID()
  SMSG_SPELLENERGIZELOG:  data << victim->GetPackGUID() + caster->GetPackGUID()

TBC continues to use full uint64 for these packets. WotLK and Classic
both use packed GUIDs. The branching now correctly distinguishes TBC
from the rest.
2026-03-10 00:38:47 -07:00
Kelsi
cb0dfddf59 game: add Classic 1.12 parseAuraUpdate override to restore aura tracking
Classic 1.12 sends SMSG_AURA_UPDATE/SMSG_AURA_UPDATE_ALL, but ClassicPacketParsers
inherited TBC's override which returns false (TBC uses a different aura system
and doesn't send SMSG_AURA_UPDATE at all).

Classic aura format differs from WotLK in two key ways:
- DURATION flag bit is 0x10 in Vanilla, not 0x20 as in WotLK; reading with the
  WotLK parser would incorrectly gate duration reads and misparse aura fields
- No caster GUID field in Classic; WotLK parser tries to read one (gated by 0x08)
  which would consume spell ID or flag bytes from the next aura slot

With this override, player/target aura bars and buff tracking work correctly
on Classic 1.12 connections for the first time.
2026-03-10 00:30:28 -07:00
Kelsi
b15a21a957 game: add Classic 1.12 overrides for melee/spell damage log packets
Vanilla 1.12 SMSG_ATTACKERSTATEUPDATE, SMSG_SPELLNONMELEEDAMAGELOG, and
SMSG_SPELLHEALLOG use PackedGuid for all entity GUIDs, not full uint64
as TBC and WotLK do.

Without these overrides Classic inherited TBC's implementations, which
over-read PackedGuid fields as fixed 8-byte GUIDs, misaligning all
subsequent damage/heal fields and making combat parsing unusable on
Classic servers.

The Classic override logic is identical to TBC except for the GUID
reads, so combat text, damage numbers, and kill tracking now work
correctly on Vanilla 1.12 connections.
2026-03-10 00:27:08 -07:00
Kelsi
5f06c18a54 game: add Classic 1.12 overrides for parseSpellStart and parseSpellGo
Vanilla 1.12 SMSG_SPELL_START and SMSG_SPELL_GO use:
- PackedGuid (variable-length) for caster and target GUIDs, not full uint64
- uint16 castFlags, not uint32 as in TBC/WotLK
- uint16 targetFlags in SpellCastTargets, not uint32

Without these overrides Classic inherited TBC's implementations which
read 8 bytes for each GUID (over-reading the PackedGuid) and then 4
bytes for castFlags instead of 2, misaligning all subsequent fields
and producing garbage spell IDs, cast times, and target GUIDs.

Hit and miss target GUIDs in SMSG_SPELL_GO are also PackedGuid in
Vanilla (vs full uint64 in TBC), handled by the new parseSpellGo.
2026-03-10 00:24:16 -07:00
Kelsi
c011d724c6 game: implement SMSG_RESISTLOG combat text (resist/miss display for all expansions) 2026-03-10 00:16:13 -07:00
Kelsi
5d2bc9503d game: fix expansion-gated GUID for FORCE_MOVE_ROOT/UNROOT 2026-03-10 00:06:11 -07:00
Kelsi
9cf331fdab game: fix expansion-gated GUIDs for RESUME_CAST_BAR, TALENTS_INFO, and TELEPORT_ACK 2026-03-10 00:00:21 -07:00
Kelsi
3d2bade521 game: fix expansion-gated GUIDs for movement handlers (FORCE_SPEED, FORCE_FLAG, KNOCK_BACK, other-player relayed moves) 2026-03-09 23:58:15 -07:00
Kelsi
deea701222 game: fix expansion-gated GUIDs for PARTY_MEMBER_STATS and MINIMAP_PING 2026-03-09 23:53:43 -07:00
Kelsi
e122d725f6 game: fix expansion-gated GUIDs for HEALTH_UPDATE, POWER_UPDATE, COMBO_POINTS 2026-03-09 23:51:01 -07:00
Kelsi
abf9ef0b5f game: fix expansion-gated GUIDs for PERIODICAURALOG, SPELLENERGIZELOG, SPELL_DELAYED; separate FEATURE_SYSTEM_STATUS/SPELL_MODIFIER from SPELL_DELAYED case 2026-03-09 23:48:06 -07:00
Kelsi
e11d0956fb game: fix TBC/Classic GUID format for SPELLLOGMISS, IMMUNE, and SPELLDISPELLOG 2026-03-09 23:45:10 -07:00
Kelsi
011b1c8295 game: fix SMSG_SPELL_DELAYED to also extend non-player cast bars
Previously SMSG_SPELL_DELAYED only adjusted the local player's cast bar.
Now it also extends unitCastStates_ for any non-player caster (e.g.
boss cast bar extends correctly when hit by a tank during cast).
2026-03-09 23:39:22 -07:00
Kelsi
f31fa29616 game/ui: add channeled spell cast tracking and party cast bars
- Handle MSG_CHANNEL_START: populate unitCastStates_ for both the local
  player and any non-player caster (boss/mob channeled spells); use
  full uint64 GUIDs for TBC/Classic, packed GUIDs for WotLK
- Handle MSG_CHANNEL_UPDATE: sync remaining channel time; clear cast
  state on channel completion (remainingMs == 0)
- Fix SMSG_RESUME_CAST_BAR: also resumes non-player units' cast bars
  (previously only resumed the player's own bar after zone transitions)
- Add party member cast bars in renderPartyFrames: golden progress bar
  appears beneath the power bar when a party member is casting,
  leveraging the existing unitCastStates_ per-GUID map
2026-03-09 23:36:14 -07:00
Kelsi
d72912714b game: fix SMSG_SPELL_FAILURE GUID format for TBC/Classic vs WotLK
WotLK uses packed GUIDs in SMSG_SPELL_FAILURE / SMSG_SPELL_FAILED_OTHER.
TBC 2.4.3 and Classic 1.12 use full uint64 GUIDs. The previous fix used
UpdateObjectParser::readPackedGuid for all expansions, which would
mis-parse the caster GUID on TBC/Classic servers, leaving stale cast
bars and potentially corrupting subsequent packet reads.

Now checks isClassicLikeExpansion() || isActiveExpansion("tbc") and
reads a raw uint64 for those expansions, matching the TBC/Classic wire
format used in parseSpellStart/parseSpellGo overrides.
2026-03-09 23:20:15 -07:00
Kelsi
640eaacb8c game: clear unit cast bars on SMSG_SPELL_FAILURE and SMSG_SPELL_FAILED_OTHER
When a spell fails or is interrupted, the server sends SMSG_SPELL_FAILURE
(for the caster's own POV) or SMSG_SPELL_FAILED_OTHER (for observers).
Previously these were consumed without updating cast state, leaving stale
cast bars for interrupted enemies. Now:

- SMSG_SPELL_FAILURE: erases unitCastStates_[failGuid] for non-player
  casters (still clears player casting/currentCastSpellId for own casts)
- SMSG_SPELL_FAILED_OTHER: erases unitCastStates_[guid] for the caster
  so boss/enemy cast bars immediately clear on interrupt/kick
2026-03-09 23:16:15 -07:00
Kelsi
07d0485a31 game/ui: generalize cast tracking to per-GUID map; add boss cast bars
Previously the target cast bar tracked a single target using 4 private
fields. This replaces that with unitCastStates_ (unordered_map<uint64_t,
UnitCastState>), tracking cast state for every non-player unit whose
SMSG_SPELL_START we receive.

Changes:
- GameHandler::UnitCastState struct: casting, spellId, timeRemaining,
  timeTotal
- getUnitCastState(guid) → returns cast state for any tracked unit
- isTargetCasting(), getTargetCastSpellId(), getTargetCastProgress(),
  getTargetCastTimeRemaining() now delegate to getUnitCastState(targetGuid)
- handleSpellStart: tracks all non-player casters (not just the target)
- handleSpellGo: erases caster from map when spell lands
- update loop: ticks down all unit cast states, erasing expired entries
- unitCastStates_ cleared on world reset
- renderBossFrames: shows red cast progress bar per boss slot with
  spell name + remaining seconds — critical for instance interrupt play
2026-03-09 23:13:30 -07:00
Kelsi
1c85b7a46d ui: add combo point display to player frame (Rogue/Druid)
Adds 5 gold/grey dot indicators below the power bar in the player frame
for Rogue (class 4) and Druid (class 11), showing the current combo
point count from SMSG_UPDATE_COMBO_POINTS. Active points are bright gold;
empty slots are dark grey. Dots are centered in the frame width.
The display is always shown for Rogues; for Druids it only appears when
combo points are non-zero (they only accumulate in Cat Form).
2026-03-09 23:09:58 -07:00
Kelsi
4d39736d29 game/ui: add target cast bar to target frame (SMSG_SPELL_START tracking)
SMSG_SPELL_START fires for all units, not just the player. Previously only
the player's own cast was tracked; now we also track when the current
target is casting, enabling interrupt decisions.

- GameHandler: track targetCasting_/targetCastSpellId_/targetCastTimeTotal_
  /targetCastTimeRemaining_ — updated by SMSG_SPELL_START for the current
  target and ticked down in the update loop each frame
- Target cast cleared when: target changes (setTarget), target's spell
  lands (SMSG_SPELL_GO), or cast timer expires naturally
- game_screen: renderTargetFrame shows a red cast progress bar between
  the power bar and distance line when the target is casting, with
  spell name + remaining seconds
- Public accessors: isTargetCasting(), getTargetCastSpellId(),
  getTargetCastProgress(), getTargetCastTimeRemaining()
2026-03-09 23:06:40 -07:00
Kelsi
6951b7803d game: fix SMSG_SPELL_GO miss-entry consumption in WotLK and TBC parsers
Both SpellGoParser::parse (WotLK) and TbcPacketParsers::parseSpellGo
(TBC) read missCount but did not consume the per-miss (guid + missType)
entries that follow, leaving unread bytes in the packet and silently
corrupting any subsequent parsing of cast-flags–gated spell data.

- Add SpellGoMissEntry{targetGuid, missType} and missTargets vector
  to SpellGoData
- WotLK parser now reads packed GUIDs + missType per miss entry
- TBC parser now reads full uint64 GUIDs + missType per miss entry
  (9 bytes per entry, bounds-checked)
- handleSpellGo now shows MISS/DODGE/PARRY/BLOCK combat text
  for each missed target when the local player cast the spell,
  complementing the existing SMSG_SPELLLOGMISS path
- Remove unused foliageLikeModel variable in m2_renderer pass-2 loop
  (fix unused-variable warning)
- Update smoke model comment in m2_renderer to reflect current state
2026-03-09 23:00:21 -07:00
Kelsi
06a628dae2 game: implement SMSG_PET_SPELLS/MODE/BROKEN and pet action plumbing
SMSG_PET_SPELLS: Parse full packet — pet GUID, react/command state,
10 action bar slots, per-spell entries with autocast flags.  Previously
only read the GUID.

SMSG_PET_MODE: Parse petGuid + mode uint32 (command low byte, react
high byte) to keep stance state in sync after server updates.

SMSG_PET_BROKEN: Clear pet state and show "Your pet has died." chat
message.

SMSG_PET_LEARNED_SPELL / SMSG_PET_UNLEARNED_SPELL: Maintain pet spell
list incrementally.

SMSG_PET_CAST_FAILED: Parse and log cast count + spell + reason.

New state accessors: getPetActionSlot(), getPetCommand(), getPetReact(),
getPetSpells(), isPetSpellAutocast().

CMSG_PET_ACTION: Add targetGuid (uint64) field — the wire format
requires petGuid(8)+action(4)+targetGuid(8).  Was sending an 12-byte
packet instead of the required 20 bytes.

sendPetAction(): New method that builds and sends CMSG_PET_ACTION with
the correct target guid.
2026-03-09 22:53:09 -07:00
Kelsi
52c1fed6ab game: implement dual-spec switch via CMSG_SET_ACTIVE_TALENT_GROUP (0x4C3)
switchTalentSpec() was only updating local state without notifying the
server, leaving the server out of sync with the client's active talent
group. Now sends CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (WotLK wire
opcode 0x4C3) with the target group index (0=primary, 1=secondary),
prompting the server to apply the spec swap and respond with a fresh
SMSG_TALENTS_INFO for the newly active group.

Also adds ActivateTalentGroupPacket::build() to world_packets for the
packet construction.
2026-03-09 22:49:23 -07:00
Kelsi
d339734143 game: fix LFG reward money display (copper→gold/silver/copper)
The SMSG_LFG_PLAYER_REWARD handler was printing raw copper value with
a "g" suffix (e.g. "12345g") instead of converting to gold/silver/copper.
Now formats as "1g 23s 45c" matching the standard WoW convention.
2026-03-09 22:45:06 -07:00
Kelsi
3e5760aefe ui: add battleground score frame for WSG/AB/AV/EotS/SotA
Renders a compact top-centre overlay showing Alliance vs Horde scores
when the player is in a recognised battleground map.  Score values are
read directly from the world state map maintained by SMSG_INIT_WORLD_STATES
and SMSG_UPDATE_WORLD_STATE, so no extra server packets are needed.

Supported maps:
  489 – Warsong Gulch    (flag captures, max 3)
  529 – Arathi Basin     (resources, max 1600)
   30 – Alterac Valley   (reinforcements, max 600)
  566 – Eye of the Storm (resources, max 1600)
  607 – Strand of Ancients
2026-03-09 22:42:44 -07:00
Kelsi
f63b75c388 tbc/classic: fix SMSG_RAID_INSTANCE_INFO format (uint32 resetTime, no extended)
TBC 2.4.3 and Classic 1.12 send resetTime as uint32 (seconds) with no
extended byte, while WotLK 3.3.5a sends uint64 timestamp + extended byte.
Parse the correct field widths based on expansion to prevent corrupted
instance lockout data on TBC/Classic realms.
2026-03-09 22:39:08 -07:00
Kelsi
c44477fbee Implement corpse reclaim: store death position and show Resurrect button
When a player releases spirit, the server sends SMSG_DEATH_RELEASE_LOC
with the corpse map and position. Store this so the ghost can reclaim.

New flow:
- SMSG_DEATH_RELEASE_LOC now stores corpseMapId_/corpseX_/Y_/Z_ instead
  of logging and discarding
- canReclaimCorpse(): true when ghost is on same map within 40 yards of
  stored corpse position
- reclaimCorpse(): sends CMSG_RECLAIM_CORPSE (no payload)
- renderReclaimCorpseButton(): shows "Resurrect from Corpse" button at
  bottom-center when canReclaimCorpse() is true
2026-03-09 22:31:56 -07:00
Kelsi
c6e39707de Fix resurrect: correct packet routing and show caster name in dialog
Two bugs fixed:

1. acceptResurrect() was always sending CMSG_SPIRIT_HEALER_ACTIVATE even
   for player-cast resurrections (Priest/Paladin/Druid). That opcode is
   only the correct response to SMSG_SPIRIT_HEALER_CONFIRM. For
   SMSG_RESURRECT_REQUEST the server expects CMSG_RESURRECT_RESPONSE
   with accept=1. Added resurrectIsSpiritHealer_ to track which path
   triggered the dialog and send the right packet per type.

2. The resurrect dialog showed a generic "Return to life?" string
   regardless of who cast the resurrection. Parse the optional CString
   name from SMSG_RESURRECT_REQUEST (or fall back to playerNameCache)
   and display "X wishes to resurrect you." when the caster is known.
2026-03-09 22:27:24 -07:00
Kelsi
ede380ec60 tbc: implement SMSG_INIT/SET_EXTRA_AURA_INFO_OBSOLETE for buff tracking
TBC 2.4.3 does not have SMSG_AURA_UPDATE (added in WotLK). Instead it
uses SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE (0x3A3) for full aura refresh
on login/zone and SMSG_SET_EXTRA_AURA_INFO_OBSOLETE (0x3A4) for single-
slot updates. Implement handlers for both packets so TBC buff/debuff bars
populate correctly.

Also implement SMSG_CLEAR_EXTRA_AURA_INFO (0x3A6) to remove individual
aura slots when buffs expire or are cancelled server-side.

Format parsed: uint64 targetGuid + uint8 count + per-slot {uint8 slot,
uint32 spellId, uint8 effectIndex, uint8 flags, uint32 durationMs,
uint32 maxDurationMs}. Infinite auras (0xFFFFFFFF) stored as durationMs=-1.
2026-03-09 22:20:47 -07:00
Kelsi
edd7e5e591 Fix shadow flashing: per-frame shadow depth images and framebuffers
Single shadow depth image shared across MAX_FRAMES=2 in-flight GPU frames
caused a race: frame N's main pass reads shadow map while frame N+1's
shadow pass clears and writes it, producing visible flashing standing
still and while moving.

Fix: give each in-flight frame its own VkImage, VmaAllocation, VkImageView,
and VkFramebuffer for the shadow depth attachment. renderShadowPass() now
indexes all shadow resources by getCurrentFrame(), and layout transitions
track per-frame state in shadowDepthLayout_[frame]. Cleanup loops over
MAX_FRAMES=2. Descriptor sets already written per-frame; updated shadow
image view binding to use the matching per-frame view.
2026-03-09 22:14:32 -07:00
Kelsi
d5de031c23 tbc: fix quest log stride and CMSG_QUESTGIVER_QUERY_QUEST format
TBC 2.4.3 quest log update fields use 4 fields per slot
(questId, state, counts, timer) vs WotLK's 5 (extra counts field).
The wrong stride (5) caused all quest log reads to use wrong indices
beyond the first slot, breaking quest tracking on TBC servers.

TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST is guid(8) + questId(4) = 12
bytes. WotLK added a trailing isDialogContinued(u8) byte that TBC
servers don't expect; sending it caused quest details to not be sent
back on some emulators.
2026-03-09 22:04:18 -07:00
Kelsi
8f0d2cc4ab terrain: pre-load bind point tiles during Hearthstone cast
When the player starts casting Hearthstone (spell IDs 6948/8690),
trigger background terrain loading at the bind point so tiles are
ready when the teleport fires.

- Add HearthstonePreloadCallback to GameHandler, called from
  handleSpellStart when a Hearthstone cast begins.
- Application callback enqueues a 5×5 tile grid around the bind
  point via precacheTiles() (same-map) or starts a file-cache warm
  via startWorldPreload() (cross-map) during the ~10 s cast time.
- On same-map teleport arrival, call processAllReadyTiles() to
  GPU-upload any tiles that finished parsing during the cast before
  the first frame at the new position.

Fixes: player landing in unloaded terrain and falling after Hearthstone.
2026-03-09 21:57:42 -07:00
Kelsi
0a6f88e8ad tbc: fix SMSG_SPELL_START and SMSG_SPELL_GO for TBC 2.4.3
TBC 2.4.3 SMSG_SPELL_START and SMSG_SPELL_GO send full uint64 GUIDs for
casterGuid/casterUnit and hit targets.  WotLK uses packed (variable-length)
GUIDs.  Using readPackedGuid() on a full uint64 reads the first byte as the
bitmask, consuming 1-8 wrong bytes, which shifts all subsequent fields
(spellId, castFlags, castTime) and causes:
  - Cast bar to never show for the player's own spells
  - Sound effects to use the wrong spell ID
  - Hit/miss target tracking to be completely wrong

Additionally, TBC SMSG_SPELL_GO lacks the WotLK timestamp field after
castFlags.

Add TbcPacketParsers::parseSpellStart and ::parseSpellGo using full GUIDs,
add virtual base methods, and route both handlers through virtual dispatch.
2026-03-09 21:48:41 -07:00
Kelsi
921c83df2e tbc: fix SMSG_CAST_RESULT — no castCount prefix in TBC 2.4.3
TBC 2.4.3 SMSG_CAST_RESULT sends spellId(u32) + result(u8) = 5 bytes.
WotLK 3.3.5a added a castCount(u8) prefix making it 6 bytes.  Without
this fix the WotLK parser was reading spellId[0] as castCount, then the
remaining 3 spellId bytes plus result byte as spellId (wrong), and then
whatever follows as result — producing incorrect failure messages and
potentially not clearing the cast bar on TBC.

Add TbcPacketParsers::parseCastResult override and a virtual base method,
then route SMSG_CAST_RESULT through virtual dispatch in the game handler.
2026-03-09 21:46:18 -07:00
Kelsi
1b2c7f595e classic: fix SMSG_CREATURE_QUERY_RESPONSE — no iconName field in 1.12
Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE has no iconName CString between
subName and typeFlags.  The TBC/WotLK parser was reading the typeFlags
uint32 bytes as the iconName string, then reading the remaining bytes as
typeFlags — producing garbage creature type/family/rank values and
corrupting target frame display for all creatures on Classic servers.

Add ClassicPacketParsers::parseCreatureQueryResponse without the iconName
read, and route the game handler through virtual dispatch so the override
is called.
2026-03-09 21:44:07 -07:00
Kelsi
6d21f77d32 game: route aura/spell-list parsing through virtual packet dispatch
AuraUpdateParser and InitialSpellsParser were called as static functions
in the game handler, bypassing the expansion-specific overrides added to
TbcPacketParsers.  Switch them to packetParsers_->parseAuraUpdate() and
packetParsers_->parseInitialSpells() so TBC 2.4.3 servers get the correct
parser for each.
2026-03-09 21:38:14 -07:00
Kelsi
63d8200303 tbc: fix heal log GUID parsing and route combat through virtual dispatch
Add TbcPacketParsers::parseSpellHealLog override using full uint64 GUIDs
(TBC) instead of packed GUIDs (WotLK).  Route handleAttackerStateUpdate,
handleSpellDamageLog, and handleSpellHealLog through the virtual
packetParsers_ interface so expansion-specific overrides are actually
called.  Previously the game handler bypassed virtual dispatch with
direct static parser calls, making all three TBC overrides dead code.
2026-03-09 21:36:12 -07:00
Kelsi
b4f744d000 tbc: fix combat damage parsing for TBC 2.4.3
TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE and SMSG_SPELLNONMELEEDAMAGELOG send
full uint64 GUIDs for attacker/target, while WotLK 3.3.5a uses packed
(variable-length) GUIDs.  Using the WotLK reader on TBC packets consumes
1-8 bytes where a fixed 8 are expected, shifting all subsequent reads
and producing completely wrong damage/absorbed/resisted values.

Add TbcPacketParsers overrides that read plain uint64 GUIDs.  Also note
that TBC SMSG_SPELLNONMELEEDAMAGELOG lacks the WotLK overkill field.
2026-03-09 21:34:02 -07:00
Kelsi
1c967e9628 tbc: fix SMSG_MAIL_LIST_RESULT parsing for TBC 2.4.3
TBC 2.4.3 differs from WotLK in four ways:
- Header: uint8 count only (WotLK: uint32 totalCount + uint8 shownCount),
  so the WotLK parser was reading 4 garbage bytes before the count
- No extra unknown uint32 between itemTextId and stationery in each entry
- Attachment item GUID: full uint64 (WotLK uses uint32 low GUID)
- Attachment enchants: 7 × uint32 id only (WotLK: 7 × {id+duration+charges})

The resulting mis-parse would scramble subject/money/cod/flags for every
mail entry and corrupt all attachment reads.  Add TbcPacketParsers::parseMailList
with the correct TBC 2.4.3 format.
2026-03-09 21:30:45 -07:00
Kelsi
4d1be18c18 wmo: apply MOHD ambient color to interior group lighting
Read the ambient color from the MOHD chunk (BGRA uint32) and store it
on WMOModel as a normalized RGB vec3.  Pass it through ModelData into
the per-batch WMOMaterialUBO (replacing the unused pad[3] bytes, keeping
the struct at 64 bytes).  The GLSL interior branch now floors vertex
colors against the WMO ambient instead of a hardcoded 0.5, so dungeon
interiors respect the artist-specified ambient tint from the WMO root
rather than always clamping to grey.
2026-03-09 21:27:01 -07:00
Kelsi
8561d5c58c tbc: fix gossip message quest parsing for TBC 2.4.3
SMSG_GOSSIP_MESSAGE quest entries in TBC 2.4.3 do not include
questFlags(u32) or isRepeatable(u8) that WotLK 3.3.5a added.
The WotLK default parser reads these 5 bytes, causing all quest titles
in gossip dialogs to be shifted/corrupted on TBC servers.

Add TbcPacketParsers::parseGossipMessage() which parses quest entries
without those fields, fixing NPC quest list display.
2026-03-09 21:20:37 -07:00
Kelsi
38333df260 tbc: fix spell cast format and NPC movement parsing for TBC 2.4.3
CMSG_CAST_SPELL: WotLK adds a castFlags(u8) byte after spellId that TBC
2.4.3 does not have. Add TbcPacketParsers::buildCastSpell() to omit it,
preventing every spell cast from being rejected by TBC servers.

CMSG_USE_ITEM: WotLK adds a glyphIndex(u32) field between itemGuid and
castFlags that TBC 2.4.3 does not have. Add buildUseItem() override.

SMSG_MONSTER_MOVE: WotLK adds a uint8 unk byte after the packed GUID
(MOVEMENTFLAG2_UNK7 toggle) that TBC 2.4.3 does not have. Add
parseMonsterMove() override to fix NPC movement parsing — without this,
all NPC positions, durations, and waypoints parse from the wrong byte
offset, making all NPC movement appear broken on TBC servers.
2026-03-09 21:14:06 -07:00
Kelsi
9d1616a11b audio: stop precast sound on spell completion, failure, or interrupt
Add AudioEngine::playSound2DStoppable() + stopSound() so callers can
hold a handle and cancel playback early. SpellSoundManager::playPrecast()
now stores the handle in activePrecastId_; stopPrecast() cuts the sound.

playCast() calls stopPrecast() before playing the release sound, so the
channeling audio never bleeds past cast time. SMSG_SPELL_FAILURE and
SMSG_CAST_FAILED both call stopPrecast() so interrupted casts silence
immediately.
2026-03-09 21:04:24 -07:00
Kelsi
e0d47040d3 Fix main-thread hang from terrain finalization; two-pass M2 rendering; tile streaming improvements
Hang/GPU device lost fix:
- M2_INSTANCES and WMO_INSTANCES finalization phases now create instances
  incrementally (32 per step / 4 per step) instead of all at once, eliminating
  the >1s main-thread stalls that caused GPU fence timeouts and device loss

M2 two-pass transparent rendering:
- Opaque/alpha-test batches render in pass 1, transparent/additive in pass 2
  (back-to-front sorted) to fix wing transparency showing terrain instead of
  trees — adds hasTransparentBatches flag to skip models with no transparency

Tile streaming improvements:
- Sort new load queue entries nearest-first so critical tiles load before
  distant ones during fast taxi flight
- Increase taxi load radius 6→8 tiles, unload 9→12 for better coverage

Water refraction gated on FSR:
- Disable water refraction when FSR is not active (bugged without upscaling)
- Auto-disable refraction if FSR is turned off while refraction was on
2026-03-09 20:58:49 -07:00
Kelsi
a49c013c89 Fix SMSG_RESUME_CAST_BAR: separate from unrelated opcodes in fallthrough group
SMSG_LOOT_LIST, SMSG_COMPLAIN_RESULT, SMSG_ITEM_REFUND_INFO_RESPONSE,
and SMSG_ITEM_ENCHANT_TIME_UPDATE were incorrectly falling through to the
SMSG_RESUME_CAST_BAR handler, causing those packets to be parsed as
cast bar resume data with a completely different wire format.
2026-03-09 20:40:58 -07:00
Kelsi
3f64f81ec0 Implement MSG_RAID_READY_CHECK_CONFIRM and MSG_RAID_READY_CHECK_FINISHED
Track individual member ready/not-ready responses in chat and summarize
results when all members have responded to the ready check.
2026-03-09 20:38:44 -07:00
Kelsi
95e8fcb88e Implement minimap ping: parse MSG_MINIMAP_PING and render animated ping circles
Parse party member minimap pings (packed GUID + posX + posY), store with
5s lifetime, and render as expanding concentric circles on the minimap.
2026-03-09 20:36:20 -07:00
Kelsi
0562139868 Implement SMSG_LOOT_ITEM_NOTIFY: show party loot notifications in chat
Parse loot item notify packets to display messages like "Thrall loots
[Item Name]." in system chat when a group member loots an item.
2026-03-09 20:31:57 -07:00
Kelsi
cc61732106 Implement SMSG_PARTYKILLLOG: show kill credit messages in chat
- Parse uint64 killerGuid + uint64 victimGuid
- Resolve names from playerNameCache (players) and entity manager (NPCs)
- Show "[Killer] killed [Victim]." as system chat when both names are known
2026-03-09 20:27:02 -07:00
Kelsi
5024e8cb32 Implement SMSG_SET_PROFICIENCY: track weapon/armor proficiency bitmasks
- Parse uint8 itemClass + uint32 subClassMask from SMSG_SET_PROFICIENCY
- Store weaponProficiency_ (itemClass=2) and armorProficiency_ (itemClass=4)
- Expose getWeaponProficiency(), getArmorProficiency(), canUseWeaponSubclass(n),
  canUseArmorSubclass(n) on GameHandler for use by equipment UI
- Enables future equipment slot validation (grey out non-proficient items)
2026-03-09 20:23:38 -07:00
Kelsi
926bcbb50e Implement SMSG_SPELLDISPELLOG dispel feedback and show dispel/steal notifications
- SMSG_SPELLDISPELLOG: parse packed caster/victim + dispel spell + isStolen +
  dispelled spell list; show system message when player dispels or has a buff
  dispelled/stolen (e.g. "Shadow Word: Pain was dispelled." / "You dispelled Renew.")
- SMSG_SPELLSTEALLOG: separated from SPELLDISPELLOG consume group with comment
  explaining the relationship (same wire format, player-facing covered by SPELLDISPELLOG)
2026-03-09 20:20:24 -07:00
Kelsi
151303a20a Implement SMSG_SPELLDAMAGESHIELD, SMSG_SPELLORDAMAGE_IMMUNE; route MSG_MOVE in SMSG_MULTIPLE_MOVES
- SMSG_SPELLDAMAGESHIELD: parse victim/caster/damage fields and show SPELL_DAMAGE
  combat text for player-relevant events (damage shields like Thorns)
- SMSG_SPELLORDAMAGE_IMMUNE: parse packed caster/victim guids and show new
  IMMUNE combat text type when player is involved in an immunity event
- Add CombatTextEntry::IMMUNE type to spell_defines.hpp and render it as
  white "Immune!" in the combat text overlay
- handleCompressedMoves: add MSG_MOVE_* routing so SMSG_MULTIPLE_MOVES
  sub-packets (player movement batches) are dispatched to handleOtherPlayerMovement
  instead of logged as unhandled; fix runtime-opcode lookup (non-static array)
2026-03-09 20:15:34 -07:00
Kelsi
1c1cdf0f23 Fix Windows socket WSAENOTCONN disconnect; add boss encounter frames
Socket fixes (fixes Windows-only connection failure):
- WorldSocket::connect() now waits for non-blocking connect to complete with
  select() before returning, preventing WSAENOTCONN on the first recv() call
  on Windows (Linux handles this implicitly but Windows requires writability
  poll after non-blocking connect)
- Add net::isConnectionClosed() helper: treats WSAENOTCONN/WSAECONNRESET/
  WSAESHUTDOWN/WSAECONNABORTED as graceful peer-close rather than recv errors
- Apply isConnectionClosed() in both WorldSocket and TCPSocket recv loops

UI:
- Add renderBossFrames(): displays boss unit health bars in top-right corner
  when SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT has active slots; supports
  click-to-target and color-coded health bars (red→orange→yellow as HP drops)
2026-03-09 20:05:09 -07:00
Kelsi
b6dfa8b747 Implement SMSG_RESUME_CAST_BAR, SMSG_THREAT_UPDATE, SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT
- SMSG_RESUME_CAST_BAR: parse packed_guid caster/target + spellId + remainingMs +
  totalMs; restores cast bar state when server re-syncs a cast in progress
- SMSG_THREAT_UPDATE: properly consume packed_guid host/target + threat entries
  to suppress unhandled packet warnings
- SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: track up to 5 boss encounter unit guids
  per slot; expose via getEncounterUnitGuid(slot); clear on world transfer
  These guids identify active boss units for raid/boss frame display.
2026-03-09 19:54:32 -07:00
Kelsi
9f340ef456 Fix SMSG_SPELL_DELAYED/EQUIPMENT_SET_SAVED incorrectly sharing PERIODICAURALOG handler
These two opcodes were accidentally falling through to the PERIODICAURALOG
handler which expects packed_guid+packed_guid+uint32+uint32 — wrong format.
Now:
- SMSG_SPELL_DELAYED: parse caster guid + delayMs, extend castTimeRemaining
  on player cast pushback (spell cast bar stays accurate under pushback)
- SMSG_EQUIPMENT_SET_SAVED: simple acknowledge log (no payload needed)
2026-03-09 19:46:52 -07:00
Kelsi
1d33ebbfe4 Wire SMSG_MULTIPLE_MOVES to handleCompressedMoves and parse SMSG_PROCRESIST
- SMSG_MULTIPLE_MOVES uses the same uint8-size+uint16-opcode format as
  SMSG_COMPRESSED_MOVES; route it to handleCompressedMoves() so bundled
  monster movement updates are processed instead of dropped
- SMSG_PROCRESIST: parse caster/victim GUIDs and show MISS combat text
  when the player's proc was resisted by an enemy spell
2026-03-09 19:42:27 -07:00
Kelsi
e56d3ca7de Add spell impact sounds for player-targeted spells and improve achievement messages
- Play SpellSoundManager::playImpact() with correct school when the player
  is hit by another unit's spell (SMSG_SPELL_GO hitTargets check)
- Show achievement name in SMSG_SERVER_FIRST_ACHIEVEMENT notifications
  using the already-loaded achievementNameCache_
- playImpact was fully implemented but never called; now wired up
2026-03-09 19:36:58 -07:00
Kelsi
63c8dfa304 Show achievement names from Achievement.dbc in chat notifications
Previously "Achievement earned! (ID 1234)" was the only message. Now
loadAchievementNameCache() lazily loads Achievement.dbc (field 4 = Title,
verified against WotLK 3.3.5a binary) on first earned event and shows
"Achievement earned: Level 10" or "Player has earned the achievement: ..."
Falls back to ID if DBC is unavailable or entry is missing.
2026-03-09 19:34:33 -07:00
Kelsi
e12e399c0a Implement SMSG_TAXINODE_STATUS parsing and NPC route status cache
Parse the flight-master POI status packet (guid + uint8 status) and cache
it per-NPC in taxiNpcHasRoutes_. Exposes taxiNpcHasRoutes(guid) accessor
for future nameplate/interaction indicators. Previously this packet was
silently consumed without any state tracking.
2026-03-09 19:30:18 -07:00
Kelsi
d4ea416dd6 Fix spell cast audio to use correct magic school from Spell.dbc
Previously all player spell casts played ARCANE school sounds regardless
of the actual spell school. Now loadSpellNameCache() reads SchoolMask
(bitmask, TBC/WotLK) or SchoolEnum (Vanilla/Classic) from Spell.dbc and
stores it in SpellNameEntry. handleSpellStart/handleSpellGo look up the
spell's school and select the correct MagicSchool for cast sounds.

DBC field indices: WotLK SchoolMask=225 (verified), TBC=215, Classic/Turtle
SchoolEnum=1 (Vanilla enum 0-6 converted to bitmask).
2026-03-09 19:24:09 -07:00
35 changed files with 3284 additions and 372 deletions

View file

@ -1,7 +1,7 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1
},
"ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3,

View file

@ -1,7 +1,7 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 124,
"Name": 127, "Tooltip": 154, "Rank": 136
"Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215
},
"ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3,

View file

@ -1,7 +1,7 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1
},
"ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3,

View file

@ -1,7 +1,7 @@
{
"Spell": {
"ID": 0, "Attributes": 4, "IconID": 133,
"Name": 136, "Tooltip": 139, "Rank": 153
"Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225
},
"ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3,
@ -28,6 +28,7 @@
"ReputationBase0": 10, "ReputationBase1": 11,
"ReputationBase2": 12, "ReputationBase3": 13
},
"Achievement": { "ID": 0, "Title": 4, "Description": 21 },
"AreaTable": { "ID": 0, "ExploreFlag": 3 },
"CreatureDisplayInfoExtra": {
"ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4,

View file

@ -29,6 +29,9 @@ layout(set = 1, binding = 1) uniform WMOMaterial {
float heightMapVariance;
float normalMapStrength;
int isLava;
float wmoAmbientR;
float wmoAmbientG;
float wmoAmbientB;
};
layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap;
@ -185,7 +188,13 @@ void main() {
} else if (unlit != 0) {
result = texColor.rgb * shadow;
} else if (isInterior != 0) {
vec3 mocv = max(VertColor.rgb, vec3(0.5));
// WMO interior: vertex colors (MOCV) are pre-baked lighting from the artist.
// The MOHD ambient color tints/floors the vertex colors so dark spots don't
// go completely black, matching the WoW client's interior shading.
vec3 wmoAmbient = vec3(wmoAmbientR, wmoAmbientG, wmoAmbientB);
// Clamp ambient to at least 0.3 to avoid total darkness when MOHD color is zero
wmoAmbient = max(wmoAmbient, vec3(0.3));
vec3 mocv = max(VertColor.rgb, wmoAmbient);
result = texColor.rgb * mocv * shadow;
} else {
vec3 ldir = normalize(-lightDir.xyz);

Binary file not shown.

View file

@ -45,6 +45,11 @@ public:
bool playSound2D(const std::vector<uint8_t>& wavData, float volume = 1.0f, float pitch = 1.0f);
bool playSound2D(const std::string& mpqPath, float volume = 1.0f, float pitch = 1.0f);
// Stoppable 2D sound — returns a non-zero handle, or 0 on failure
uint32_t playSound2DStoppable(const std::vector<uint8_t>& wavData, float volume = 1.0f);
// Stop a sound started with playSound2DStoppable (no-op if already finished)
void stopSound(uint32_t id);
// 3D positional sound playback
bool playSound3D(const std::vector<uint8_t>& wavData, const glm::vec3& position,
float volume = 1.0f, float pitch = 1.0f, float maxDistance = 100.0f);
@ -70,8 +75,10 @@ private:
ma_sound* sound;
void* buffer; // ma_audio_buffer* - Keep audio buffer alive
std::shared_ptr<const std::vector<uint8_t>> pcmDataRef; // Keep decoded PCM alive
uint32_t id = 0; // 0 = anonymous (not stoppable)
};
std::vector<ActiveSound> activeSounds_;
uint32_t nextSoundId_ = 1;
// Music track state
ma_sound* musicSound_ = nullptr;

View file

@ -45,6 +45,7 @@ public:
// Spell casting sounds
void playPrecast(MagicSchool school, SpellPower power); // Channeling/preparation
void stopPrecast(); // Stop precast sound early
void playCast(MagicSchool school); // When spell fires
void playImpact(MagicSchool school, SpellPower power); // When spell hits target
@ -96,6 +97,7 @@ private:
// State tracking
float volumeScale_ = 1.0f;
bool initialized_ = false;
uint32_t activePrecastId_ = 0; // Handle from AudioEngine::playSound2DStoppable()
// Helper methods
bool loadSound(const std::string& path, SpellSample& sample, pipeline::AssetManager* assets);

View file

@ -456,7 +456,55 @@ public:
void dismissPet();
bool hasPet() const { return petGuid_ != 0; }
uint64_t getPetGuid() const { return petGuid_; }
// ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ----
// 10 action bar slots; each entry is a packed uint32:
// bits 0-23 = spell ID (or 0 for empty)
// bits 24-31 = action type (0x00=cast, 0xC0=autocast on, 0x40=autocast off)
static constexpr int PET_ACTION_BAR_SLOTS = 10;
uint32_t getPetActionSlot(int idx) const {
if (idx < 0 || idx >= PET_ACTION_BAR_SLOTS) return 0;
return petActionSlots_[idx];
}
// Pet command/react state from SMSG_PET_MODE or SMSG_PET_SPELLS
uint8_t getPetCommand() const { return petCommand_; } // 0=stay,1=follow,2=attack,3=dismiss
uint8_t getPetReact() const { return petReact_; } // 0=passive,1=defensive,2=aggressive
// Spells the pet knows (from SMSG_PET_SPELLS spell list)
const std::vector<uint32_t>& getPetSpells() const { return petSpellList_; }
// Pet autocast set (spellIds that have autocast enabled)
bool isPetSpellAutocast(uint32_t spellId) const {
return petAutocastSpells_.count(spellId) != 0;
}
// Send CMSG_PET_ACTION to issue a pet command
void sendPetAction(uint32_t action, uint64_t targetGuid = 0);
const std::unordered_set<uint32_t>& getKnownSpells() const { return knownSpells; }
// Player proficiency bitmasks (from SMSG_SET_PROFICIENCY)
// itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing)
// itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield)
uint32_t getWeaponProficiency() const { return weaponProficiency_; }
uint32_t getArmorProficiency() const { return armorProficiency_; }
bool canUseWeaponSubclass(uint32_t subClass) const { return (weaponProficiency_ >> subClass) & 1u; }
bool canUseArmorSubclass(uint32_t subClass) const { return (armorProficiency_ >> subClass) & 1u; }
// Minimap pings from party members
struct MinimapPing {
uint64_t senderGuid = 0;
float wowX = 0.0f; // canonical WoW X (north)
float wowY = 0.0f; // canonical WoW Y (west)
float age = 0.0f; // seconds since received
static constexpr float LIFETIME = 5.0f;
bool isExpired() const { return age >= LIFETIME; }
};
const std::vector<MinimapPing>& getMinimapPings() const { return minimapPings_; }
void tickMinimapPings(float dt) {
for (auto& p : minimapPings_) p.age += dt;
minimapPings_.erase(
std::remove_if(minimapPings_.begin(), minimapPings_.end(),
[](const MinimapPing& p){ return p.isExpired(); }),
minimapPings_.end());
}
bool isCasting() const { return casting; }
bool isGameObjectInteractionCasting() const {
return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0;
@ -465,6 +513,34 @@ public:
float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; }
float getCastTimeRemaining() const { return castTimeRemaining; }
// Unit cast state (tracked per GUID for target frame + boss frames)
struct UnitCastState {
bool casting = false;
uint32_t spellId = 0;
float timeRemaining = 0.0f;
float timeTotal = 0.0f;
};
// Returns cast state for any unit by GUID (empty/non-casting if not found)
const UnitCastState* getUnitCastState(uint64_t guid) const {
auto it = unitCastStates_.find(guid);
return (it != unitCastStates_.end() && it->second.casting) ? &it->second : nullptr;
}
// Convenience helpers for the current target
bool isTargetCasting() const { return getUnitCastState(targetGuid) != nullptr; }
uint32_t getTargetCastSpellId() const {
auto* s = getUnitCastState(targetGuid);
return s ? s->spellId : 0;
}
float getTargetCastProgress() const {
auto* s = getUnitCastState(targetGuid);
return (s && s->timeTotal > 0.0f)
? (s->timeTotal - s->timeRemaining) / s->timeTotal : 0.0f;
}
float getTargetCastTimeRemaining() const {
auto* s = getUnitCastState(targetGuid);
return s ? s->timeRemaining : 0.0f;
}
// Talents
uint8_t getActiveTalentSpec() const { return activeTalentSpec_; }
uint8_t getUnspentTalentPoints() const { return unspentTalentPoints_[activeTalentSpec_]; }
@ -583,6 +659,12 @@ public:
using BindPointCallback = std::function<void(uint32_t mapId, float x, float y, float z)>;
void setBindPointCallback(BindPointCallback cb) { bindPointCallback_ = std::move(cb); }
// Called when the player starts casting Hearthstone so terrain at the bind
// point can be pre-loaded during the cast time.
// Parameters: mapId and canonical (x, y, z) of the bind location.
using HearthstonePreloadCallback = std::function<void(uint32_t mapId, float x, float y, float z)>;
void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); }
// Creature spawn callback (online mode - triggered when creature enters view)
// Parameters: guid, displayId, x, y, z (canonical), orientation
using CreatureSpawnCallback = std::function<void(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation)>;
@ -702,6 +784,11 @@ public:
bool isPlayerGhost() const { return releasedSpirit_; }
bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; }
bool showResurrectDialog() const { return resurrectRequestPending_; }
const std::string& getResurrectCasterName() const { return resurrectCasterName_; }
/** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const;
/** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */
void reclaimCorpse();
void releaseSpirit();
void acceptResurrect();
void declineResurrect();
@ -773,6 +860,13 @@ public:
};
const std::vector<InstanceLockout>& getInstanceLockouts() const { return instanceLockouts_; }
// Boss encounter unit tracking (SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
static constexpr uint32_t kMaxEncounterSlots = 5;
// Returns boss unit guid for the given encounter slot (0 if none)
uint64_t getEncounterUnitGuid(uint32_t slot) const {
return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0;
}
// ---- LFG / Dungeon Finder ----
enum class LfgState : uint8_t {
None = 0,
@ -1016,6 +1110,10 @@ public:
};
const std::unordered_map<uint32_t, TaxiNode>& getTaxiNodes() const { return taxiNodes_; }
uint32_t getTaxiCostTo(uint32_t destNodeId) const;
bool taxiNpcHasRoutes(uint64_t guid) const {
auto it = taxiNpcHasRoutes_.find(guid);
return it != taxiNpcHasRoutes_.end() && it->second;
}
// Vendor
void openVendor(uint64_t npcGuid);
@ -1439,6 +1537,8 @@ private:
void handleWho(network::Packet& packet);
// ---- Social handlers ----
void handleFriendList(network::Packet& packet); // Classic SMSG_FRIEND_LIST
void handleContactList(network::Packet& packet); // WotLK SMSG_CONTACT_LIST (full parse)
void handleFriendStatus(network::Packet& packet);
void handleRandomRoll(network::Packet& packet);
@ -1558,6 +1658,7 @@ private:
// ---- Friend list cache ----
std::unordered_map<std::string, uint64_t> friendsCache; // name -> guid
std::unordered_set<uint64_t> friendGuids_; // all known friend GUIDs (for name backfill)
uint32_t lastContactListMask_ = 0;
uint32_t lastContactListCount_ = 0;
@ -1645,6 +1746,7 @@ private:
UnstuckCallback unstuckGyCallback_;
UnstuckCallback unstuckHearthCallback_;
BindPointCallback bindPointCallback_;
HearthstonePreloadCallback hearthstonePreloadCallback_;
CreatureSpawnCallback creatureSpawnCallback_;
CreatureDespawnCallback creatureDespawnCallback_;
PlayerSpawnCallback playerSpawnCallback_;
@ -1676,10 +1778,15 @@ private:
std::unique_ptr<TransportManager> transportManager_; // Transport movement manager
std::unordered_set<uint32_t> knownSpells;
std::unordered_map<uint32_t, float> spellCooldowns; // spellId -> remaining seconds
uint32_t weaponProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=2
uint32_t armorProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=4
std::vector<MinimapPing> minimapPings_;
uint8_t castCount = 0;
bool casting = false;
uint32_t currentCastSpellId = 0;
float castTimeRemaining = 0.0f;
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
std::unordered_map<uint64_t, UnitCastState> unitCastStates_;
uint64_t pendingGameObjectInteractGuid_ = 0;
// Talents (dual-spec support)
@ -1710,6 +1817,11 @@ private:
std::vector<AuraSlot> playerAuras;
std::vector<AuraSlot> targetAuras;
uint64_t petGuid_ = 0;
uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots)
uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss
uint8_t petReact_ = 1; // 0=passive,1=defensive,2=aggressive
std::vector<uint32_t> petSpellList_; // known pet spells
std::unordered_set<uint32_t> petAutocastSpells_; // spells with autocast on
// ---- Battleground queue state ----
struct BgQueueSlot {
@ -1734,6 +1846,9 @@ private:
// Instance / raid lockouts
std::vector<InstanceLockout> instanceLockouts_;
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot
// LFG / Dungeon Finder state
LfgState lfgState_ = LfgState::None;
uint32_t lfgDungeonId_ = 0; // current dungeon entry
@ -1743,6 +1858,8 @@ private:
// Ready check state
bool pendingReadyCheck_ = false;
uint32_t readyCheckReadyCount_ = 0;
uint32_t readyCheckNotReadyCount_ = 0;
std::string readyCheckInitiator_;
// Faction standings (factionId → absolute standing value)
@ -1878,6 +1995,7 @@ private:
}
// Taxi / Flight Paths
std::unordered_map<uint64_t, bool> taxiNpcHasRoutes_; // guid -> has new/available routes
std::unordered_map<uint32_t, TaxiNode> taxiNodes_;
std::vector<TaxiPathEdge> taxiPathEdges_;
std::unordered_map<uint32_t, std::vector<TaxiPathNode>> taxiPathNodes_; // pathId -> ordered waypoints
@ -1971,9 +2089,14 @@ private:
// Trainer
bool trainerWindowOpen_ = false;
TrainerListData currentTrainerList_;
struct SpellNameEntry { std::string name; std::string rank; };
struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; };
std::unordered_map<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;
// Achievement name cache (lazy-loaded from Achievement.dbc on first earned event)
std::unordered_map<uint32_t, std::string> achievementNameCache_;
bool achievementNameCacheLoaded_ = false;
void loadAchievementNameCache();
std::vector<TrainerTab> trainerTabs_;
void handleTrainerList(network::Packet& packet);
void loadSpellNameCache();
@ -2090,6 +2213,8 @@ private:
float serverPitchRate_ = 3.14159f;
bool playerDead_ = false;
bool releasedSpirit_ = false;
uint32_t corpseMapId_ = 0;
float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f;
// Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially
std::array<RuneSlot, 6> playerRunes_ = [] {
std::array<RuneSlot, 6> r{};
@ -2101,7 +2226,9 @@ private:
uint64_t pendingSpiritHealerGuid_ = 0;
bool resurrectPending_ = false;
bool resurrectRequestPending_ = false;
bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST
uint64_t resurrectCasterGuid_ = 0;
std::string resurrectCasterName_;
bool repopPending_ = false;
uint64_t lastRepopRequestMs_ = 0;

View file

@ -93,6 +93,11 @@ public:
return SpellDamageLogParser::parse(packet, data);
}
/** Parse SMSG_SPELLHEALLOG */
virtual bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) {
return SpellHealLogParser::parse(packet, data);
}
// --- Spells ---
/** Parse SMSG_INITIAL_SPELLS */
@ -100,11 +105,34 @@ public:
return InitialSpellsParser::parse(packet, data);
}
/** Parse SMSG_SPELL_START */
virtual bool parseSpellStart(network::Packet& packet, SpellStartData& data) {
return SpellStartParser::parse(packet, data);
}
/** Parse SMSG_SPELL_GO */
virtual bool parseSpellGo(network::Packet& packet, SpellGoData& data) {
return SpellGoParser::parse(packet, data);
}
/** Parse SMSG_CAST_FAILED */
virtual bool parseCastFailed(network::Packet& packet, CastFailedData& data) {
return CastFailedParser::parse(packet, data);
}
/** Parse SMSG_CAST_RESULT header (spellId + result), expansion-aware.
* WotLK: castCount(u8) + spellId(u32) + result(u8)
* TBC/Classic: spellId(u32) + result(u8) (no castCount prefix)
*/
virtual bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) {
// WotLK default: skip castCount, read spellId + result
if (packet.getSize() - packet.getReadPos() < 6) return false;
packet.readUInt8(); // castCount
spellId = packet.readUInt32();
result = packet.readUInt8();
return true;
}
/** Parse SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL */
virtual bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) {
return AuraUpdateParser::parse(packet, data, isAll);
@ -122,6 +150,13 @@ public:
return NameQueryResponseParser::parse(packet, data);
}
// --- Creature Query ---
/** Parse SMSG_CREATURE_QUERY_RESPONSE */
virtual bool parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) {
return CreatureQueryResponseParser::parse(packet, data);
}
// --- Item Query ---
/** Build CMSG_ITEM_QUERY_SINGLE */
@ -287,6 +322,37 @@ public:
bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override;
bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override;
network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override;
// TBC 2.4.3 CMSG_CAST_SPELL has no castFlags byte (WotLK added it)
network::Packet buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) override;
// TBC 2.4.3 CMSG_USE_ITEM has no glyphIndex field (WotLK added it)
network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) override;
// TBC 2.4.3 SMSG_MONSTER_MOVE has no unk byte after packed GUID (WotLK added it)
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override;
// TBC 2.4.3 SMSG_GOSSIP_MESSAGE quests lack questFlags(u32)+isRepeatable(u8) (WotLK added them)
bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override;
// TBC 2.4.3 SMSG_CAST_RESULT: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix
bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) override;
// TBC 2.4.3 SMSG_CAST_FAILED: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix
bool parseCastFailed(network::Packet& packet, CastFailedData& data) override;
// TBC 2.4.3 SMSG_SPELL_START: full uint64 GUIDs (WotLK uses packed GUIDs)
bool parseSpellStart(network::Packet& packet, SpellStartData& data) override;
// TBC 2.4.3 SMSG_SPELL_GO: full uint64 GUIDs, no timestamp field (WotLK added one)
bool parseSpellGo(network::Packet& packet, SpellGoData& data) override;
// TBC 2.4.3 SMSG_MAIL_LIST_RESULT: uint8 count (not uint32+uint8), no body field,
// attachment uses uint64 itemGuid (not uint32), enchants are 7×u32 id-only (not 7×{id+dur+charges})
bool parseMailList(network::Packet& packet, std::vector<MailMessage>& inbox) override;
// TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE uses full uint64 GUIDs (WotLK uses packed GUIDs)
bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override;
// TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG uses full uint64 GUIDs (WotLK uses packed GUIDs)
bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override;
// TBC 2.4.3 SMSG_SPELLHEALLOG uses full uint64 GUIDs (WotLK uses packed GUIDs)
bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override;
// TBC 2.4.3 quest log has 4 update fields per slot (questId, state, counts, timer)
// WotLK expands this to 5 (splits counts into two fields).
uint8_t questLogStride() const override { return 4; }
// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST: guid(8) + questId(4) — no trailing
// isDialogContinued byte that WotLK added
network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override;
};
/**
@ -317,6 +383,8 @@ public:
bool parseCastFailed(network::Packet& packet, CastFailedData& data) override;
bool parseMessageChat(network::Packet& packet, MessageChatData& data) override;
bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override;
// Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName string that TBC/WotLK include
bool parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) override;
bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override;
bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override;
bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override;
@ -339,6 +407,19 @@ public:
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override {
return MonsterMoveParser::parseVanilla(packet, data);
}
// Classic 1.12 uses PackedGuid (not full uint64) and uint16 castFlags (not uint32)
bool parseSpellStart(network::Packet& packet, SpellStartData& data) override;
bool parseSpellGo(network::Packet& packet, SpellGoData& data) override;
// Classic 1.12 melee/spell log packets use PackedGuid (not full uint64)
bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override;
bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override;
bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override;
// Classic 1.12 has SMSG_AURA_UPDATE (unlike TBC which doesn't);
// format differs from WotLK: no caster GUID, DURATION flag is 0x10 not 0x20
bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) override;
// Classic 1.12 SMSG_NAME_QUERY_RESPONSE: full uint64 guid + name + realmName CString +
// uint32 race + uint32 gender + uint32 class (TBC Variant A skips the realmName CString)
bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override;
};
/**

View file

@ -51,7 +51,7 @@ struct CombatTextEntry {
enum Type : uint8_t {
MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK,
CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL,
ENERGIZE, XP_GAIN
ENERGIZE, XP_GAIN, IMMUNE
};
Type type;
int32_t amount = 0;

View file

@ -717,7 +717,9 @@ struct TextEmoteData {
*/
class TextEmoteParser {
public:
static bool parse(network::Packet& packet, TextEmoteData& data);
// legacyFormat: Classic 1.12 and TBC 2.4.3 send textEmoteId+emoteNum first, then senderGuid.
// WotLK 3.3.5a reverses this: senderGuid first, then textEmoteId+emoteNum.
static bool parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat = false);
};
// ============================================================
@ -1729,7 +1731,8 @@ public:
/** CMSG_PET_ACTION packet builder */
class PetActionPacket {
public:
static network::Packet build(uint64_t petGuid, uint32_t action);
/** CMSG_PET_ACTION: petGuid + action + targetGuid (0 = no target) */
static network::Packet build(uint64_t petGuid, uint32_t action, uint64_t targetGuid = 0);
};
/** SMSG_CAST_FAILED data */
@ -1765,6 +1768,11 @@ public:
};
/** SMSG_SPELL_GO data (simplified) */
struct SpellGoMissEntry {
uint64_t targetGuid = 0;
uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST
};
struct SpellGoData {
uint64_t casterGuid = 0;
uint64_t casterUnit = 0;
@ -1774,6 +1782,7 @@ struct SpellGoData {
uint8_t hitCount = 0;
std::vector<uint64_t> hitTargets;
uint8_t missCount = 0;
std::vector<SpellGoMissEntry> missTargets;
bool isValid() const { return spellId != 0; }
};
@ -1848,7 +1857,9 @@ public:
/** SMSG_GROUP_LIST parser */
class GroupListParser {
public:
static bool parse(network::Packet& packet, GroupListData& data);
// hasRoles: WotLK 3.3.5a added a roles byte at group level and per-member for LFD.
// Classic 1.12 and TBC 2.4.3 do not send this byte.
static bool parse(network::Packet& packet, GroupListData& data, bool hasRoles = true);
};
/** SMSG_PARTY_COMMAND_RESULT data */
@ -2220,7 +2231,9 @@ struct TrainerListData {
class TrainerListParser {
public:
static bool parse(network::Packet& packet, TrainerListData& data);
// isClassic: Classic 1.12 per-spell layout has no profDialog/profButton fields
// (reqLevel immediately follows cost), plus a trailing unk uint32 per entry.
static bool parse(network::Packet& packet, TrainerListData& data, bool isClassic = false);
};
class TrainerBuySpellPacket {
@ -2266,6 +2279,13 @@ public:
static network::Packet build(bool accept);
};
/** CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3) — switch dual-spec talent group */
class ActivateTalentGroupPacket {
public:
/** @param group 0 = primary spec, 1 = secondary spec */
static network::Packet build(uint32_t group);
};
// ============================================================
// Taxi / Flight Paths
// ============================================================
@ -2612,7 +2632,8 @@ public:
/** SMSG_AUCTION_LIST_RESULT parser (shared for browse/owner/bidder) */
class AuctionListResultParser {
public:
static bool parse(network::Packet& packet, AuctionListResult& data);
// numEnchantSlots: Classic 1.12 = 1, TBC/WotLK = 3 (extra enchant slots per entry)
static bool parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots = 3);
};
/** SMSG_AUCTION_COMMAND_RESULT parser */

View file

@ -91,6 +91,20 @@ inline bool isWouldBlock(int err) {
#endif
}
// Returns true for errors that mean the peer closed the connection cleanly.
// On Windows, WSAENOTCONN / WSAECONNRESET / WSAESHUTDOWN can be returned by
// recv() when the server closes the connection, rather than returning 0.
inline bool isConnectionClosed(int err) {
#ifdef _WIN32
return err == WSAENOTCONN || // socket not connected (server closed)
err == WSAECONNRESET || // connection reset by peer
err == WSAESHUTDOWN || // socket shut down
err == WSAECONNABORTED; // connection aborted
#else
return err == ENOTCONN || err == ECONNRESET;
#endif
}
inline bool isInProgress(int err) {
#ifdef _WIN32
return err == WSAEWOULDBLOCK || err == WSAEALREADY;

View file

@ -185,6 +185,7 @@ struct WMOModel {
uint32_t nDoodadDefs;
uint32_t nDoodadSets;
glm::vec3 ambientColor; // MOHD ambient color (used for interior group lighting)
glm::vec3 boundingBoxMin;
glm::vec3 boundingBoxMax;

View file

@ -122,6 +122,7 @@ struct M2ModelGPU {
bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed)
bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback)
bool hasTextureAnimation = false; // True if any batch has UV animation
bool hasTransparentBatches = false; // True if any batch uses alpha-blend or additive (blendMode >= 2)
uint8_t availableLODs = 0; // Bitmask: bit N set if any batch has submeshLevel==N
// Particle emitter data (kept from M2Model)

View file

@ -241,13 +241,17 @@ private:
std::unique_ptr<game::ZoneManager> zoneManager;
// Shadow mapping (Vulkan)
static constexpr uint32_t SHADOW_MAP_SIZE = 4096;
VkImage shadowDepthImage = VK_NULL_HANDLE;
VmaAllocation shadowDepthAlloc = VK_NULL_HANDLE;
VkImageView shadowDepthView = VK_NULL_HANDLE;
// Per-frame shadow resources: each in-flight frame has its own depth image and
// framebuffer so that frame N's shadow read and frame N+1's shadow write don't
// race on the same image across concurrent GPU submissions.
// Array size must match MAX_FRAMES (= 2, defined in the private section below).
VkImage shadowDepthImage[2] = {};
VmaAllocation shadowDepthAlloc[2] = {};
VkImageView shadowDepthView[2] = {};
VkSampler shadowSampler = VK_NULL_HANDLE;
VkRenderPass shadowRenderPass = VK_NULL_HANDLE;
VkFramebuffer shadowFramebuffer = VK_NULL_HANDLE;
VkImageLayout shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED;
VkFramebuffer shadowFramebuffer[2] = {};
VkImageLayout shadowDepthLayout_[2] = {};
glm::mat4 lightSpaceMatrix = glm::mat4(1.0f);
glm::vec3 shadowCenter = glm::vec3(0.0f);
bool shadowCenterInitialized = false;

View file

@ -153,7 +153,9 @@ struct FinalizingTile {
// Progress indices within current phase
size_t m2ModelIndex = 0; // Next M2 model to upload
size_t m2InstanceIndex = 0; // Next M2 placement to instantiate
size_t wmoModelIndex = 0; // Next WMO model to upload
size_t wmoInstanceIndex = 0; // Next WMO placement to instantiate
size_t wmoDoodadIndex = 0; // Next WMO doodad to upload
// Incremental terrain upload state (splits TERRAIN phase across frames)

View file

@ -353,7 +353,9 @@ private:
float heightMapVariance; // 40 (low variance = skip POM)
float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated)
int32_t isLava; // 48 (1=lava/magma UV scroll)
float pad[3]; // 52-60 padding to 64 bytes
float wmoAmbientR; // 52 (interior ambient color R)
float wmoAmbientG; // 56 (interior ambient color G)
float wmoAmbientB; // 60 (interior ambient color B)
}; // 64 bytes total
/**
@ -472,6 +474,7 @@ private:
std::vector<GroupResources> groups;
glm::vec3 boundingBoxMin;
glm::vec3 boundingBoxMax;
glm::vec3 wmoAmbientColor{0.5f, 0.5f, 0.5f}; // From MOHD, used for interior lighting
bool isLowPlatform = false;
// Doodad templates (M2 models placed in WMO, stored for instancing)

View file

@ -210,6 +210,7 @@ private:
void renderMirrorTimers(game::GameHandler& gameHandler);
void renderCombatText(game::GameHandler& gameHandler);
void renderPartyFrames(game::GameHandler& gameHandler);
void renderBossFrames(game::GameHandler& gameHandler);
void renderGroupInvitePopup(game::GameHandler& gameHandler);
void renderDuelRequestPopup(game::GameHandler& gameHandler);
void renderLootRollPopup(game::GameHandler& gameHandler);
@ -227,6 +228,7 @@ private:
void renderTrainerWindow(game::GameHandler& gameHandler);
void renderTaxiWindow(game::GameHandler& gameHandler);
void renderDeathScreen(game::GameHandler& gameHandler);
void renderReclaimCorpseButton(game::GameHandler& gameHandler);
void renderResurrectDialog(game::GameHandler& gameHandler);
void renderEscapeMenu();
void renderSettingsWindow();
@ -245,6 +247,7 @@ private:
void renderDungeonFinderWindow(game::GameHandler& gameHandler);
void renderInstanceLockouts(game::GameHandler& gameHandler);
void renderNameplates(game::GameHandler& gameHandler);
void renderBattlegroundScore(game::GameHandler& gameHandler);
/**
* Inventory screen

View file

@ -288,11 +288,77 @@ bool AudioEngine::playSound2D(const std::vector<uint8_t>& wavData, float volume,
}
// Track this sound for cleanup (decoded PCM shared across plays)
activeSounds_.push_back({sound, audioBuffer, decoded.pcmData});
activeSounds_.push_back({sound, audioBuffer, decoded.pcmData, 0u});
return true;
}
uint32_t AudioEngine::playSound2DStoppable(const std::vector<uint8_t>& wavData, float volume) {
if (!initialized_ || !engine_ || wavData.empty()) return 0;
if (masterVolume_ <= 0.0f) return 0;
DecodedWavCacheEntry decoded;
if (!decodeWavCached(wavData, decoded) || !decoded.pcmData || decoded.frames == 0) return 0;
ma_audio_buffer_config bufferConfig = ma_audio_buffer_config_init(
decoded.format, decoded.channels, decoded.frames, decoded.pcmData->data(), nullptr);
bufferConfig.sampleRate = decoded.sampleRate;
ma_audio_buffer* audioBuffer = static_cast<ma_audio_buffer*>(std::malloc(sizeof(ma_audio_buffer)));
if (!audioBuffer) return 0;
if (ma_audio_buffer_init(&bufferConfig, audioBuffer) != MA_SUCCESS) {
std::free(audioBuffer);
return 0;
}
ma_sound* sound = static_cast<ma_sound*>(std::malloc(sizeof(ma_sound)));
if (!sound) {
ma_audio_buffer_uninit(audioBuffer);
std::free(audioBuffer);
return 0;
}
ma_result result = ma_sound_init_from_data_source(
engine_, audioBuffer,
MA_SOUND_FLAG_DECODE | MA_SOUND_FLAG_ASYNC | MA_SOUND_FLAG_NO_PITCH | MA_SOUND_FLAG_NO_SPATIALIZATION,
nullptr, sound);
if (result != MA_SUCCESS) {
ma_audio_buffer_uninit(audioBuffer);
std::free(audioBuffer);
std::free(sound);
return 0;
}
ma_sound_set_volume(sound, volume);
if (ma_sound_start(sound) != MA_SUCCESS) {
ma_sound_uninit(sound);
ma_audio_buffer_uninit(audioBuffer);
std::free(audioBuffer);
std::free(sound);
return 0;
}
uint32_t id = nextSoundId_++;
if (nextSoundId_ == 0) nextSoundId_ = 1; // Skip 0 (sentinel)
activeSounds_.push_back({sound, audioBuffer, decoded.pcmData, id});
return id;
}
void AudioEngine::stopSound(uint32_t id) {
if (id == 0) return;
for (auto it = activeSounds_.begin(); it != activeSounds_.end(); ++it) {
if (it->id == id) {
ma_sound_stop(it->sound);
ma_sound_uninit(it->sound);
std::free(it->sound);
ma_audio_buffer* buffer = static_cast<ma_audio_buffer*>(it->buffer);
ma_audio_buffer_uninit(buffer);
std::free(buffer);
activeSounds_.erase(it);
return;
}
}
}
bool AudioEngine::playSound2D(const std::string& mpqPath, float volume, float pitch) {
if (!assetManager_) {
LOG_WARNING("AudioEngine::playSound2D(path): no AssetManager set");

View file

@ -220,12 +220,22 @@ void SpellSoundManager::playPrecast(MagicSchool school, SpellPower power) {
return;
}
if (library) {
playSound(*library);
if (library && !library->empty() && (*library)[0].loaded) {
stopPrecast(); // Stop any previous precast still playing
float volume = 0.75f * volumeScale_;
activePrecastId_ = AudioEngine::instance().playSound2DStoppable((*library)[0].data, volume);
}
}
void SpellSoundManager::stopPrecast() {
if (activePrecastId_ != 0) {
AudioEngine::instance().stopSound(activePrecastId_);
activePrecastId_ = 0;
}
}
void SpellSoundManager::playCast(MagicSchool school) {
stopPrecast(); // Ensure precast doesn't overlap the cast sound
switch (school) {
case MagicSchool::FIRE:
playSound(castFireSounds_);

View file

@ -1108,8 +1108,8 @@ void Application::update(float deltaTime) {
// Taxi flights move fast (32 u/s) — load further ahead so terrain is ready
// before the camera arrives. Keep updates frequent to spot new tiles early.
renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.033f : 0.033f);
renderer->getTerrainManager()->setLoadRadius(onTaxi ? 6 : 4);
renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 9 : 7);
renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4);
renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7);
renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi);
}
lastTaxiFlight_ = onTaxi;
@ -1710,6 +1710,10 @@ 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();
return;
}
@ -1950,6 +1954,51 @@ void Application::setupUICallbacks() {
LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")");
});
// Hearthstone preload callback: begin loading terrain at the bind point as soon as
// the player starts casting Hearthstone. The ~10 s cast gives enough time for
// the background streaming workers to bring tiles into the cache so the player
// lands on solid ground instead of falling through un-loaded terrain.
gameHandler->setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) {
if (!renderer || !assetManager) return;
auto* terrainMgr = renderer->getTerrainManager();
if (!terrainMgr) return;
// Resolve map name from the cached Map.dbc table
std::string mapName;
if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) {
mapName = it->second;
} else {
mapName = mapIdToName(mapId);
}
if (mapName.empty()) mapName = "Azeroth";
if (mapId == loadedMapId_) {
// Same map: pre-enqueue tiles around the bind point so workers start
// loading them now. Uses render-space coords (canonicalToRender).
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> tiles;
tiles.reserve(25);
for (int dy = -2; dy <= 2; dy++)
for (int dx = -2; dx <= 2; dx++)
tiles.push_back({tileX + dx, tileY + dy});
terrainMgr->precacheTiles(tiles);
LOG_INFO("Hearthstone preload: enqueued ", tiles.size(),
" tiles around bind point (same map) tile=[", tileX, ",", tileY, "]");
} else {
// Different map: warm the file cache so ADT parsing is fast when
// loadOnlineWorldTerrain runs its blocking load loop.
// homeBindPos_ is canonical; startWorldPreload expects server coords.
glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z));
startWorldPreload(mapId, mapName, server.x, server.y);
LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName,
"' (id=", mapId, ")");
}
});
// Faction hostility map is built in buildFactionHostilityMap() when character enters world
// Creature spawn callback (online mode) - spawn creature models

File diff suppressed because it is too large Load diff

View file

@ -314,6 +314,308 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo
return packet;
}
// ============================================================================
// Classic parseSpellStart — Vanilla 1.12 SMSG_SPELL_START
//
// Key differences from TBC:
// - GUIDs are PackedGuid (variable-length byte mask + non-zero bytes),
// NOT full uint64 as in TBC/WotLK.
// - castFlags is uint16 (NOT uint32 as in TBC/WotLK).
// - SpellCastTargets uses uint16 targetFlags (NOT uint32 as in TBC).
//
// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount)
// + uint32(spellId) + uint16(castFlags) + uint32(castTime)
// + uint16(targetFlags) [+ PackedGuid(unitTarget) if TARGET_FLAG_UNIT]
// ============================================================================
bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 2) return false;
data.casterGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 1) return false;
data.casterUnit = UpdateObjectParser::readPackedGuid(packet);
// uint8 castCount + uint32 spellId + uint16 castFlags + uint32 castTime = 11 bytes
if (rem() < 11) return false;
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK)
data.castTime = packet.readUInt32();
// SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK)
if (rem() < 2) return true;
uint16_t targetFlags = packet.readUInt16();
// TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID
if (((targetFlags & 0x02) || (targetFlags & 0x800)) && rem() >= 1) {
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
}
LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
return true;
}
// ============================================================================
// Classic parseSpellGo — Vanilla 1.12 SMSG_SPELL_GO
//
// Same GUID and castFlags format differences as parseSpellStart:
// - GUIDs are PackedGuid (not full uint64)
// - castFlags is uint16 (not uint32)
// - Hit/miss target GUIDs are also PackedGuid in Vanilla
//
// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount)
// + uint32(spellId) + uint16(castFlags)
// + uint8(hitCount) + [PackedGuid(hitTarget) × hitCount]
// + uint8(missCount) + [PackedGuid(missTarget) + uint8(missType)] × missCount
// ============================================================================
bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 2) return false;
data.casterGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 1) return false;
data.casterUnit = UpdateObjectParser::readPackedGuid(packet);
// uint8 castCount + uint32 spellId + uint16 castFlags = 7 bytes
if (rem() < 7) return false;
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK)
// Hit targets
if (rem() < 1) return true;
data.hitCount = packet.readUInt8();
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) {
data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet));
}
// Miss targets
if (rem() < 1) return true;
data.missCount = packet.readUInt8();
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) {
SpellGoMissEntry m;
m.targetGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 1) break;
m.missType = packet.readUInt8();
data.missTargets.push_back(m);
}
LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
return true;
}
// ============================================================================
// Classic parseAttackerStateUpdate — Vanilla 1.12 SMSG_ATTACKERSTATEUPDATE
//
// Identical to TBC format except GUIDs are PackedGuid (not full uint64).
// Format: uint32(hitInfo) + PackedGuid(attacker) + PackedGuid(target)
// + int32(totalDamage) + uint8(subDamageCount)
// + [per sub: uint32(schoolMask) + float(damage) + uint32(intDamage)
// + uint32(absorbed) + uint32(resisted)]
// + uint32(victimState) + int32(overkill) [+ uint32(blocked)]
// ============================================================================
bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 5) return false; // hitInfo(4) + at least GUID mask byte(1)
data.hitInfo = packet.readUInt32();
data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla
if (rem() < 1) return false;
data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla
if (rem() < 5) return false; // int32 totalDamage + uint8 subDamageCount
data.totalDamage = static_cast<int32_t>(packet.readUInt32());
data.subDamageCount = packet.readUInt8();
for (uint8_t i = 0; i < data.subDamageCount && rem() >= 20; ++i) {
SubDamage sub;
sub.schoolMask = packet.readUInt32();
sub.damage = packet.readFloat();
sub.intDamage = packet.readUInt32();
sub.absorbed = packet.readUInt32();
sub.resisted = packet.readUInt32();
data.subDamages.push_back(sub);
}
if (rem() < 8) return true;
data.victimState = packet.readUInt32();
data.overkill = static_cast<int32_t>(packet.readUInt32());
if (rem() >= 4) {
data.blocked = packet.readUInt32();
}
LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
return true;
}
// ============================================================================
// Classic parseSpellDamageLog — Vanilla 1.12 SMSG_SPELLNONMELEEDAMAGELOG
//
// Identical to TBC except GUIDs are PackedGuid (not full uint64).
// Format: PackedGuid(target) + PackedGuid(caster) + uint32(spellId)
// + uint32(damage) + uint8(schoolMask) + uint32(absorbed) + uint32(resisted)
// + uint8(periodicLog) + uint8(unused) + uint32(blocked) + uint32(flags)
// ============================================================================
bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 2) return false;
data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla
if (rem() < 1) return false;
data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla
// uint32(spellId) + uint32(damage) + uint8(schoolMask) + uint32(absorbed)
// + uint32(resisted) + uint8 + uint8 + uint32(blocked) + uint32(flags) = 21 bytes
if (rem() < 21) return false;
data.spellId = packet.readUInt32();
data.damage = packet.readUInt32();
data.schoolMask = packet.readUInt8();
data.absorbed = packet.readUInt32();
data.resisted = packet.readUInt32();
packet.readUInt8(); // periodicLog
packet.readUInt8(); // unused
packet.readUInt32(); // blocked
uint32_t flags = packet.readUInt32();
data.isCrit = (flags & 0x02) != 0;
data.overkill = 0; // no overkill field in Vanilla (same as TBC)
LOG_INFO("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
return true;
}
// ============================================================================
// Classic parseSpellHealLog — Vanilla 1.12 SMSG_SPELLHEALLOG
//
// Identical to TBC except GUIDs are PackedGuid (not full uint64).
// Format: PackedGuid(target) + PackedGuid(caster) + uint32(spellId)
// + uint32(heal) + uint32(overheal) + uint8(crit)
// ============================================================================
bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 2) return false;
data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla
if (rem() < 1) return false;
data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla
if (rem() < 13) return false; // uint32 + uint32 + uint32 + uint8 = 13 bytes
data.spellId = packet.readUInt32();
data.heal = packet.readUInt32();
data.overheal = packet.readUInt32();
data.isCrit = (packet.readUInt8() != 0);
LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
return true;
}
// ============================================================================
// Classic parseAuraUpdate — Vanilla 1.12 SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL
//
// Classic has SMSG_AURA_UPDATE (TBC does not — TBC uses a different aura system
// and the TBC override returns false with a warning). Classic inherits TBC's
// override by default, so this override is needed to restore aura tracking.
//
// Classic aura flags differ from WotLK:
// 0x01/0x02/0x04 = effect indices active (same as WotLK)
// 0x08 = CANCELABLE / NOT-NEGATIVE (WotLK: 0x08 = NOT_CASTER)
// 0x10 = DURATION (WotLK: 0x20 = DURATION)
// 0x20 = NOT_CASTER (WotLK: no caster GUID at all if 0x08)
// 0x40 = POSITIVE (WotLK: 0x40 = EFFECT_AMOUNTS)
//
// Key differences from WotLK parser:
// - No caster GUID field in Classic SMSG_AURA_UPDATE packets
// - DURATION bit is 0x10, not 0x20
// - No effect amounts field (WotLK 0x40 = EFFECT_AMOUNTS does not exist here)
//
// Format: PackedGuid(entity) + [uint8(slot) + uint32(spellId)
// [+ uint8(flags) + uint8(level) + uint8(charges)
// + [uint32(maxDuration) + uint32(duration) if flags & 0x10]]*
// ============================================================================
bool ClassicPacketParsers::parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 1) return false;
data.guid = UpdateObjectParser::readPackedGuid(packet);
while (rem() > 0) {
if (rem() < 1) break;
uint8_t slot = packet.readUInt8();
if (rem() < 4) break;
uint32_t spellId = packet.readUInt32();
AuraSlot aura;
if (spellId != 0) {
aura.spellId = spellId;
if (rem() < 3) { data.updates.push_back({slot, aura}); break; }
aura.flags = packet.readUInt8();
aura.level = packet.readUInt8();
aura.charges = packet.readUInt8();
// Classic DURATION flag is 0x10 (WotLK uses 0x20)
if ((aura.flags & 0x10) && rem() >= 8) {
aura.maxDurationMs = static_cast<int32_t>(packet.readUInt32());
aura.durationMs = static_cast<int32_t>(packet.readUInt32());
}
// No caster GUID field in Classic (WotLK added it gated by 0x08 NOT_CASTER)
// No effect amounts field in Classic (WotLK added it gated by 0x40)
}
data.updates.push_back({slot, aura});
if (!isAll) break;
}
LOG_DEBUG("[Classic] Aura update for 0x", std::hex, data.guid, std::dec,
": ", data.updates.size(), " slots");
return true;
}
// ============================================================================
// Classic SMSG_NAME_QUERY_RESPONSE format (1.12 / vmangos):
// uint64 guid (full, GetObjectGuid)
// CString name
// CString realmName (usually empty = single \0 byte)
// uint32 race
// uint32 gender
// uint32 class
//
// TBC Variant A (inherited from TbcPacketParsers) skips the realmName CString,
// causing it to misread the uint32 race field (absorbs the realmName \0 byte
// as the low byte), producing race=0 and shifted gender/class values.
// ============================================================================
bool ClassicPacketParsers::parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) {
data = NameQueryResponseData{};
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 8) return false;
data.guid = packet.readUInt64(); // full uint64, not PackedGuid
data.name = packet.readString(); // null-terminated name
if (rem() == 0) return !data.name.empty();
data.realmName = packet.readString(); // null-terminated realm name (usually "")
if (rem() < 12) return !data.name.empty();
uint32_t race = packet.readUInt32();
uint32_t gender = packet.readUInt32();
uint32_t cls = packet.readUInt32();
data.race = static_cast<uint8_t>(race & 0xFF);
data.gender = static_cast<uint8_t>(gender & 0xFF);
data.classId = static_cast<uint8_t>(cls & 0xFF);
data.found = 0;
LOG_DEBUG("[Classic] Name query response: ", data.name,
" (race=", (int)data.race, " gender=", (int)data.gender,
" class=", (int)data.classId, ")");
return !data.name.empty();
}
// ============================================================================
// Classic SMSG_CAST_FAILED: no castCount byte (added in TBC/WotLK)
// Format: spellId(u32) + result(u8)
@ -1282,6 +1584,16 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai
/*activateAccept*/ packet.readUInt8();
data.suggestedPlayers = packet.readUInt32();
// Vanilla 1.12: emote section before reward items
// Format: emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t emoteCount = packet.readUInt32();
for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
packet.readUInt32(); // delay
packet.readUInt32(); // type
}
}
// Choice reward items: variable count + 3 uint32s each
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t choiceCount = packet.readUInt32();
@ -1309,5 +1621,42 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai
return true;
}
// ============================================================================
// ClassicPacketParsers::parseCreatureQueryResponse
//
// Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName CString field
// that TBC 2.4.3 and WotLK 3.3.5a include between subName and typeFlags.
// Without this override, the TBC/WotLK parser reads typeFlags bytes as the
// iconName string, shifting typeFlags/creatureType/family/rank by 1-4 bytes.
// ============================================================================
bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet,
CreatureQueryResponseData& data) {
data.entry = packet.readUInt32();
if (data.entry & 0x80000000) {
data.entry &= ~0x80000000;
data.name = "";
return true;
}
data.name = packet.readString();
packet.readString(); // name2
packet.readString(); // name3
packet.readString(); // name4
data.subName = packet.readString();
// NOTE: NO iconName field in Classic 1.12 — goes straight to typeFlags
if (packet.getReadPos() + 16 > packet.getSize()) {
LOG_WARNING("[Classic] Creature query: truncated at typeFlags (entry=", data.entry, ")");
return true;
}
data.typeFlags = packet.readUInt32();
data.creatureType = packet.readUInt32();
data.family = packet.readUInt32();
data.rank = packet.readUInt32();
LOG_DEBUG("[Classic] Creature query: ", data.name, " type=", data.creatureType,
" rank=", data.rank);
return true;
}
} // namespace game
} // namespace wowee

View file

@ -497,6 +497,199 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa
return false;
}
// ============================================================================
// TBC 2.4.3 SMSG_GOSSIP_MESSAGE
// Identical to WotLK except each quest entry lacks questFlags(u32) and
// isRepeatable(u8) that WotLK added. Without this override the WotLK parser
// reads those 5 bytes as part of the quest title, corrupting all gossip quests.
// ============================================================================
bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessageData& data) {
if (packet.getSize() - packet.getReadPos() < 16) return false;
data.npcGuid = packet.readUInt64();
data.menuId = packet.readUInt32(); // TBC added menuId (Classic doesn't have it)
data.titleTextId = packet.readUInt32();
uint32_t optionCount = packet.readUInt32();
data.options.clear();
data.options.reserve(optionCount);
for (uint32_t i = 0; i < optionCount; ++i) {
GossipOption opt;
opt.id = packet.readUInt32();
opt.icon = packet.readUInt8();
opt.isCoded = (packet.readUInt8() != 0);
opt.boxMoney = packet.readUInt32();
opt.text = packet.readString();
opt.boxText = packet.readString();
data.options.push_back(opt);
}
uint32_t questCount = packet.readUInt32();
data.quests.clear();
data.quests.reserve(questCount);
for (uint32_t i = 0; i < questCount; ++i) {
GossipQuestItem quest;
quest.questId = packet.readUInt32();
quest.questIcon = packet.readUInt32();
quest.questLevel = static_cast<int32_t>(packet.readUInt32());
// TBC 2.4.3: NO questFlags(u32) and NO isRepeatable(u8) here
// WotLK adds these 5 bytes — reading them from TBC garbles the quest title
quest.questFlags = 0;
quest.isRepeatable = 0;
quest.title = normalizeWowTextTokens(packet.readString());
data.quests.push_back(quest);
}
LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests");
return true;
}
// ============================================================================
// TBC 2.4.3 SMSG_MONSTER_MOVE
// Identical to WotLK except WotLK added a uint8 unk byte immediately after the
// packed GUID (toggles MOVEMENTFLAG2_UNK7). TBC does NOT have this byte.
// Without this override, all NPC movement positions/durations are offset by 1
// byte and parse as garbage.
// ============================================================================
bool TbcPacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) {
data.guid = UpdateObjectParser::readPackedGuid(packet);
if (data.guid == 0) return false;
// No unk byte here in TBC 2.4.3
if (packet.getReadPos() + 12 > packet.getSize()) return false;
data.x = packet.readFloat();
data.y = packet.readFloat();
data.z = packet.readFloat();
if (packet.getReadPos() + 4 > packet.getSize()) return false;
packet.readUInt32(); // splineId
if (packet.getReadPos() >= packet.getSize()) return false;
data.moveType = packet.readUInt8();
if (data.moveType == 1) {
data.destX = data.x;
data.destY = data.y;
data.destZ = data.z;
data.hasDest = false;
return true;
}
if (data.moveType == 2) {
if (packet.getReadPos() + 12 > packet.getSize()) return false;
packet.readFloat(); packet.readFloat(); packet.readFloat();
} else if (data.moveType == 3) {
if (packet.getReadPos() + 8 > packet.getSize()) return false;
data.facingTarget = packet.readUInt64();
} else if (data.moveType == 4) {
if (packet.getReadPos() + 4 > packet.getSize()) return false;
data.facingAngle = packet.readFloat();
}
if (packet.getReadPos() + 4 > packet.getSize()) return false;
data.splineFlags = packet.readUInt32();
// TBC 2.4.3 SplineFlags animation bit is same as WotLK: 0x00400000
if (data.splineFlags & 0x00400000) {
if (packet.getReadPos() + 5 > packet.getSize()) return false;
packet.readUInt8(); // animationType
packet.readUInt32(); // effectStartTime
}
if (packet.getReadPos() + 4 > packet.getSize()) return false;
data.duration = packet.readUInt32();
if (data.splineFlags & 0x00000800) {
if (packet.getReadPos() + 8 > packet.getSize()) return false;
packet.readFloat(); // verticalAcceleration
packet.readUInt32(); // effectStartTime
}
if (packet.getReadPos() + 4 > packet.getSize()) return false;
uint32_t pointCount = packet.readUInt32();
if (pointCount == 0) return true;
if (pointCount > 16384) return false;
bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0;
if (uncompressed) {
for (uint32_t i = 0; i < pointCount - 1; i++) {
if (packet.getReadPos() + 12 > packet.getSize()) return true;
packet.readFloat(); packet.readFloat(); packet.readFloat();
}
if (packet.getReadPos() + 12 > packet.getSize()) return true;
data.destX = packet.readFloat();
data.destY = packet.readFloat();
data.destZ = packet.readFloat();
data.hasDest = true;
} else {
if (packet.getReadPos() + 12 > packet.getSize()) return true;
data.destX = packet.readFloat();
data.destY = packet.readFloat();
data.destZ = packet.readFloat();
data.hasDest = true;
}
LOG_DEBUG("[TBC] MonsterMove: guid=0x", std::hex, data.guid, std::dec,
" type=", (int)data.moveType, " dur=", data.duration, "ms",
" dest=(", data.destX, ",", data.destY, ",", data.destZ, ")");
return true;
}
// ============================================================================
// TBC 2.4.3 CMSG_CAST_SPELL
// Format: castCount(u8) + spellId(u32) + SpellCastTargets
// WotLK 3.3.5a adds castFlags(u8) between spellId and targets — TBC does NOT.
// ============================================================================
network::Packet TbcPacketParsers::buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) {
network::Packet packet(wireOpcode(LogicalOpcode::CMSG_CAST_SPELL));
packet.writeUInt8(castCount);
packet.writeUInt32(spellId);
// No castFlags byte in TBC 2.4.3
if (targetGuid != 0) {
packet.writeUInt32(0x02); // TARGET_FLAG_UNIT
// Write packed GUID
uint8_t mask = 0;
uint8_t bytes[8];
int byteCount = 0;
uint64_t g = targetGuid;
for (int i = 0; i < 8; ++i) {
uint8_t b = g & 0xFF;
if (b != 0) {
mask |= (1 << i);
bytes[byteCount++] = b;
}
g >>= 8;
}
packet.writeUInt8(mask);
for (int i = 0; i < byteCount; ++i)
packet.writeUInt8(bytes[i]);
} else {
packet.writeUInt32(0x00); // TARGET_FLAG_SELF
}
return packet;
}
// ============================================================================
// TBC 2.4.3 CMSG_USE_ITEM
// Format: bag(u8) + slot(u8) + castCount(u8) + spellId(u32) + itemGuid(u64) +
// castFlags(u8) + SpellCastTargets
// WotLK 3.3.5a adds glyphIndex(u32) between itemGuid and castFlags — TBC does NOT.
// ============================================================================
network::Packet TbcPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) {
network::Packet packet(wireOpcode(LogicalOpcode::CMSG_USE_ITEM));
packet.writeUInt8(bagIndex);
packet.writeUInt8(slotIndex);
packet.writeUInt8(0); // cast count
packet.writeUInt32(spellId); // on-use spell id
packet.writeUInt64(itemGuid); // full 8-byte GUID
// No glyph index field in TBC 2.4.3
packet.writeUInt8(0); // cast flags
packet.writeUInt32(0x00); // SpellCastTargets: TARGET_FLAG_SELF
return packet;
}
network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) {
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST));
packet.writeUInt64(npcGuid);
@ -505,6 +698,20 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3
return packet;
}
// ============================================================================
// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST
//
// WotLK adds a trailing uint8 isDialogContinued byte; TBC does not.
// TBC format: guid(8) + questId(4) = 12 bytes.
// ============================================================================
network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) {
network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_QUERY_QUEST));
packet.writeUInt64(npcGuid);
packet.writeUInt32(questId);
// No isDialogContinued byte (WotLK-only addition)
return packet;
}
// ============================================================================
// TBC parseAuraUpdate - SMSG_AURA_UPDATE doesn't exist in TBC
// TBC uses inline aura update fields + SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE (0x3A3) /
@ -696,5 +903,294 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
return true;
}
// ============================================================================
// TbcPacketParsers::parseMailList — TBC 2.4.3 SMSG_MAIL_LIST_RESULT
//
// Differences from WotLK 3.3.5a (base implementation):
// - Header: uint8 count only (WotLK: uint32 totalCount + uint8 shownCount)
// - No body field — subject IS the full text (WotLK added body when mailTemplateId==0)
// - Attachment item GUID: full uint64 (WotLK: uint32 low GUID)
// - Attachment enchants: 7 × uint32 id only (WotLK: 7 × {id+duration+charges} = 84 bytes)
// - Header fields: cod + itemTextId + stationery (WotLK has extra unknown uint32 between
// itemTextId and stationery)
// ============================================================================
bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector<MailMessage>& inbox) {
size_t remaining = packet.getSize() - packet.getReadPos();
if (remaining < 1) return false;
uint8_t count = packet.readUInt8();
LOG_INFO("SMSG_MAIL_LIST_RESULT (TBC): count=", (int)count);
inbox.clear();
inbox.reserve(count);
for (uint8_t i = 0; i < count; ++i) {
remaining = packet.getSize() - packet.getReadPos();
if (remaining < 2) break;
uint16_t msgSize = packet.readUInt16();
size_t startPos = packet.getReadPos();
MailMessage msg;
if (remaining < static_cast<size_t>(msgSize) + 2) {
LOG_WARNING("[TBC] Mail entry ", i, " truncated");
break;
}
msg.messageId = packet.readUInt32();
msg.messageType = packet.readUInt8();
switch (msg.messageType) {
case 0: msg.senderGuid = packet.readUInt64(); break;
default: msg.senderEntry = packet.readUInt32(); break;
}
msg.cod = packet.readUInt32();
packet.readUInt32(); // itemTextId
// NOTE: TBC has NO extra unknown uint32 here (WotLK added one between itemTextId and stationery)
msg.stationeryId = packet.readUInt32();
msg.money = packet.readUInt32();
msg.flags = packet.readUInt32();
msg.expirationTime = packet.readFloat();
msg.mailTemplateId = packet.readUInt32();
msg.subject = packet.readString();
// TBC has no separate body field at all
uint8_t attachCount = packet.readUInt8();
msg.attachments.reserve(attachCount);
for (uint8_t j = 0; j < attachCount; ++j) {
MailAttachment att;
att.slot = packet.readUInt8();
uint64_t itemGuid = packet.readUInt64(); // full 64-bit GUID (TBC)
att.itemGuidLow = static_cast<uint32_t>(itemGuid & 0xFFFFFFFF);
att.itemId = packet.readUInt32();
// TBC: 7 × uint32 enchant ID only (no duration/charges per slot)
for (int e = 0; e < 7; ++e) {
uint32_t enchId = packet.readUInt32();
if (e == 0) att.enchantId = enchId;
}
att.randomPropertyId = packet.readUInt32();
att.randomSuffix = packet.readUInt32();
att.stackCount = packet.readUInt32();
att.chargesOrDurability = packet.readUInt32();
att.maxDurability = packet.readUInt32();
packet.readUInt32(); // current durability (separate from chargesOrDurability)
msg.attachments.push_back(att);
}
msg.read = (msg.flags & 0x01) != 0;
inbox.push_back(std::move(msg));
// Skip any unread bytes within this mail entry
size_t consumed = packet.getReadPos() - startPos;
if (consumed < static_cast<size_t>(msgSize)) {
packet.setReadPos(startPos + msgSize);
}
}
return !inbox.empty();
}
// ============================================================================
// TbcPacketParsers::parseSpellStart — TBC 2.4.3 SMSG_SPELL_START
//
// TBC uses full uint64 GUIDs for casterGuid and casterUnit.
// WotLK uses packed (variable-length) GUIDs.
// TBC also lacks the castCount byte — format:
// casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + castTime(u32)
// Wait: TBC DOES have castCount. But WotLK removed spellId in some paths.
// Correct TBC format (cmangos-tbc): objectGuid(u64) + casterGuid(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + castTime(u32)
// ============================================================================
bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) {
if (packet.getSize() - packet.getReadPos() < 22) return false;
data.casterGuid = packet.readUInt64(); // full GUID (object)
data.casterUnit = packet.readUInt64(); // full GUID (caster unit)
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.castFlags = packet.readUInt32();
data.castTime = packet.readUInt32();
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t targetFlags = packet.readUInt32();
if ((targetFlags & 0x02) && packet.getReadPos() + 8 <= packet.getSize()) {
data.targetGuid = packet.readUInt64(); // full GUID in TBC
}
}
LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
return true;
}
// ============================================================================
// TbcPacketParsers::parseSpellGo — TBC 2.4.3 SMSG_SPELL_GO
//
// TBC uses full uint64 GUIDs, no timestamp field after castFlags.
// WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags.
// ============================================================================
bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) {
if (packet.getSize() - packet.getReadPos() < 19) return false;
data.casterGuid = packet.readUInt64(); // full GUID in TBC
data.casterUnit = packet.readUInt64(); // full GUID in TBC
data.castCount = packet.readUInt8();
data.spellId = packet.readUInt32();
data.castFlags = packet.readUInt32();
// NOTE: NO timestamp field here in TBC (WotLK added packet.readUInt32())
if (packet.getReadPos() >= packet.getSize()) {
LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " (no hit data)");
return true;
}
data.hitCount = packet.readUInt8();
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
data.hitTargets.push_back(packet.readUInt64()); // full GUID in TBC
}
if (packet.getReadPos() < packet.getSize()) {
data.missCount = packet.readUInt8();
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) {
SpellGoMissEntry m;
m.targetGuid = packet.readUInt64(); // full GUID in TBC
m.missType = packet.readUInt8();
data.missTargets.push_back(m);
}
}
LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
return true;
}
// ============================================================================
// TbcPacketParsers::parseCastResult — TBC 2.4.3 SMSG_CAST_RESULT
//
// TBC format: spellId(u32) + result(u8) = 5 bytes
// WotLK adds a castCount(u8) prefix making it 6 bytes.
// Without this override, WotLK parser reads spellId[0] as castCount,
// then the remaining 4 bytes as spellId (off by one), producing wrong result.
// ============================================================================
bool TbcPacketParsers::parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) {
if (packet.getSize() - packet.getReadPos() < 5) return false;
spellId = packet.readUInt32(); // No castCount prefix in TBC
result = packet.readUInt8();
return true;
}
// ============================================================================
// TbcPacketParsers::parseCastFailed — TBC 2.4.3 SMSG_CAST_FAILED
//
// TBC format: spellId(u32) + result(u8)
// WotLK added castCount(u8) before spellId; reading it on TBC would shift
// the spellId by one byte and corrupt all subsequent fields.
// Classic has the same layout, but the result enum starts differently (offset +1);
// TBC uses the same result values as WotLK so no offset is needed.
// ============================================================================
bool TbcPacketParsers::parseCastFailed(network::Packet& packet, CastFailedData& data) {
if (packet.getSize() - packet.getReadPos() < 5) return false;
data.castCount = 0; // not present in TBC
data.spellId = packet.readUInt32();
data.result = packet.readUInt8(); // same enum as WotLK
LOG_DEBUG("[TBC] Cast failed: spell=", data.spellId, " result=", (int)data.result);
return true;
}
// ============================================================================
// TbcPacketParsers::parseAttackerStateUpdate — TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE
//
// TBC uses full uint64 GUIDs for attacker and target.
// WotLK uses packed (variable-length) GUIDs — using the WotLK reader here
// would mis-parse TBC's GUIDs and corrupt all subsequent damage fields.
// ============================================================================
bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) {
if (packet.getSize() - packet.getReadPos() < 21) return false;
data.hitInfo = packet.readUInt32();
data.attackerGuid = packet.readUInt64(); // full GUID in TBC
data.targetGuid = packet.readUInt64(); // full GUID in TBC
data.totalDamage = static_cast<int32_t>(packet.readUInt32());
data.subDamageCount = packet.readUInt8();
for (uint8_t i = 0; i < data.subDamageCount; ++i) {
SubDamage sub;
sub.schoolMask = packet.readUInt32();
sub.damage = packet.readFloat();
sub.intDamage = packet.readUInt32();
sub.absorbed = packet.readUInt32();
sub.resisted = packet.readUInt32();
data.subDamages.push_back(sub);
}
data.victimState = packet.readUInt32();
data.overkill = static_cast<int32_t>(packet.readUInt32());
if (packet.getReadPos() < packet.getSize()) {
data.blocked = packet.readUInt32();
}
LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
return true;
}
// ============================================================================
// TbcPacketParsers::parseSpellDamageLog — TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG
//
// TBC uses full uint64 GUIDs; WotLK uses packed GUIDs.
// ============================================================================
bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) {
if (packet.getSize() - packet.getReadPos() < 29) return false;
data.targetGuid = packet.readUInt64(); // full GUID in TBC
data.attackerGuid = packet.readUInt64(); // full GUID in TBC
data.spellId = packet.readUInt32();
data.damage = packet.readUInt32();
data.schoolMask = packet.readUInt8();
data.absorbed = packet.readUInt32();
data.resisted = packet.readUInt32();
uint8_t periodicLog = packet.readUInt8();
(void)periodicLog;
packet.readUInt8(); // unused
packet.readUInt32(); // blocked
uint32_t flags = packet.readUInt32();
data.isCrit = (flags & 0x02) != 0;
// TBC does not have an overkill field here
data.overkill = 0;
LOG_INFO("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
return true;
}
// ============================================================================
// TbcPacketParsers::parseSpellHealLog — TBC 2.4.3 SMSG_SPELLHEALLOG
//
// TBC uses full uint64 GUIDs; WotLK uses packed GUIDs.
// ============================================================================
bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) {
if (packet.getSize() - packet.getReadPos() < 25) return false;
data.targetGuid = packet.readUInt64(); // full GUID in TBC
data.casterGuid = packet.readUInt64(); // full GUID in TBC
data.spellId = packet.readUInt32();
data.heal = packet.readUInt32();
data.overheal = packet.readUInt32();
// TBC has no absorbed field in SMSG_SPELLHEALLOG; skip crit flag
if (packet.getReadPos() < packet.getSize()) {
uint8_t critFlag = packet.readUInt8();
data.isCrit = (critFlag != 0);
}
LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
return true;
}
} // namespace game
} // namespace wowee

View file

@ -1510,20 +1510,30 @@ network::Packet TextEmotePacket::build(uint32_t textEmoteId, uint64_t targetGuid
return packet;
}
bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data) {
bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat) {
size_t bytesLeft = packet.getSize() - packet.getReadPos();
if (bytesLeft < 20) {
LOG_WARNING("SMSG_TEXT_EMOTE too short: ", bytesLeft, " bytes");
return false;
}
if (legacyFormat) {
// Classic 1.12 / TBC 2.4.3: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64)
data.textEmoteId = packet.readUInt32();
data.emoteNum = packet.readUInt32();
data.senderGuid = packet.readUInt64();
} else {
// WotLK 3.3.5a: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32)
data.senderGuid = packet.readUInt64();
data.textEmoteId = packet.readUInt32();
data.emoteNum = packet.readUInt32();
}
uint32_t nameLen = packet.readUInt32();
if (nameLen > 0 && nameLen <= 256) {
data.targetName = packet.readString();
} else if (nameLen > 0) {
// Skip garbage
// Implausible name length — misaligned read
return false;
}
return true;
@ -2934,10 +2944,12 @@ network::Packet CancelAuraPacket::build(uint32_t spellId) {
return packet;
}
network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action) {
network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action, uint64_t targetGuid) {
// CMSG_PET_ACTION: petGuid(8) + action(4) + targetGuid(8)
network::Packet packet(wireOpcode(Opcode::CMSG_PET_ACTION));
packet.writeUInt64(petGuid);
packet.writeUInt32(action);
packet.writeUInt64(targetGuid);
return packet;
}
@ -2985,7 +2997,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
}
data.missCount = packet.readUInt8();
// Skip miss details for now
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 2 <= packet.getSize(); ++i) {
SpellGoMissEntry m;
m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK
m.missType = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0;
data.missTargets.push_back(m);
}
LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
@ -3082,47 +3100,82 @@ network::Packet GroupDeclinePacket::build() {
return packet;
}
bool GroupListParser::parse(network::Packet& packet, GroupListData& data) {
bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool hasRoles) {
auto rem = [&]() { return packet.getSize() - packet.getReadPos(); };
if (rem() < 3) return false;
data.groupType = packet.readUInt8();
data.subGroup = packet.readUInt8();
data.flags = packet.readUInt8();
data.roles = packet.readUInt8();
// Skip LFG data if present
if (data.groupType & 0x04) {
packet.readUInt8(); // lfg state
packet.readUInt32(); // lfg entry
packet.readUInt8(); // lfg flags (3.3.5a may not have this)
// WotLK 3.3.5a added a roles byte (tank/healer/dps) for the dungeon finder.
// Classic 1.12 and TBC 2.4.3 do not have this byte.
if (hasRoles) {
if (rem() < 1) return false;
data.roles = packet.readUInt8();
} else {
data.roles = 0;
}
packet.readUInt64(); // group GUID
packet.readUInt32(); // counter
// WotLK: LFG data gated by groupType bit 0x04 (LFD group type)
if (hasRoles && (data.groupType & 0x04)) {
if (rem() < 5) return false;
packet.readUInt8(); // lfg state
packet.readUInt32(); // lfg entry
// WotLK 3.3.5a may or may not send the lfg flags byte — read it only if present
if (rem() >= 13) { // enough for lfgFlags(1)+groupGuid(8)+counter(4)
packet.readUInt8(); // lfg flags
}
}
if (rem() < 12) return false;
packet.readUInt64(); // group GUID
packet.readUInt32(); // update counter
if (rem() < 4) return false;
data.memberCount = packet.readUInt32();
if (data.memberCount > 40) {
LOG_WARNING("GroupListParser: implausible memberCount=", data.memberCount, ", clamping");
data.memberCount = 40;
}
data.members.reserve(data.memberCount);
for (uint32_t i = 0; i < data.memberCount; ++i) {
if (rem() == 0) break;
GroupMember member;
member.name = packet.readString();
if (rem() < 8) break;
member.guid = packet.readUInt64();
if (rem() < 3) break;
member.isOnline = packet.readUInt8();
member.subGroup = packet.readUInt8();
member.flags = packet.readUInt8();
// WotLK added per-member roles byte; Classic/TBC do not have it.
if (hasRoles) {
if (rem() < 1) break;
member.roles = packet.readUInt8();
} else {
member.roles = 0;
}
data.members.push_back(member);
}
if (rem() < 8) {
LOG_INFO("Group list: ", data.memberCount, " members (no leader GUID in packet)");
return true;
}
data.leaderGuid = packet.readUInt64();
if (data.memberCount > 0 && packet.getReadPos() < packet.getSize()) {
if (data.memberCount > 0 && rem() >= 10) {
data.lootMethod = packet.readUInt8();
data.looterGuid = packet.readUInt64();
data.lootThreshold = packet.readUInt8();
data.difficultyId = packet.readUInt8();
data.raidDifficultyId = packet.readUInt8();
if (packet.getReadPos() < packet.getSize()) {
packet.readUInt8(); // unknown byte
}
// Dungeon difficulty (heroic/normal) — Classic doesn't send this; TBC/WotLK do
if (rem() >= 1) data.difficultyId = packet.readUInt8();
// Raid difficulty — WotLK only
if (rem() >= 1) data.raidDifficultyId = packet.readUInt8();
// Extra byte in some 3.3.5a builds
if (hasRoles && rem() >= 1) packet.readUInt8();
}
LOG_INFO("Group list: ", data.memberCount, " members, leader=0x",
@ -3780,7 +3833,11 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data
// Trainer
// ============================================================
bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) {
bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bool isClassic) {
// WotLK per-entry: spellId(4) + state(1) + cost(4) + profDialog(4) + profButton(4) +
// reqLevel(1) + reqSkill(4) + reqSkillValue(4) + chain×3(12) = 38 bytes
// Classic per-entry: spellId(4) + state(1) + cost(4) + reqLevel(1) +
// reqSkill(4) + reqSkillValue(4) + chain×3(12) + unk(4) = 34 bytes
data = TrainerListData{};
data.trainerGuid = packet.readUInt64();
data.trainerType = packet.readUInt32();
@ -3797,20 +3854,32 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) {
spell.spellId = packet.readUInt32();
spell.state = packet.readUInt8();
spell.spellCost = packet.readUInt32();
if (isClassic) {
// Classic 1.12: reqLevel immediately after cost; no profDialog/profButton
spell.profDialog = 0;
spell.profButton = 0;
spell.reqLevel = packet.readUInt8();
} else {
// TBC / WotLK: profDialog + profButton before reqLevel
spell.profDialog = packet.readUInt32();
spell.profButton = packet.readUInt32();
spell.reqLevel = packet.readUInt8();
}
spell.reqSkill = packet.readUInt32();
spell.reqSkillValue = packet.readUInt32();
spell.chainNode1 = packet.readUInt32();
spell.chainNode2 = packet.readUInt32();
spell.chainNode3 = packet.readUInt32();
if (isClassic) {
packet.readUInt32(); // trailing unk / sort index
}
data.spells.push_back(spell);
}
data.greeting = packet.readString();
LOG_INFO("Trainer list: ", spellCount, " spells, type=", data.trainerType,
LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ",
spellCount, " spells, type=", data.trainerType,
", greeting=\"", data.greeting, "\"");
return true;
}
@ -3935,6 +4004,14 @@ network::Packet TalentWipeConfirmPacket::build(bool accept) {
return packet;
}
network::Packet ActivateTalentGroupPacket::build(uint32_t group) {
// CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3 in WotLK 3.3.5a)
// Payload: uint32 group (0 = primary, 1 = secondary)
network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE));
packet.writeUInt32(group);
return packet;
}
// ============================================================
// Death/Respawn
// ============================================================
@ -4437,28 +4514,35 @@ network::Packet AuctionListBidderItemsPacket::build(
return p;
}
bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data) {
bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots) {
// Per-entry fixed size: auctionId(4) + itemEntry(4) + enchantSlots×3×4 +
// randProp(4) + suffix(4) + stack(4) + charges(4) + flags(4) +
// ownerGuid(8) + startBid(4) + outbid(4) + buyout(4) + expire(4) +
// bidderGuid(8) + curBid(4)
// Classic: numEnchantSlots=1 → 80 bytes/entry
// TBC/WotLK: numEnchantSlots=3 → 104 bytes/entry
if (packet.getSize() - packet.getReadPos() < 4) return false;
uint32_t count = packet.readUInt32();
data.auctions.clear();
data.auctions.reserve(count);
const size_t minPerEntry = static_cast<size_t>(8 + numEnchantSlots * 12 + 28 + 8 + 8);
for (uint32_t i = 0; i < count; ++i) {
if (packet.getReadPos() + 64 > packet.getSize()) break;
if (packet.getReadPos() + minPerEntry > packet.getSize()) break;
AuctionEntry e;
e.auctionId = packet.readUInt32();
e.itemEntry = packet.readUInt32();
// 3 enchant slots: enchantId, duration, charges
// First enchant slot always present
e.enchantId = packet.readUInt32();
packet.readUInt32(); // enchant duration
packet.readUInt32(); // enchant charges
packet.readUInt32(); // enchant2 id
packet.readUInt32(); // enchant2 duration
packet.readUInt32(); // enchant2 charges
packet.readUInt32(); // enchant3 id
packet.readUInt32(); // enchant3 duration
packet.readUInt32(); // enchant3 charges
packet.readUInt32(); // enchant1 duration
packet.readUInt32(); // enchant1 charges
// Extra enchant slots for TBC/WotLK
for (int s = 1; s < numEnchantSlots; ++s) {
packet.readUInt32(); // enchant N id
packet.readUInt32(); // enchant N duration
packet.readUInt32(); // enchant N charges
}
e.randomPropertyId = packet.readUInt32();
e.suffixFactor = packet.readUInt32();
e.stackCount = packet.readUInt32();
@ -4474,8 +4558,10 @@ bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult&
data.auctions.push_back(e);
}
if (packet.getSize() - packet.getReadPos() >= 8) {
data.totalCount = packet.readUInt32();
data.searchDelay = packet.readUInt32();
}
return true;
}

View file

@ -153,6 +153,11 @@ void TCPSocket::update() {
if (net::isWouldBlock(err)) {
break;
}
if (net::isConnectionClosed(err)) {
// Peer closed the connection — treat the same as recv() returning 0
sawClose = true;
break;
}
LOG_ERROR("Receive failed: ", net::errorString(err));
disconnect();

View file

@ -128,6 +128,39 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) {
sockfd = INVALID_SOCK;
return false;
}
// Non-blocking connect in progress — wait up to 10s for completion.
// On Windows, calling recv() before the connect completes returns
// WSAENOTCONN; we must poll writability before declaring connected.
fd_set writefds, errfds;
FD_ZERO(&writefds);
FD_ZERO(&errfds);
FD_SET(sockfd, &writefds);
FD_SET(sockfd, &errfds);
struct timeval tv;
tv.tv_sec = 10;
tv.tv_usec = 0;
int sel = ::select(static_cast<int>(sockfd) + 1, nullptr, &writefds, &errfds, &tv);
if (sel <= 0) {
LOG_ERROR("World server connection timed out (", host, ":", port, ")");
net::closeSocket(sockfd);
sockfd = INVALID_SOCK;
return false;
}
// Verify the socket error code — writeable doesn't guarantee success on all platforms
int sockErr = 0;
socklen_t errLen = sizeof(sockErr);
getsockopt(sockfd, SOL_SOCKET, SO_ERROR,
reinterpret_cast<char*>(&sockErr), &errLen);
if (sockErr != 0) {
LOG_ERROR("Failed to connect to world server: ", net::errorString(sockErr));
net::closeSocket(sockfd);
sockfd = INVALID_SOCK;
return false;
}
}
connected = true;
@ -369,6 +402,11 @@ void WorldSocket::update() {
if (net::isWouldBlock(err)) {
break;
}
if (net::isConnectionClosed(err)) {
// Peer closed the connection — treat the same as recv() returning 0
sawClose = true;
break;
}
LOG_ERROR("Receive failed: ", net::errorString(err));
disconnect();

View file

@ -109,7 +109,11 @@ WMOModel WMOLoader::load(const std::vector<uint8_t>& wmoData) {
model.nDoodadDefs = read<uint32_t>(wmoData, offset);
model.nDoodadSets = read<uint32_t>(wmoData, offset);
[[maybe_unused]] uint32_t ambColor = read<uint32_t>(wmoData, offset); // Ambient color (BGRA)
uint32_t ambColor = read<uint32_t>(wmoData, offset); // Ambient color (BGRA)
// Unpack BGRA bytes to normalized [0,1] RGB
model.ambientColor.r = ((ambColor >> 16) & 0xFF) / 255.0f;
model.ambientColor.g = ((ambColor >> 8) & 0xFF) / 255.0f;
model.ambientColor.b = ((ambColor >> 0) & 0xFF) / 255.0f;
[[maybe_unused]] uint32_t wmoID = read<uint32_t>(wmoData, offset);
model.boundingBoxMin.x = read<float>(wmoData, offset);

View file

@ -543,9 +543,24 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc");
if (!displayInfoDbc || !displayInfoDbc->isLoaded()) {
LOG_WARNING("applyEquipment: ItemDisplayInfo.dbc not loaded");
return false;
}
// Diagnostic: log equipment vector and DBC state
LOG_INFO("applyEquipment: ", equipment.size(), " items, ItemDisplayInfo.dbc records=",
displayInfoDbc->getRecordCount(), " fields=", displayInfoDbc->getFieldCount(),
" bodySkin=", bodySkinPath_.empty() ? "(empty)" : bodySkinPath_);
for (size_t ei = 0; ei < equipment.size(); ++ei) {
const auto& it = equipment[ei];
if (it.displayModel == 0) continue;
int32_t dbcRec = displayInfoDbc->findRecordById(it.displayModel);
LOG_INFO(" slot[", ei, "]: displayModel=", it.displayModel,
" invType=", (int)it.inventoryType,
" dbcRec=", dbcRec,
(dbcRec >= 0 ? " (found)" : " (NOT FOUND in ItemDisplayInfo.dbc)"));
}
auto hasInvType = [&](std::initializer_list<uint8_t> types) -> bool {
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
@ -560,7 +575,7 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
for (uint8_t t : types) {
if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum)
if (it.inventoryType == t) return it.displayModel;
}
}
return 0;
@ -570,7 +585,12 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
if (displayInfoId == 0) return 0;
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
if (recIdx < 0) return 0;
return displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
uint32_t val = displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
if (val > 0) {
LOG_INFO(" getGeosetGroup: displayInfoId=", displayInfoId,
" groupField=", groupField, " field=", (7 + groupField), " val=", val);
}
return val;
};
// --- Geosets ---
@ -654,6 +674,9 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), fieldIdx);
if (texName.empty()) continue;
LOG_INFO(" texture region ", region, " (field ", fieldIdx, "): texName=", texName,
" for displayModel=", it.displayModel);
std::string base = "Item\\TextureComponents\\" +
std::string(componentDirs[region]) + "\\" + texName;
@ -669,6 +692,7 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
} else if (assetManager_->fileExists(basePath)) {
fullPath = basePath;
} else {
LOG_INFO(" texture path not found: ", base, " (_M/_F/_U/.blp)");
continue;
}
regionLayers.emplace_back(region, fullPath);

View file

@ -1185,7 +1185,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
" tris, grid ", gpuModel.collision.gridCellsX, "x", gpuModel.collision.gridCellsY);
}
// Flag smoke models for UV scroll animation (particle emitters not implemented)
// Flag smoke models for UV scroll animation (in addition to particle emitters)
{
std::string smokeName = model.name;
std::transform(smokeName.begin(), smokeName.end(), smokeName.begin(),
@ -1357,6 +1357,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) {
if (batch.materialIndex < model.materials.size()) {
bgpu.blendMode = model.materials[batch.materialIndex].blendMode;
bgpu.materialFlags = model.materials[batch.materialIndex].flags;
if (bgpu.blendMode >= 2) gpuModel.hasTransparentBatches = true;
}
// Copy LOD level from batch
@ -2349,7 +2350,11 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq});
}
// Sort by modelId to minimize vertex/index buffer rebinds
// Two-pass rendering: opaque/alpha-test first (depth write ON), then transparent/additive
// (depth write OFF, sorted back-to-front) so transparent geometry composites correctly
// against all opaque geometry rather than only against what was rendered before it.
// Pass 1: sort by modelId for minimum buffer rebinds (opaque batches)
std::sort(sortedVisible_.begin(), sortedVisible_.end(),
[](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; });
@ -2377,6 +2382,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
// Start with opaque pipeline
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, opaquePipeline_);
currentPipeline = opaquePipeline_;
bool opaquePass = true; // Pass 1 = opaque, pass 2 = transparent (set below for second pass)
for (const auto& entry : sortedVisible_) {
if (entry.index >= instances.size()) continue;
@ -2475,6 +2481,15 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue;
if (batch.batchOpacity < 0.01f) continue;
// Two-pass gate: pass 1 = opaque/cutout only, pass 2 = transparent/additive only.
// Alpha-test (blendMode==1) and spell effects that force-additive are handled
// by their effective blend mode below; gate on raw blendMode here.
{
const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect;
if (opaquePass && rawTransparent) continue; // skip transparent in opaque pass
if (!opaquePass && !rawTransparent) continue; // skip opaque in transparent pass
}
const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame;
const bool smallCardLikeBatch =
(batch.glowSize <= 1.35f) ||
@ -2628,6 +2643,162 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
}
}
// Pass 2: transparent/additive batches — sort back-to-front by distance so
// overlapping transparent geometry composites in the correct painter's order.
opaquePass = false;
std::sort(sortedVisible_.begin(), sortedVisible_.end(),
[](const VisibleEntry& a, const VisibleEntry& b) { return a.distSq > b.distSq; });
currentModelId = UINT32_MAX;
currentModel = nullptr;
// Reset pipeline to opaque so the first transparent bind always sets explicitly
currentPipeline = opaquePipeline_;
for (const auto& entry : sortedVisible_) {
if (entry.index >= instances.size()) continue;
auto& instance = instances[entry.index];
// Quick skip: if model has no transparent batches at all, skip it entirely
if (entry.modelId != currentModelId) {
auto mdlIt = models.find(entry.modelId);
if (mdlIt == models.end()) continue;
if (!mdlIt->second.hasTransparentBatches && !mdlIt->second.isSpellEffect) continue;
}
// Reuse the same rendering logic as pass 1 (via fallthrough — the batch gate
// `!opaquePass && !rawTransparent → continue` handles opaque skipping)
if (entry.modelId != currentModelId) {
currentModelId = entry.modelId;
auto mdlIt = models.find(currentModelId);
if (mdlIt == models.end()) continue;
currentModel = &mdlIt->second;
if (!currentModel->vertexBuffer) continue;
VkDeviceSize offset = 0;
vkCmdBindVertexBuffers(cmd, 0, 1, &currentModel->vertexBuffer, &offset);
vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16);
}
const M2ModelGPU& model = *currentModel;
// Distance-based fade alpha (same as pass 1)
float fadeAlpha = 1.0f;
float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction;
float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac;
if (entry.distSq > fadeStartDistSq) {
fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) /
(entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f);
}
float instanceFadeAlpha = fadeAlpha;
if (model.isGroundDetail) instanceFadeAlpha *= 0.82f;
if (model.isInstancePortal) instanceFadeAlpha *= 0.12f;
bool modelNeedsAnimation = model.hasAnimation && !model.disableAnimation;
if (modelNeedsAnimation && instance.boneMatrices.empty()) continue;
bool needsBones = modelNeedsAnimation && !instance.boneMatrices.empty();
if (needsBones && (!instance.boneBuffer[frameIndex] || !instance.boneSet[frameIndex])) continue;
bool useBones = needsBones;
if (useBones && instance.boneSet[frameIndex]) {
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout_, 2, 1, &instance.boneSet[frameIndex], 0, nullptr);
}
uint16_t desiredLOD = 0;
if (entry.distSq > 150.0f * 150.0f) desiredLOD = 3;
else if (entry.distSq > 80.0f * 80.0f) desiredLOD = 2;
else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1;
uint16_t targetLOD = desiredLOD;
if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0;
const bool particleDominantEffect = model.isSpellEffect &&
!model.particleEmitters.empty() && model.batches.size() <= 2;
for (const auto& batch : model.batches) {
if (batch.indexCount == 0) continue;
if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue;
if (batch.batchOpacity < 0.01f) continue;
// Pass 2 gate: only transparent/additive batches
{
const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect;
if (!rawTransparent) continue;
}
// Skip glow sprites (handled after loop)
const bool batchUnlit = (batch.materialFlags & 0x01) != 0;
const bool shouldUseGlowSprite =
!batch.colorKeyBlack &&
(model.isElvenLike || model.isLanternLike) &&
!model.isSpellEffect &&
(batch.glowSize <= 1.35f || (batch.lanternGlowHint && batch.glowSize <= 6.0f)) &&
(batch.lanternGlowHint || (batch.blendMode >= 3) ||
(batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1));
if (shouldUseGlowSprite) {
const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit;
if ((batch.glowCardLike && model.isLanternLike) || (cardLikeSkipMesh && !model.isLanternLike))
continue;
}
glm::vec2 uvOffset(0.0f, 0.0f);
if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) {
uint16_t lookupIdx = batch.textureAnimIndex;
if (lookupIdx < model.textureTransformLookup.size()) {
uint16_t transformIdx = model.textureTransformLookup[lookupIdx];
if (transformIdx < model.textureTransforms.size()) {
const auto& tt = model.textureTransforms[transformIdx];
glm::vec3 trans = interpVec3(tt.translation,
instance.currentSequenceIndex, instance.animTime,
glm::vec3(0.0f), model.globalSequenceDurations);
uvOffset = glm::vec2(trans.x, trans.y);
}
}
}
if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) {
static auto startTime2 = std::chrono::steady_clock::now();
float t = std::chrono::duration<float>(std::chrono::steady_clock::now() - startTime2).count();
uvOffset = glm::vec2(t * 0.03f, -t * 0.08f);
}
uint8_t effectiveBlendMode = batch.blendMode;
if (model.isSpellEffect) {
if (effectiveBlendMode <= 1) effectiveBlendMode = 3;
else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) effectiveBlendMode = 3;
}
VkPipeline desiredPipeline;
switch (effectiveBlendMode) {
case 2: desiredPipeline = alphaPipeline_; break;
default: desiredPipeline = additivePipeline_; break;
}
if (desiredPipeline != currentPipeline) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline);
currentPipeline = desiredPipeline;
}
if (batch.materialUBOMapped) {
auto* mat = static_cast<M2MaterialUBO*>(batch.materialUBOMapped);
mat->interiorDarken = insideInterior ? 1.0f : 0.0f;
if (batch.colorKeyBlack)
mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f;
}
if (!batch.materialSet) continue;
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS,
pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr);
M2PushConstants pc;
pc.model = instance.modelMatrix;
pc.uvOffset = uvOffset;
pc.texCoordSet = static_cast<int>(batch.textureUnit);
pc.useBones = useBones ? 1 : 0;
pc.isFoliage = model.shadowWindFoliage ? 1 : 0;
pc.fadeAlpha = instanceFadeAlpha;
if (particleDominantEffect) continue; // emission-only mesh
vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc);
vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0);
lastDrawCallCount++;
}
}
// Render glow sprites as billboarded additive point lights
if (!glowSprites_.empty() && particleAdditivePipeline_ && glowVB_ && glowTexDescSet_) {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particleAdditivePipeline_);

View file

@ -288,7 +288,9 @@ Renderer::~Renderer() = default;
bool Renderer::createPerFrameResources() {
VkDevice device = vkCtx->getDevice();
// --- Create shadow depth image ---
// --- Create per-frame shadow depth images (one per in-flight frame) ---
// Each frame slot has its own depth image so that frame N's shadow read and
// frame N+1's shadow write cannot race on the same image.
VkImageCreateInfo imgCI{};
imgCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO;
imgCI.imageType = VK_IMAGE_TYPE_2D;
@ -301,26 +303,30 @@ bool Renderer::createPerFrameResources() {
imgCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
VmaAllocationCreateInfo imgAllocCI{};
imgAllocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY;
for (uint32_t i = 0; i < MAX_FRAMES; i++) {
if (vmaCreateImage(vkCtx->getAllocator(), &imgCI, &imgAllocCI,
&shadowDepthImage, &shadowDepthAlloc, nullptr) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow depth image");
&shadowDepthImage[i], &shadowDepthAlloc[i], nullptr) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow depth image [", i, "]");
return false;
}
shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED;
shadowDepthLayout_[i] = VK_IMAGE_LAYOUT_UNDEFINED;
}
// --- Create shadow depth image view ---
// --- Create per-frame shadow depth image views ---
VkImageViewCreateInfo viewCI{};
viewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO;
viewCI.image = shadowDepthImage;
viewCI.viewType = VK_IMAGE_VIEW_TYPE_2D;
viewCI.format = VK_FORMAT_D32_SFLOAT;
viewCI.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow depth image view");
for (uint32_t i = 0; i < MAX_FRAMES; i++) {
viewCI.image = shadowDepthImage[i];
if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView[i]) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow depth image view [", i, "]");
return false;
}
}
// --- Create shadow sampler ---
// --- Create shadow sampler (shared — read-only, no per-frame needed) ---
VkSamplerCreateInfo sampCI{};
sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO;
sampCI.magFilter = VK_FILTER_LINEAR;
@ -377,19 +383,21 @@ bool Renderer::createPerFrameResources() {
return false;
}
// --- Create shadow framebuffer ---
// --- Create per-frame shadow framebuffers ---
VkFramebufferCreateInfo fbCI{};
fbCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO;
fbCI.renderPass = shadowRenderPass;
fbCI.attachmentCount = 1;
fbCI.pAttachments = &shadowDepthView;
fbCI.width = SHADOW_MAP_SIZE;
fbCI.height = SHADOW_MAP_SIZE;
fbCI.layers = 1;
if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow framebuffer");
for (uint32_t i = 0; i < MAX_FRAMES; i++) {
fbCI.pAttachments = &shadowDepthView[i];
if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer[i]) != VK_SUCCESS) {
LOG_ERROR("Failed to create shadow framebuffer [", i, "]");
return false;
}
}
// --- Create descriptor set layout for set 0 (per-frame UBO + shadow sampler) ---
VkDescriptorSetLayoutBinding bindings[2]{};
@ -470,7 +478,7 @@ bool Renderer::createPerFrameResources() {
VkDescriptorImageInfo shadowImgInfo{};
shadowImgInfo.sampler = shadowSampler;
shadowImgInfo.imageView = shadowDepthView;
shadowImgInfo.imageView = shadowDepthView[i];
shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkWriteDescriptorSet writes[2]{};
@ -527,7 +535,7 @@ bool Renderer::createPerFrameResources() {
VkDescriptorImageInfo shadowImgInfo{};
shadowImgInfo.sampler = shadowSampler;
shadowImgInfo.imageView = shadowDepthView;
shadowImgInfo.imageView = shadowDepthView[0]; // reflection uses frame 0 shadow view
shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
VkWriteDescriptorSet writes[2]{};
@ -576,13 +584,15 @@ void Renderer::destroyPerFrameResources() {
perFrameSetLayout = VK_NULL_HANDLE;
}
// Destroy shadow resources
if (shadowFramebuffer) { vkDestroyFramebuffer(device, shadowFramebuffer, nullptr); shadowFramebuffer = VK_NULL_HANDLE; }
// Destroy per-frame shadow resources
for (uint32_t i = 0; i < MAX_FRAMES; i++) {
if (shadowFramebuffer[i]) { vkDestroyFramebuffer(device, shadowFramebuffer[i], nullptr); shadowFramebuffer[i] = VK_NULL_HANDLE; }
if (shadowDepthView[i]) { vkDestroyImageView(device, shadowDepthView[i], nullptr); shadowDepthView[i] = VK_NULL_HANDLE; }
if (shadowDepthImage[i]) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage[i], shadowDepthAlloc[i]); shadowDepthImage[i] = VK_NULL_HANDLE; shadowDepthAlloc[i] = VK_NULL_HANDLE; }
shadowDepthLayout_[i] = VK_IMAGE_LAYOUT_UNDEFINED;
}
if (shadowRenderPass) { vkDestroyRenderPass(device, shadowRenderPass, nullptr); shadowRenderPass = VK_NULL_HANDLE; }
if (shadowDepthView) { vkDestroyImageView(device, shadowDepthView, nullptr); shadowDepthView = VK_NULL_HANDLE; }
if (shadowDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage, shadowDepthAlloc); shadowDepthImage = VK_NULL_HANDLE; shadowDepthAlloc = VK_NULL_HANDLE; }
if (shadowSampler) { vkDestroySampler(device, shadowSampler, nullptr); shadowSampler = VK_NULL_HANDLE; }
shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED;
}
void Renderer::updatePerFrameUBO() {
@ -1088,7 +1098,7 @@ void Renderer::beginFrame() {
}
// Shadow pre-pass (before main render pass)
if (shadowsEnabled && shadowDepthImage != VK_NULL_HANDLE) {
if (shadowsEnabled && shadowDepthImage[0] != VK_NULL_HANDLE) {
renderShadowPass();
}
@ -5669,7 +5679,7 @@ void Renderer::renderReflectionPass() {
void Renderer::renderShadowPass() {
static const bool skipShadows = (std::getenv("WOWEE_SKIP_SHADOWS") != nullptr);
if (skipShadows) return;
if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return;
if (!shadowsEnabled || shadowDepthImage[0] == VK_NULL_HANDLE) return;
if (currentCmd == VK_NULL_HANDLE) return;
// Shadows render every frame — throttling causes visible flicker on player/NPCs
@ -5686,21 +5696,21 @@ void Renderer::renderShadowPass() {
ubo->shadowParams.y = 0.8f;
}
// Barrier 1: transition shadow map into writable depth layout.
// Barrier 1: transition this frame's shadow map into writable depth layout.
VkImageMemoryBarrier b1{};
b1.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER;
b1.oldLayout = shadowDepthLayout_;
b1.oldLayout = shadowDepthLayout_[frame];
b1.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL;
b1.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b1.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b1.srcAccessMask = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
b1.srcAccessMask = (shadowDepthLayout_[frame] == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
? VK_ACCESS_SHADER_READ_BIT
: 0;
b1.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT |
VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
b1.image = shadowDepthImage;
b1.image = shadowDepthImage[frame];
b1.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
VkPipelineStageFlags srcStage = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
VkPipelineStageFlags srcStage = (shadowDepthLayout_[frame] == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)
? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
: VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT;
vkCmdPipelineBarrier(currentCmd,
@ -5711,7 +5721,7 @@ void Renderer::renderShadowPass() {
VkRenderPassBeginInfo rpInfo{};
rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
rpInfo.renderPass = shadowRenderPass;
rpInfo.framebuffer = shadowFramebuffer;
rpInfo.framebuffer = shadowFramebuffer[frame];
rpInfo.renderArea = {{0, 0}, {SHADOW_MAP_SIZE, SHADOW_MAP_SIZE}};
VkClearValue clear{};
clear.depthStencil = {1.0f, 0};
@ -5750,12 +5760,12 @@ void Renderer::renderShadowPass() {
b2.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
b2.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT;
b2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT;
b2.image = shadowDepthImage;
b2.image = shadowDepthImage[frame];
b2.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1};
vkCmdPipelineBarrier(currentCmd,
VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT,
0, 0, nullptr, 0, nullptr, 1, &b2);
shadowDepthLayout_ = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
shadowDepthLayout_[frame] = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL;
}
} // namespace rendering

View file

@ -885,13 +885,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
}
case FinalizationPhase::M2_INSTANCES: {
// Create all M2 instances (lightweight struct allocation, no GPU work)
if (m2Renderer) {
int loadedDoodads = 0;
int skippedDedup = 0;
for (const auto& p : pending->m2Placements) {
// Create M2 instances incrementally to avoid main-thread stalls.
// createInstance includes an O(n) bone-sibling scan that becomes expensive
// on dense tiles with many placements and a large existing instance list.
if (m2Renderer && ft.m2InstanceIndex < pending->m2Placements.size()) {
constexpr size_t kInstancesPerStep = 32;
size_t created = 0;
while (ft.m2InstanceIndex < pending->m2Placements.size() && created < kInstancesPerStep) {
const auto& p = pending->m2Placements[ft.m2InstanceIndex++];
if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) {
skippedDedup++;
continue;
}
uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale);
@ -901,12 +903,14 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
placedDoodadIds.insert(p.uniqueId);
ft.tileUniqueIds.push_back(p.uniqueId);
}
loadedDoodads++;
created++;
}
}
if (ft.m2InstanceIndex < pending->m2Placements.size()) {
return false; // More instances to create — yield
}
LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ",
loadedDoodads, " instances (", ft.uploadedM2ModelIds.size(), " new models, ",
skippedDedup, " dedup skipped)");
ft.m2InstanceIds.size(), " instances (", ft.uploadedM2ModelIds.size(), " new models)");
}
ft.phase = FinalizationPhase::WMO_MODELS;
return false;
@ -948,17 +952,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
}
case FinalizationPhase::WMO_INSTANCES: {
// Create all WMO instances + load WMO liquids
if (wmoRenderer) {
int loadedWMOs = 0;
int loadedLiquids = 0;
int skippedWmoDedup = 0;
for (auto& wmoReady : pending->wmoModels) {
// Create WMO instances incrementally to avoid stalls on tiles with many WMOs.
if (wmoRenderer && ft.wmoInstanceIndex < pending->wmoModels.size()) {
constexpr size_t kWmoInstancesPerStep = 4;
size_t created = 0;
while (ft.wmoInstanceIndex < pending->wmoModels.size() && created < kWmoInstancesPerStep) {
auto& wmoReady = pending->wmoModels[ft.wmoInstanceIndex++];
if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) {
skippedWmoDedup++;
continue;
}
uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation);
if (wmoInstId) {
ft.wmoInstanceIds.push_back(wmoInstId);
@ -966,8 +968,6 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
placedWmoIds.insert(wmoReady.uniqueId);
ft.tileWmoUniqueIds.push_back(wmoReady.uniqueId);
}
loadedWMOs++;
// Load WMO liquids (canals, pools, etc.)
if (waterRenderer) {
glm::mat4 modelMatrix = glm::mat4(1.0f);
@ -977,25 +977,21 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) {
modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
for (const auto& group : wmoReady.model.groups) {
if (!group.liquid.hasLiquid()) continue;
// Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava)
if (group.flags & 0x2000) {
uint16_t lt = group.liquid.materialId;
uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4);
if (basicType < 2) continue;
}
waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId);
loadedLiquids++;
}
}
created++;
}
}
if (loadedWMOs > 0 || skippedWmoDedup > 0) {
LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ",
loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped");
}
if (loadedLiquids > 0) {
LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids);
if (ft.wmoInstanceIndex < pending->wmoModels.size()) {
return false; // More WMO instances to create — yield
}
LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", ft.wmoInstanceIds.size(), " instances");
}
ft.phase = FinalizationPhase::WMO_DOODADS;
return false;
@ -2213,10 +2209,16 @@ void TerrainManager::streamTiles() {
return false;
};
// Enqueue tiles in radius around current tile for async loading
// Enqueue tiles in radius around current tile for async loading.
// Collect all newly-needed tiles, then sort by distance so the closest
// (most visible) tiles get loaded first. This is critical during taxi
// flight where new tiles enter the radius faster than they can load.
{
std::lock_guard<std::mutex> lock(queueMutex);
struct PendingEntry { TileCoord coord; int distSq; };
std::vector<PendingEntry> newTiles;
for (int dy = -loadRadius; dy <= loadRadius; dy++) {
for (int dx = -loadRadius; dx <= loadRadius; dx++) {
int tileX = currentTile.x + dx;
@ -2240,10 +2242,19 @@ void TerrainManager::streamTiles() {
if (failedTiles.find(coord) != failedTiles.end()) continue;
if (shouldSkipMissingAdt(coord)) continue;
loadQueue.push_back(coord);
newTiles.push_back({coord, dx*dx + dy*dy});
pendingTiles[coord] = true;
}
}
// Sort nearest tiles first so workers service the most visible tiles
std::sort(newTiles.begin(), newTiles.end(),
[](const PendingEntry& a, const PendingEntry& b) { return a.distSq < b.distSq; });
// Insert at front so new close tiles preempt any distant tiles already queued
for (auto it = newTiles.rbegin(); it != newTiles.rend(); ++it) {
loadQueue.push_front(it->coord);
}
}
// Notify workers that there's work

View file

@ -414,6 +414,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
modelData.id = id;
modelData.boundingBoxMin = model.boundingBoxMin;
modelData.boundingBoxMax = model.boundingBoxMax;
modelData.wmoAmbientColor = model.ambientColor;
{
glm::vec3 ext = model.boundingBoxMax - model.boundingBoxMin;
float horiz = std::max(ext.x, ext.y);
@ -681,6 +682,9 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) {
matData.heightMapVariance = mb.heightMapVariance;
matData.normalMapStrength = normalMapStrength_;
matData.isLava = mb.isLava ? 1 : 0;
matData.wmoAmbientR = modelData.wmoAmbientColor.r;
matData.wmoAmbientG = modelData.wmoAmbientColor.g;
matData.wmoAmbientB = modelData.wmoAmbientColor.b;
if (matBuf.info.pMappedData) {
memcpy(matBuf.info.pMappedData, &matData, sizeof(matData));
}

View file

@ -401,8 +401,10 @@ void GameScreen::render(game::GameHandler& gameHandler) {
renderMirrorTimers(gameHandler);
renderQuestObjectiveTracker(gameHandler);
if (showNameplates_) renderNameplates(gameHandler);
renderBattlegroundScore(gameHandler);
renderCombatText(gameHandler);
renderPartyFrames(gameHandler);
renderBossFrames(gameHandler);
renderGroupInvitePopup(gameHandler);
renderDuelRequestPopup(gameHandler);
renderLootRollPopup(gameHandler);
@ -432,6 +434,7 @@ void GameScreen::render(game::GameHandler& gameHandler) {
// renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now
renderMinimapMarkers(gameHandler);
renderDeathScreen(gameHandler);
renderReclaimCorpseButton(gameHandler);
renderResurrectDialog(gameHandler);
renderChatBubbles(gameHandler);
renderEscapeMenu();
@ -1865,6 +1868,37 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) {
}
ImGui::Dummy(ImVec2(totalW, squareH));
}
// Combo point display — Rogue (4) and Druid (11) in Cat Form
{
uint8_t cls = gameHandler.getPlayerClass();
const bool isRogue = (cls == 4);
const bool isDruid = (cls == 11);
if (isRogue || isDruid) {
uint8_t cp = gameHandler.getComboPoints();
if (cp > 0 || isRogue) { // always show for rogue; only when non-zero for druid
ImGui::Spacing();
ImVec2 cursor = ImGui::GetCursorScreenPos();
float totalW = ImGui::GetContentRegionAvail().x;
constexpr int MAX_CP = 5;
constexpr float DOT_R = 7.0f;
constexpr float SPACING = 4.0f;
float totalDotsW = MAX_CP * (DOT_R * 2.0f) + (MAX_CP - 1) * SPACING;
float startX = cursor.x + (totalW - totalDotsW) * 0.5f;
float cy = cursor.y + DOT_R;
ImDrawList* dl = ImGui::GetWindowDrawList();
for (int i = 0; i < MAX_CP; ++i) {
float cx = startX + i * (DOT_R * 2.0f + SPACING) + DOT_R;
ImU32 col = (i < static_cast<int>(cp))
? IM_COL32(255, 210, 0, 240) // bright gold — active
: IM_COL32(60, 60, 60, 160); // dark — empty
dl->AddCircleFilled(ImVec2(cx, cy), DOT_R, col);
dl->AddCircle(ImVec2(cx, cy), DOT_R, IM_COL32(160, 140, 0, 180), 0, 1.5f);
}
ImGui::Dummy(ImVec2(totalW, DOT_R * 2.0f));
}
}
}
}
ImGui::End();
@ -2090,6 +2124,22 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) {
}
}
// Target cast bar — shown when the target is casting
if (gameHandler.isTargetCasting()) {
float castPct = gameHandler.getTargetCastProgress();
float castLeft = gameHandler.getTargetCastTimeRemaining();
uint32_t tspell = gameHandler.getTargetCastSpellId();
const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : "";
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f));
char castLabel[72];
if (!castName.empty())
snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft);
else
snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft);
ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel);
ImGui::PopStyleColor();
}
// Distance
const auto& movement = gameHandler.getMovementInfo();
float dx = target->getX() - movement.x;
@ -4670,6 +4720,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) {
snprintf(text, sizeof(text), "+%d XP", entry.amount);
color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP
break;
case game::CombatTextEntry::IMMUNE:
snprintf(text, sizeof(text), "Immune!");
color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune
break;
default:
snprintf(text, sizeof(text), "%d", entry.amount);
color = ImVec4(1.0f, 1.0f, 1.0f, alpha);
@ -4883,6 +4937,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
ImGui::PopStyleColor();
}
// Party member cast bar — shows when the party member is casting
if (auto* cs = gameHandler.getUnitCastState(member.guid)) {
float castPct = (cs->timeTotal > 0.0f)
? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f;
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.8f, 0.2f, 1.0f));
char pcastLabel[48];
const std::string& spellNm = gameHandler.getSpellName(cs->spellId);
if (!spellNm.empty())
snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining);
else
snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining);
ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel);
ImGui::PopStyleColor();
}
ImGui::Separator();
ImGui::PopID();
}
@ -4893,6 +4962,97 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) {
ImGui::PopStyleVar();
}
// ============================================================
// Boss Encounter Frames
// ============================================================
void GameScreen::renderBossFrames(game::GameHandler& gameHandler) {
// Collect active boss unit slots
struct BossSlot { uint32_t slot; uint64_t guid; };
std::vector<BossSlot> active;
for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) {
uint64_t g = gameHandler.getEncounterUnitGuid(s);
if (g != 0) active.push_back({s, g});
}
if (active.empty()) return;
const float frameW = 200.0f;
const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f;
float frameY = 120.0f;
ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar |
ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f));
ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always);
if (ImGui::Begin("##BossFrames", nullptr, flags)) {
for (const auto& bs : active) {
ImGui::PushID(static_cast<int>(bs.guid));
// Try to resolve name and health from entity manager
std::string name = "Boss";
uint32_t hp = 0, maxHp = 0;
auto entity = gameHandler.getEntityManager().getEntity(bs.guid);
if (entity && (entity->getType() == game::ObjectType::UNIT ||
entity->getType() == game::ObjectType::PLAYER)) {
auto unit = std::static_pointer_cast<game::Unit>(entity);
const auto& n = unit->getName();
if (!n.empty()) name = n;
hp = unit->getHealth();
maxHp = unit->getMaxHealth();
}
// Clickable name to target
if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) {
gameHandler.setTarget(bs.guid);
}
if (maxHp > 0) {
float pct = static_cast<float>(hp) / static_cast<float>(maxHp);
// Boss health bar in red shades
ImGui::PushStyleColor(ImGuiCol_PlotHistogram,
pct > 0.5f ? ImVec4(0.8f, 0.2f, 0.2f, 1.0f) :
pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) :
ImVec4(1.0f, 0.8f, 0.1f, 1.0f));
char label[32];
std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp);
ImGui::ProgressBar(pct, ImVec2(-1, 14), label);
ImGui::PopStyleColor();
}
// Boss cast bar — shown when the boss is casting (critical for interrupt)
if (auto* cs = gameHandler.getUnitCastState(bs.guid)) {
float castPct = (cs->timeTotal > 0.0f)
? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f;
uint32_t bspell = cs->spellId;
const std::string& bcastName = (bspell != 0)
? gameHandler.getSpellName(bspell) : "";
ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f));
char bcastLabel[72];
if (!bcastName.empty())
snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)",
bcastName.c_str(), cs->timeRemaining);
else
snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining);
ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel);
ImGui::PopStyleColor();
}
ImGui::PopID();
ImGui::Spacing();
}
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar();
}
// ============================================================
// Group Invite Popup (Phase 4)
// ============================================================
@ -6935,6 +7095,34 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) {
ImGui::PopStyleVar();
}
void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) {
if (!gameHandler.isPlayerGhost() || !gameHandler.canReclaimCorpse()) return;
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
float screenH = window ? static_cast<float>(window->getHeight()) : 720.0f;
float btnW = 220.0f, btnH = 36.0f;
ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f));
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f));
if (ImGui::Begin("##ReclaimCorpse", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoBringToFrontOnFocus)) {
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f));
if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) {
gameHandler.reclaimCorpse();
}
ImGui::PopStyleColor(2);
}
ImGui::End();
ImGui::PopStyleColor();
ImGui::PopStyleVar(2);
}
void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) {
if (!gameHandler.showResurrectDialog()) return;
@ -6956,10 +7144,13 @@ void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) {
ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) {
ImGui::Spacing();
const char* text = "Return to life?";
float textW = ImGui::CalcTextSize(text).x;
ImGui::SetCursorPosX((dlgW - textW) / 2);
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text);
const std::string& casterName = gameHandler.getResurrectCasterName();
std::string text = casterName.empty()
? "Return to life?"
: casterName + " wishes to resurrect you.";
float textW = ImGui::CalcTextSize(text.c_str()).x;
ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2));
ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text.c_str());
ImGui::Spacing();
ImGui::Spacing();
@ -7106,10 +7297,20 @@ void GameScreen::renderSettingsWindow() {
saveSettings();
}
}
if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) {
{
bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled());
if (!fsrActive && pendingWaterRefraction) {
// FSR was disabled while refraction was on — auto-disable
pendingWaterRefraction = false;
if (renderer) renderer->setWaterRefractionEnabled(false);
}
if (!fsrActive) ImGui::BeginDisabled();
if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) {
if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction);
saveSettings();
}
if (!fsrActive) ImGui::EndDisabled();
}
{
const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" };
bool fsr2Active = renderer && renderer->isFSR2Enabled();
@ -7962,6 +8163,25 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) {
}
}
// Minimap pings from party members
for (const auto& ping : gameHandler.getMinimapPings()) {
glm::vec3 pingRender = core::coords::canonicalToRender(glm::vec3(ping.wowX, ping.wowY, 0.0f));
float sx = 0.0f, sy = 0.0f;
if (!projectToMinimap(pingRender, sx, sy)) continue;
float t = ping.age / game::GameHandler::MinimapPing::LIFETIME;
float alpha = 1.0f - t;
float pulse = 1.0f + 1.5f * t; // expands outward as it fades
ImU32 col = IM_COL32(255, 220, 0, static_cast<int>(alpha * 200));
ImU32 col2 = IM_COL32(255, 150, 0, static_cast<int>(alpha * 100));
float r1 = 4.0f * pulse;
float r2 = 8.0f * pulse;
drawList->AddCircle(ImVec2(sx, sy), r1, col, 16, 2.0f);
drawList->AddCircle(ImVec2(sx, sy), r2, col2, 16, 1.0f);
drawList->AddCircleFilled(ImVec2(sx, sy), 2.5f, col);
}
auto applyMuteState = [&]() {
auto* activeRenderer = core::Application::getInstance().getRenderer();
float masterScale = soundMuted_ ? 0.0f : static_cast<float>(pendingMasterVolume) / 100.0f;
@ -10157,4 +10377,122 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) {
ImGui::End();
}
// ============================================================================
// Battleground score frame
//
// Displays the current score for the player's battleground using world states.
// Shown in the top-centre of the screen whenever SMSG_INIT_WORLD_STATES has
// been received for a known BG map. The layout adapts per battleground:
//
// WSG 489 Alliance / Horde flag captures (max 3)
// AB 529 Alliance / Horde resource scores (max 1600)
// AV 30 Alliance / Horde reinforcements
// EotS 566 Alliance / Horde resource scores (max 1600)
// ============================================================================
void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) {
// Only show when in a recognised battleground map
uint32_t mapId = gameHandler.getWorldStateMapId();
// World state key sets per battleground
// Keys from the WoW 3.3.5a WorldState.dbc / client source
struct BgScoreDef {
uint32_t mapId;
const char* name;
uint32_t allianceKey; // world state key for Alliance value
uint32_t hordeKey; // world state key for Horde value
uint32_t maxKey; // max score world state key (0 = use hardcoded)
uint32_t hardcodedMax; // used when maxKey == 0
const char* unit; // suffix label (e.g. "flags", "resources")
};
static constexpr BgScoreDef kBgDefs[] = {
// Warsong Gulch: 3 flag captures wins
{ 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" },
// Arathi Basin: 1600 resources wins
{ 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" },
// Alterac Valley: reinforcements count down from 600 / 800 etc.
{ 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" },
// Eye of the Storm: 1600 resources wins
{ 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" },
// Strand of the Ancients (WotLK)
{ 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" },
};
const BgScoreDef* def = nullptr;
for (const auto& d : kBgDefs) {
if (d.mapId == mapId) { def = &d; break; }
}
if (!def) return;
auto allianceOpt = gameHandler.getWorldState(def->allianceKey);
auto hordeOpt = gameHandler.getWorldState(def->hordeKey);
if (!allianceOpt && !hordeOpt) return;
uint32_t allianceScore = allianceOpt.value_or(0);
uint32_t hordeScore = hordeOpt.value_or(0);
uint32_t maxScore = def->hardcodedMax;
if (def->maxKey != 0) {
if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv;
}
auto* window = core::Application::getInstance().getWindow();
float screenW = window ? static_cast<float>(window->getWidth()) : 1280.0f;
// Width scales with screen but stays reasonable
float frameW = 260.0f;
float frameH = 60.0f;
float posX = screenW / 2.0f - frameW / 2.0f;
float posY = 4.0f;
ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always);
ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always);
ImGui::SetNextWindowBgAlpha(0.75f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f));
if (ImGui::Begin("##BGScore", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus |
ImGuiWindowFlags_NoSavedSettings)) {
// BG name centred at top
float nameW = ImGui::CalcTextSize(def->name).x;
ImGui::SetCursorPosX((frameW - nameW) / 2.0f);
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "%s", def->name);
// Alliance score | separator | Horde score
float innerW = frameW - 12.0f;
float halfW = innerW / 2.0f - 4.0f;
ImGui::SetCursorPosX(6.0f);
ImGui::BeginGroup();
{
// Alliance (blue)
char aBuf[32];
if (maxScore > 0 && strlen(def->unit) > 0)
snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore);
else
snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore);
ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "%s", aBuf);
}
ImGui::EndGroup();
ImGui::SameLine(halfW + 16.0f);
ImGui::BeginGroup();
{
// Horde (red)
char hBuf[32];
if (maxScore > 0 && strlen(def->unit) > 0)
snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore);
else
snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore);
ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "%s", hBuf);
}
ImGui::EndGroup();
}
ImGui::End();
ImGui::PopStyleVar(2);
}
}} // namespace wowee::ui