Compare commits

...

64 commits

Author SHA1 Message Date
Kelsi
87cb293297 fix: consume SpellCastTargets bytes after miss list in Classic/TBC SpellGo
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
Added skipClassicSpellCastTargets() and skipTbcSpellCastTargets() calls
in parseSpellGo() for both expansions, matching the same fix applied to
WotLK SpellGoParser and both SpellStartParsers. Prevents packet stream
misalignment for ground-targeted and AoE spells (Blizzard, Rain of
Fire, Flamestrike, etc.) where the server appends DEST_LOCATION or
other target fields after the hit/miss lists.
2026-03-17 22:29:02 -07:00
Kelsi
6f936f258f fix: consume all SpellCastTargets bytes in WotLK SpellGoParser
Applied the same SpellCastTargets fix from SpellStartParser (dd64724)
to SpellGoParser: after parsing hit/miss target lists, now reads the
full target section (UNIT/UNIT_MINIPET/CORPSE/GAMEOBJECT packed GUID,
ITEM/TRADE_ITEM packed GUID, SOURCE/DEST PackedGuid+3floats, null-
terminated STRING). Also adds targetGuid field to SpellGoData so
callers can read the primary target. Prevents stream misalignment on
ground-targeted AoE spells (e.g. Blizzard, Rain of Fire).
2026-03-17 22:26:05 -07:00
Kelsi
dd64724dbb fix: consume all SpellCastTargets bytes in WotLK SpellStartParser
Replaced partial UNIT/OBJECT-only flag handling with full WotLK
SpellCastTargets layout: UNIT/UNIT_MINIPET/CORPSE/GAMEOBJECT share
one PackedGuid, ITEM/TRADE_ITEM share one PackedGuid, SOURCE_LOCATION
and DEST_LOCATION are each PackedGuid+3floats (transport-relative),
STRING is null-terminated. Prevents byte-stream corruption on
ground-targeted AoE and similar multi-field target packets.
2026-03-17 22:20:03 -07:00
Kelsi
a4415eb207 fix: clamp pointCount in handleMonsterMoveTransport to prevent DoS
handleMonsterMoveTransport() read a server-supplied pointCount without
any bounds check before iterating. A malformed packet with
pointCount=0xFFFFFFFF would loop billions of times. All other parsers
(MonsterMoveParser::parse, TBC parseMonsterMove) cap at 1000 or 16384.

Added kMaxTransportSplinePoints=1000 cap with a LOG_WARNING, matching
the limit used by MonsterMoveParser::parse() in world_packets.cpp.
2026-03-17 22:08:25 -07:00
Kelsi
b00025918c feat: draw player facing arrow at minimap center
The minimap had a comment "skip self (already drawn as arrow)" but no
code that actually drew the arrow. Players had no visual indication of
which direction they were facing on the minimap.

Draws a chevron-shaped white/gold arrow at the minimap center:
- On fixed-north minimap: arrow rotates to match camera compass bearing
  (computed from camera forward vector: atan2(-fwd.x, fwd.y))
- On rotating minimap: arrow points straight up because the minimap
  already rotates to put camera-forward at the top
- Style: two filled triangles (tip+left half, tip+right half) with dark
  outline for readability against all map backgrounds
- Rendered last so it sits on top of all other minimap markers
2026-03-17 22:05:24 -07:00
Kelsi
c870460dea fix: wire Warden module tick, generateRC4Keys, and unload callbacks
The funcList_ dispatchers were populated by initializeModule() but the
public tick(), generateRC4Keys(), and unload() methods had their actual
call sites commented out as TODOs.

- tick(): now calls funcList_.tick(deltaMs) so the emulated module can
  run its internal periodic scheduler.
- generateRC4Keys(): now calls funcList_.generateRC4Keys(packet) so
  the Warden crypto stream is re-keyed as the module expects.
- unload(): now calls funcList_.unload(nullptr) before freeing module
  memory, allowing the module to clean up its own state.

All three paths already guard on !loaded_ || !funcList_.<fn> so they
are no-ops when the module is not loaded or Unicorn is unavailable.
2026-03-17 22:00:06 -07:00
Kelsi
32497552d1 fix: R key resets camera angles only; consume all SpellCastTargets bytes
- CameraController::resetAngles(): new method that only resets yaw/pitch
  without teleporting the player. R key now calls resetAngles() instead
  of reset() so pressing R no longer moves the character to spawn.
  The full reset() (position + angles) is still used on world-entry and
  respawn via application.cpp.

- packet_parsers_classic: parseSpellStart now calls
  skipClassicSpellCastTargets() to consume all target payload bytes
  (UNIT, ITEM, SOURCE_LOCATION, DEST_LOCATION, etc.) instead of only
  handling UNIT/OBJECT. Prevents packet-read corruption for ground-
  targeted AoE spells.

- packet_parsers_tbc: added skipTbcSpellCastTargets() static helper
  (uint32 targetFlags, full payload coverage including TRADE_ITEM and
  STRING targets). parseSpellStart now uses it.
2026-03-17 21:52:45 -07:00
Kelsi
a731223e47 fix: right-clicking a quest-starting item now opens the quest offer dialog
Items with startQuestId != 0 were calling useItemBySlot()/useItemInBag()
which sends CMSG_USE_ITEM — but quest-starting items have no on-use spell,
so the server silently ignored the packet and no quest dialog appeared.

Fix:
- offerQuestFromItem(itemGuid, questId): sends CMSG_QUESTGIVER_QUERY_QUEST
  with the item's own GUID as the questgiver GUID. The server responds with
  SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() already picks up
  and opens the Accept/Decline dialog with full rewards/description.
- getBagItemGuid(bagIndex, slotIndex): resolves the per-slot item GUID from
  the bag's containerContents_ map (mirrors the logic inside useItemInBag).
- inventory_screen.cpp right-click handler: checks item.startQuestId != 0
  before the equip/use branch; if set, resolves item GUID and calls
  offerQuestFromItem. Works for both backpack slots and bag slots.
2026-03-17 21:38:08 -07:00
Kelsi
c70740fcdf feat: wire Warden funcList_ dispatchers and implement PacketHandler call
Previously initializeModule() read the 4 WardenFuncList function addresses
from emulated memory, logged them, then discarded them — funcList_ was never
populated, so tick(), generateRC4Keys(), and processCheckRequest() were
permanently no-ops even when the Unicorn emulator successfully ran the module.

Changes:
- initializeModule() now wraps each non-null emulated function address in a
  std::function lambda that marshals args to/from emulated memory via
  emulator_->writeData/callFunction/freeMemory
- generateRC4Keys: copies 4-byte seed to emulated space, calls function
- unload: calls function with NULL (module saves own RC4 state)
- tick: direct uint32_t(deltaMs) dispatch, returns emulated EAX
- packetHandler: 2-arg variant for generic callers
- Stores emulatedPacketHandlerAddr_ for full 4-arg call in processCheckRequest
- processCheckRequest() now calls the emulated PacketHandler with the proper
  4-argument stdcall convention: (data, size, responseOut, responseSizeOut),
  reads back the response size and bytes, returns them in responseOut
- unload() resets emulatedPacketHandlerAddr_ to 0 for clean re-initialization
- Remove dead no-op renderObjectiveTracker() (no call sites, superseded)
2026-03-17 21:29:09 -07:00
Kelsi
005b1fcb54 feat: implement Warden API stub dispatch via Unicorn UC_HOOK_CODE
Previously hookAPI() allocated a stub address and registered a C++ handler
but never stored the handler or wrote any executable code to the stub
region, meaning any Warden module call to a Windows API would execute zeros
and crash or silently return garbage.

Changes:
- Store ApiHookEntry {argCount, handler} per stub address in apiHandlers_
- Write RET (0xC3) to stub memory as a safe fallback
- Register UC_HOOK_CODE over the API stub address range during initialize()
- hookCode() now detects stub addresses, reads args from the emulated stack,
  dispatches to the C++ handler, then simulates stdcall epilogue by setting
  EAX/ESP/EIP so Unicorn returns cleanly to the caller
- Convert static-local nextStubAddr to instance member nextApiStubAddr_
  so re-initialization resets the allocator correctly
- Known arg counts for all 7 registered Windows APIs (VirtualAlloc,
  VirtualFree, GetTickCount, Sleep, GetCurrentThreadId,
  GetCurrentProcessId, ReadProcessMemory)
2026-03-17 21:22:41 -07:00
Kelsi
b29d76bbc8 feat: highlight quest-starting items in loot window with gold indicator
Items with startQuestId != 0 now show:
- Gold outer glow border (2px) around the item icon
- Gold "!" badge in the top-right corner of the icon
- "Begins a Quest" label in gold on the second text line

Matches WoW's visual convention for quest-pickup items in loot rolls.
2026-03-17 21:17:22 -07:00
Kelsi
49ba89dfc3 feat: handle SMSG_PET_UNLEARN_CONFIRM with pet talent respec dialog
Parses the pet talent wipe confirm packet (petGuid + cost), shows a
confirmation dialog matching the player talent reset UX, and sends
CMSG_PET_UNLEARN_TALENTS on confirmation. Completes the pet talent
respec flow for Hunters/Warlocks on WotLK servers.
2026-03-17 21:13:27 -07:00
Kelsi
67c8101f67 fix: add missing TOGGLE_SKILLS to keybinding_manager (fixes CI build failure) 2026-03-17 21:08:02 -07:00
Kelsi
5df5f4d423 feat: handle SMSG_PET_RENAMEABLE to auto-open pet rename dialog on first tame
When the server sends SMSG_PET_RENAMEABLE (after taming a pet for the first
time), the pet rename modal now automatically opens so the player can name
their new pet without needing to right-click the pet frame.
2026-03-17 20:59:29 -07:00
Kelsi
113be66314 feat: parse MSG_BATTLEGROUND_PLAYER_POSITIONS and show flag carriers on minimap
Replaces the silent consume with full packet parsing: reads two lists of
(guid, x, y) positions (typically ally and horde flag carriers) and stores
them in bgPlayerPositions_. Renders each as a colored diamond on the minimap
(blue=group0, red=group1) with a "Flag carrier" tooltip showing the player's
name when available.
2026-03-17 20:54:59 -07:00
Kelsi
48cb7df4b4 feat: add Skills/Professions window (K key) with per-category progress bars
Implements renderSkillsWindow() showing all player skills grouped by
DBC category (Professions, Secondary Skills, Class Skills, Weapon Skills,
Armor, Languages) with value/max progress bars and a bonus breakdown tooltip.
Hooked up to the TOGGLE_SKILLS keybinding (K by default).
2026-03-17 20:46:41 -07:00
Kelsi
d44d462686 feat: add auto-repair at vendor open
When 'Auto Repair' is enabled in Settings > Gameplay, all damaged
equipment is automatically repaired when opening any armorer vendor
(canRepair=true). The repair is skipped when no items are actually
damaged to avoid a pointless server round-trip. A system chat message
confirms the repair. Setting persists to ~/.wowee/settings.cfg as
auto_repair.
2026-03-17 20:27:45 -07:00
Kelsi
072f256af6 feat: add auto-sell grey items on vendor open
When 'Auto Sell Greys' is enabled in Settings > Gameplay, all grey
(ItemQuality::POOR) items in the backpack and extra bags are sold
automatically when opening a vendor window. Items with no sell price
are skipped. A system chat message reports the number of items sold
and total gold received. The setting persists to ~/.wowee/settings.cfg
under the key auto_sell_grey.
2026-03-17 20:21:06 -07:00
Kelsi
e62ae8b03e feat: add local time clock display below minimap coordinates
Shows current local time in HH:MM format in a small dimmed label just
below the coordinate display near the minimap. Uses localtime_r (POSIX)
with a _WIN32 fallback. The clock complements the existing coordinate
and zone name overlays, matching the WoW default UI minimap area.
2026-03-17 20:06:05 -07:00
Kelsi
63f4d10ab1 fix: apply interruptibility coloring to target-of-target cast bar
The ToT (target-of-target) cast bar was still using a fixed orange-yellow
color regardless of spell interruptibility. Now uses the same green/red
scheme as the target frame and nameplate cast bars: green = interruptible
(can Kick/Counterspell), red = not interruptible, both pulse at >80%.
2026-03-17 20:02:02 -07:00
Kelsi
4ce6fdb5f3 feat: color player cast bar by spell school from Spell.dbc
The player's own cast bar now uses spell-school-based colors for quick
identification: Fire=orange-red, Frost=icy blue, Shadow=purple,
Arcane=violet, Nature=green, Holy=golden, Physical=gold. Channels
remain blue regardless of school. Adds getSpellSchoolMask() using the
already-loaded Spell.dbc cache (schoolMask field, covering all
expansions including Classic SchoolEnum→bitmask conversion).
2026-03-17 19:56:52 -07:00
Kelsi
d0df6eed2c feat: show corpse skull marker on world map when player is a ghost
When the player dies and releases spirit, the world map now renders a
bone-white X cross at the corpse's location (matching the existing
minimap skull marker). The marker appears only when the player is a
ghost with an unclaimed corpse on the same map, and shows a "Your
corpse" tooltip on hover. Implemented via setCorpsePos() on WorldMap,
called from renderWorldMap() using getCorpseCanonicalPos().
2026-03-17 19:52:17 -07:00
Kelsi
614fcf6b98 feat: show orange nameplate border when hostile NPC is targeting player
When a hostile unit has UNIT_FIELD_TARGET pointing to the local player,
highlight its nameplate with an orange border so players can immediately
see which enemies are attacking them vs. attacking group members.

Priority: gold=selected, orange=targeting you, dark=default.
2026-03-17 19:47:45 -07:00
Kelsi
1f20f55c62 fix: set interruptible flag on channel start for non-player casters
MSG_CHANNEL_START for NPCs/bosses was leaving UnitCastState::interruptible
at its default (true) instead of checking Spell.dbc AttributesEx.
2026-03-17 19:45:45 -07:00
Kelsi
7c932559e0 fix: apply interruptibility coloring to boss frame cast bars
Boss encounter frames were still using the old fixed orange/red cast bar
color. Update them to match the target frame: green = interruptible,
red = SPELL_ATTR_EX_NOT_INTERRUPTIBLE, both pulse at >80% completion.
2026-03-17 19:44:48 -07:00
Kelsi
279b4de09a feat: color cast bars green/red by spell interruptibility from Spell.dbc
Load AttributesEx from Spell.dbc for all expansions (Classic/TBC/WotLK/
Turtle). Check SPELL_ATTR_EX_NOT_INTERRUPTIBLE (bit 4 = 0x10) to classify
each cast as interruptible or not when SMSG_SPELL_START arrives.

Target frame and nameplate cast bars now use:
- Green: spell can be interrupted by Kick/Counterspell/Pummel etc.
- Red: spell is immune to interrupt (boss abilities, instant-cast effects)
Both colors pulse faster at >80% completion to signal the closing window.

Adds GameHandler::isSpellInterruptible() and UnitCastState::interruptible.
2026-03-17 19:43:19 -07:00
Kelsi
b8712f380d fix: show sub-zone name in minimap label using server-reported zone ID
The zone label above the minimap now preferentially uses the zone/area
name from getWorldStateZoneId() (populated via SMSG_INIT_WORLD_STATES)
rather than the renderer's map-level zone name. This means the label
correctly shows "Ironforge", "Wailing Caverns", etc. instead of always
showing the parent continent zone name.
2026-03-17 19:16:02 -07:00
Kelsi
f9947300da feat: show zone entry text on every zone crossing via SMSG_INIT_WORLD_STATES
Previously the "Entering: [Zone]" overlay only triggered when the terrain
renderer loaded a new map. Now it also fires whenever worldStateZoneId_
changes (sent by the server via SMSG_INIT_WORLD_STATES on each zone
crossing), giving correct "Entering: Ironforge", "Entering: Wailing
Caverns" etc. display for sub-zones and dungeon entries without requiring
a full map reload.

- Added lastKnownWorldStateZoneId_ to track server-reported zone changes
- renderZoneText() now takes GameHandler& to access getWorldStateZoneId()
  and getWhoAreaName() for name lookup via WorldMapArea.dbc cache
- Renderer zone name still checked as a fallback for map-level transitions
- Both sources de-duplicate to avoid triggering the same text twice
2026-03-17 19:14:17 -07:00
Kelsi
4a439fb0d1 feat: add clock-sweep arc to buff bar and target aura icons
Aura icons on the player buff bar and the target frame now display a
WoW-style dark fan overlay that sweeps clockwise as the buff/debuff
elapses, providing instant visual feedback on remaining duration.
The sweep uses AuraSlot::maxDurationMs / getRemainingMs() — the same
data that already drives the numeric countdown — so no new state is
required. Only temporary auras (maxDurationMs > 0) show a sweep;
permanent buffs remain unaffected.
2026-03-17 19:04:40 -07:00
Kelsi
d60d296b77 feat: show discovered taxi nodes as markers on the world map
Add gold diamond markers for every flight master the player has already
discovered (knownTaxiMask_), read from TaxiNodes.dbc and filtered to the
current continent/map being displayed:
- WorldMapTaxiNode struct carries canonical WoW coords + known flag
- WorldMap::setTaxiNodes() accepts the per-frame list from game_screen
- renderImGuiOverlay() projects each known node to UV, draws a gold
  diamond (AddQuadFilled) with a dark outline, and shows the node name
  as a tooltip on hover
- GameHandler::isKnownTaxiNode(id) checks knownTaxiMask_[] efficiently
- Markers update live — newly discovered nodes appear without reopening
  the map
2026-03-17 19:01:03 -07:00
Kelsi
488ec945b6 feat: display glancing and crushing blows in combat text and log
Add GLANCING (hitInfo 0x800) and CRUSHING (hitInfo 0x1000) as distinct
combat text types so players see mechanics feedback they expect from
Classic/TBC content:
- Glancing: shown as "~{amount}" in muted yellow/red; "glances for N" in
  the combat log
- Crushing: shown as "{amount}!" in bright orange/red; "crushes for N!"
  in the combat log
Both types are counted toward DPS meter accumulation. AttackerStateUpdateData
gains isGlancing()/isCrushing() helpers alongside the existing isCrit()/isMiss().
2026-03-17 18:51:48 -07:00
Kelsi
36fed15d43 feat: separate cast/impact kit paths in spell visual DBC lookup
loadSpellVisualDbc() now builds two distinct maps:
  spellVisualCastPath_  — visualId → M2 via SpellVisual.CastKit chain
  spellVisualImpactPath_ — visualId → M2 via SpellVisual.ImpactKit chain

playSpellVisual() accepts useImpactKit=false (default, cast) / true (impact).
SMSG_PLAY_SPELL_IMPACT passes useImpactKit=true so impact effects (explosions,
debuff indicators) use the ImpactKit model instead of the CastKit model.
Added ImpactKit field to all four dbc_layouts.json files.
2026-03-17 18:30:11 -07:00
Kelsi
d558e3a927 fix: separate SMSG_PLAY_SPELL_IMPACT from SMSG_PLAY_OBJECT_SOUND and spawn impact visual
SMSG_PLAY_SPELL_IMPACT has a different wire format from SMSG_PLAY_OBJECT_SOUND:
it carries uint64 targetGuid + uint32 visualId (same as SMSG_PLAY_SPELL_VISUAL),
not uint32 soundId + uint64 sourceGuid.

Previously both were handled together, causing the target GUID low-bytes to be
misread as a sound ID and the visualId to be missed entirely.

Now each handler parses its own format correctly.  SMSG_PLAY_SPELL_IMPACT resolves
the target entity position and calls playSpellVisual() to spawn the M2 impact effect
at that location.
2026-03-17 18:26:55 -07:00
Kelsi
315adfbe93 feat: implement SMSG_PLAY_SPELL_VISUAL with SpellVisual DBC chain lookup
Parse SMSG_PLAY_SPELL_VISUAL (casterGuid + visualId) and spawn a
transient M2 spell effect at the caster's world position.

DBC chain: SpellVisual.dbc → SpellVisualKit.dbc → SpellVisualEffectName.dbc
Lookup priority: CastKit.SpecialEffect0, fallback to MissileModel.
Models are lazy-loaded and cached by path; instances auto-expire after 3.5s.
DBC layouts added to all four expansion layout files (Classic/TBC/WotLK/Turtle).
2026-03-17 18:23:05 -07:00
Kelsi
06ad676be1 fix: surface barber shop, NPC, and LFG autojoin errors in UIError overlay
Add addUIError() for remaining error-only chat-message cases:
- SMSG_BARBER_SHOP_RESULT non-zero result (not enough money, wrong
  location, must stand up)
- SMSG_NPC_WONT_TALK ("That creature can't talk to you right now")
- SMSG_LFG_AUTOJOIN_FAILED and SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER

Completes the UIError improvement pass: all server-reported failure
events now surface as the red on-screen overlay, not chat-only.
2026-03-17 18:08:27 -07:00
Kelsi
2d00f00261 fix: surface LFG/auction/chat/pet errors in UIError overlay
Add addUIError() alongside addSystemChatMessage() for:
- SMSG_CHAT_WRONG_FACTION / SMSG_CHAT_NOT_IN_PARTY / SMSG_CHAT_RESTRICTED
- SMSG_LFG_JOIN_RESULT failure, LFG proposal failure (state=0), LFG
  role check missing-role failure
- SMSG_AUCTION_COMMAND_RESULT error cases (bid/post/cancel/buyout)
- SMSG_PLAYERBINDERROR (hearthstone not bound / bind failed)
- SMSG_READ_ITEM_FAILED
- SMSG_PET_NAME_INVALID

Consistent with the rest of the error-overlay pass: players now see
these failures as the red on-screen overlay text, not just in chat.
2026-03-17 17:56:53 -07:00
Kelsi
cd39cd821f fix: show zone transfer failures and rename errors in UIError overlay
- SMSG_TRANSFER_ABORTED: all zone/instance portal rejection reasons shown as UIError
  (expansion required, instance full, too many instances, zone in combat, etc.)
2026-03-17 17:49:06 -07:00
Kelsi
8411c39eaf fix: surface rename/stable/durability loss errors in UIError overlay
- SMSG_CHAR_RENAME error: "Rename failed: [reason]" shown as UIError
- SMSG_STABLE_RESULT failure (0x09): stable error shown as UIError
- SMSG_DURABILITY_DAMAGE_DEATH: durability loss % shown as UIError overlay
2026-03-17 17:48:04 -07:00
Kelsi
5ad849666d fix: surface pet/raid/talent/instakill errors in UIError overlay
- SMSG_PET_TAME_FAILURE: "Failed to tame: [reason]" shown as UIError
- SMSG_RAID_GROUP_ONLY: "Must be in raid group" shown as UIError
- SMSG_RAID_READY_CHECK_ERROR: all ready check failures shown as UIError
- SMSG_RESET_FAILED_NOTIFY: instance reset failure shown as UIError
- SMSG_TALENTS_INVOLUNTARILY_RESET: talents reset notification shown as UIError
- SMSG_EQUIPMENT_SET_USE_RESULT failure: shown as UIError
- SMSG_SPELLINSTAKILLLOG (player victim): instakill notification shown as UIError
2026-03-17 17:45:45 -07:00
Kelsi
0f2f9ff78d fix: show group kick/party command failures in UIError overlay
- handleGroupUninvite: "You have been removed from the group." now shown as UIError
- handlePartyCommandResult: all party errors (group full, not leader, wrong faction,
  ignoring you, etc.) now also shown as UIError overlay
2026-03-17 17:43:10 -07:00
Kelsi
b22183b000 fix: surface fishing/BG/party/instance/zone notifications in UIError overlay
- Fishing bobber splash: "A fish is on your line!" shown as UIError (time-critical)
- SMSG_BATTLEFIELD_PORT_DENIED: shown as UIError
- SMSG_INSTANCE_RESET_FAILED: failure reason shown as UIError
- SMSG_GROUP_DESTROYED: "Party disbanded" shown as UIError
- SMSG_CORPSE_NOT_IN_INSTANCE: shown as UIError
- SMSG_ZONE_UNDER_ATTACK: "[Zone] is under attack!" shown as UIError
- SMSG_AREA_TRIGGER_MESSAGE: zone/area entry messages shown as UIError
2026-03-17 17:39:02 -07:00
Kelsi
220f1b177c fix: surface trainer/resurrect/innkeeper/difficulty errors in UIError overlay
- SMSG_TRAINER_BUY_FAILED: "Cannot learn [spell]" now appears as red overlay
- SMSG_RESURRECT_FAILED: all resurrection failure reasons shown as UIError
- SMSG_BINDZONEREPLY error: "Too far from innkeeper" shown as UIError
- SMSG_CHANGEPLAYER_DIFFICULTY_RESULT error: reason shown as UIError
- SMSG_INVENTORY_CHANGE_FAILURE case 1: level-gated equip error now calls
  addUIError before the early break, matching all other inventory error paths
2026-03-17 17:36:25 -07:00
Kelsi
495dfb7aae fix: show buy/sell vendor failures in UIError overlay
SMSG_BUY_FAILED ("Not enough money", "Sold out", etc.) and
SMSG_SELL_ITEM non-zero results now call addUIError() so the error
appears on screen alongside the chat message.
2026-03-17 17:25:27 -07:00
Kelsi
fba6aba80d fix: show inventory, mount, and socket errors in UIError overlay
Several server-reported action failures were posting to chat only
without firing the red on-screen UIError overlay:
- SMSG_INVENTORY_CHANGE_FAILURE: bag full, wrong slot, can't equip, etc.
- SMSG_MOUNTRESULT / SMSG_DISMOUNTRESULT: mount denied errors
- SMSG_QUESTLOG_FULL: quest log at capacity
- SMSG_SOCKET_GEMS_RESULT: gem socketing failure

All now call addUIError() in addition to addSystemChatMessage() so
players see the error immediately on screen without looking at chat.
2026-03-17 17:24:23 -07:00
Kelsi
dcf9aeed92 fix: show SMSG_CAST_FAILED reason in UIError overlay
handleCastFailed was only posting to chat; now also calls addUIError
so mid-cast server rejections (e.g. "Interrupted") show the same red
on-screen overlay as SMSG_CAST_RESULT failures already did.
2026-03-17 17:20:24 -07:00
Kelsi
caad20285b feat: add hover tooltips to character sheet stats panel
Hovering over Armor, primary stats (Strength/Agility/Stamina/
Intellect/Spirit), and secondary rating stats now shows a brief
description of the stat's in-game effect — matching WoW's native
character screen behavior.

Uses ImGui::BeginGroup/EndGroup to make multi-widget rows (stat +
green bonus) respond to a single IsItemHovered check.
2026-03-17 17:16:56 -07:00
Kelsi
d1a392cd0e feat: add colors for SKILL, LOOT, BG system, and monster chat types
Added distinct colors for chat types that previously fell through to
the gray default: SKILL (cyan), LOOT (light purple), GUILD_ACHIEVEMENT
(gold), MONSTER_WHISPER/RAID_BOSS_WHISPER (pink), RAID_BOSS_EMOTE
(orange), MONSTER_PARTY (blue), BG_SYSTEM_NEUTRAL/ALLIANCE/HORDE
(gold/blue/red), and AFK/DND (light gray).
2026-03-17 17:00:46 -07:00
Kelsi
1e80e294f0 feat: add Heroic and Unique-Equipped indicators to chat link tooltips
Chat item link tooltips now show "Heroic" (green) for items with
ITEM_FLAG_HEROIC_TOOLTIP (0x8) and "Unique-Equipped" for items with
ITEM_FLAG_UNIQUE_EQUIPPABLE (0x1000000), matching InventoryScreen.
"Unique" text is now gold-colored to match as well.
2026-03-17 16:56:37 -07:00
Kelsi
cb99dbaea4 feat: add elemental resistances and full spell descriptions to chat link tooltips
Chat item link tooltips now show per-school elemental resistances
(Holy/Fire/Nature/Frost/Shadow/Arcane) when non-zero, matching the
inventory tooltip. Spell effect text (Use/Equip/Chance on Hit) now
shows the full spell description instead of just the spell name,
consistent with InventoryScreen::renderItemTooltip.
2026-03-17 16:54:40 -07:00
Kelsi
7e6de75e8a feat: show skill, reputation, class and race requirements in chat link tooltips
Chat item link tooltips now match InventoryScreen for required-skill
(SkillLine.dbc), required-reputation (Faction.dbc), class restriction,
and race restriction. Red text when the player does not meet the
requirement, grey otherwise.
2026-03-17 16:47:33 -07:00
Kelsi
dab03f2729 feat: show item set name and bonuses in chat item link tooltips
Chat link tooltips (hover over item links in chat) were missing item set
information already shown in the inventory tooltip.  Now shows:
- Set name with equipped/total piece count (e.g. "Tier 9 (2/5)")
- Each set bonus with its piece-threshold, colored green when active
  and grey when inactive
- Falls back to "Set (id N)" when ItemSet.dbc is unavailable

Lazy-loads ItemSet.dbc on first hover; consistent with
InventoryScreen::renderItemTooltip formatting.
2026-03-17 16:43:57 -07:00
Kelsi
dee33db0aa feat: show gem socket slots and socket bonus in chat item link tooltips
Item tooltips shown when hovering chat links already displayed all stats,
spells, and flavor text, but gem sockets were missing.  Add the same
socket rendering used in the inventory tooltip:

- Iterate socketColor[0..2]; for each non-zero slot show a colored label
  (Meta / Red / Yellow / Blue Socket) in the socket's faction color
- Lazy-load SpellItemEnchantment.dbc to resolve the socketBonus enchant
  name; fall back to "(id N)" when the record is not found
- Consistent with InventoryScreen::renderItemTooltip formatting
2026-03-17 16:42:19 -07:00
Kelsi
973db16658 feat: add screen-space weather particle overlay (rain/snow/storm)
Weather type and intensity are already tracked from SMSG_WEATHER, but
only an icon was shown next to the zone name.  This adds a fullscreen
ImDrawList overlay that renders:
- Rain (type 1): diagonal rain streaks proportional to intensity
- Snow (type 2): gently swaying snowflakes with two-tone highlight
- Storm (type 3): heavy rain + dark fog-vignette on screen edges

Particles wrap at screen boundaries and are re-seeded on type or
resolution change.  Delta time is capped at 50 ms to prevent teleporting
after focus loss.  No heap allocations at runtime (static local arrays).
2026-03-17 16:34:39 -07:00
Kelsi
1f1925797f feat: show cooldown overlay on pet action bar spell buttons
Pet spell buttons now dim and display remaining cooldown time when a
spell is on cooldown, matching the feedback available on the player
action bar. Clicking a pet spell while it is on cooldown is also
suppressed to prevent spam-sending CMSG_PET_ACTION to the server.
Cooldown time appears as a text overlay (seconds or "Nm" for minutes)
and is also shown in the hover tooltip.
2026-03-17 15:59:27 -07:00
Kelsi
98dc2a0dc7 feat: show Max Level bar at max level instead of hiding XP bar
When nextLevelXp==0 and playerLevel>0, render a gold fully-filled bar
with centered "Max Level" text instead of hiding the XP bar entirely.
Fixes missing closing brace that caused all subsequent methods to fail
compilation.
2026-03-17 15:28:33 -07:00
Kelsi
c15ef915bf feat: add Ctrl+1..3 keyboard shortcuts for stance/form/presence switching
Ctrl+1, Ctrl+2, Ctrl+3 (up to Ctrl+8 for Druids with many forms) now
cast the Nth available stance spell for classes that use a stance bar.
Ordering matches the stance bar UI so visual and keyboard positions align.
Normal action bar keys 1–= are skipped when Ctrl is held to prevent
accidental spell casts instead of stance switches.
2026-03-17 15:18:04 -07:00
Kelsi
6d83027226 feat: add stance/form/presence bar for Warriors, Druids, Death Knights, Rogues, Priests
Renders a stance bar to the left of the main action bar showing the
player's known stance spells filtered to only those they have learned:
- Warrior: Battle Stance, Defensive Stance, Berserker Stance
- Death Knight: Blood Presence, Frost Presence, Unholy Presence
- Druid: Bear/Dire Bear, Cat, Travel, Aquatic, Moonkin, Tree, Flight forms
- Rogue: Stealth
- Priest: Shadowform

Active form detected from permanent player auras (maxDurationMs == -1).
Clicking an inactive stance casts the corresponding spell. Active stance
shown with green border/tint; inactive stances are slightly dimmed.
Spell name tooltips shown on hover using existing SpellbookScreen lookup.
2026-03-17 15:12:58 -07:00
Kelsi
4edc4017ed feat: show extra stats in equipped item comparison panel (shift-hover)
When shift-hovering an item link in chat to compare with equipped gear,
also display extra stats (hit/crit/haste/AP/SP/expertise) for the
currently-equipped item, matching what is shown for the hovered item.
2026-03-17 14:50:28 -07:00
Kelsi
3b79f44b54 feat: show item flavor/lore text in item tooltip
Display item description (flavor text) from SMSG_ITEM_QUERY_SINGLE_RESPONSE
at the bottom of item tooltips in gold color, matching WoW's standard
tooltip layout where lore text appears below stats and effects.
2026-03-17 14:44:15 -07:00
Kelsi
020ba134cd feat: show item spell effects (Use/Equip/Teaches) in item tooltip
Display Use, Equip, Chance on Hit, and Teaches spell effects from
SMSG_ITEM_QUERY_SINGLE_RESPONSE in item tooltips. Looks up spell
name from Spell.dbc via SpellbookScreen for readable descriptions.
2026-03-17 14:43:22 -07:00
Kelsi
03397ec23c feat: show extra item stats in tooltip (hit/crit/haste/sp/ap/expertise)
Display all ExtraStat entries from SMSG_ITEM_QUERY_SINGLE_RESPONSE in
the item tooltip (hit rating, crit rating, haste, spell power, attack
power, expertise, resilience, etc.). These were previously silently
discarded, making WotLK/TBC gear tooltips incomplete.
2026-03-17 14:42:00 -07:00
Kelsi
f04875514e feat: improve item tooltip with bind type, item level, weapon damage range, and required level
Add standard WoW tooltip fields that were previously missing:
- Bind type (Binds when picked up/equipped/used, Quest Item)
- Unique indicator
- Item Level XX
- Weapon damage range (e.g. '22 - 41 Damage  Speed 2.20') replacing bare DPS
- Damage per second sub-line in dimmed text
- Requires Level XX
2026-03-17 14:41:00 -07:00
Kelsi
8b57e6fa45 feat: add HONOR_GAIN floating combat text for PvP honor gains
Show '+X Honor' floating text in gold when SMSG_PVP_CREDIT is received,
matching WoW's native behavior. Also add HONOR_GAIN to the combat log
panel for a complete record. Previously only a chat message was added.
2026-03-17 14:38:57 -07:00
Kelsi
b6ea78dfab fix: show spell name in REFLECT floating combat text
REFLECT entries already stored the reflected spell ID but the floating
text display showed only "Reflected"/"You Reflect" without the name.
Now shows "Reflected: Fireball" or "Reflect: Frost Nova", matching the
pattern already used by INTERRUPT, DISPEL, and STEAL entries.
2026-03-17 14:26:10 -07:00
26 changed files with 2501 additions and 219 deletions

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33,
"DispelType": 4
@ -95,5 +95,14 @@
"ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3,
"LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7,
"DisplayMapID": 8, "ParentWorldMapID": 10
},
"SpellVisual": {
"ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8
},
"SpellVisualKit": {
"ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13
},
"SpellVisualEffectName": {
"ID": 0, "FilePath": 2
}
}

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 124,
"ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 124,
"Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215,
"CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40,
"DispelType": 3
@ -111,5 +111,14 @@
"Threshold0": 38, "Threshold1": 39, "Threshold2": 40, "Threshold3": 41,
"Threshold4": 42, "Threshold5": 43, "Threshold6": 44, "Threshold7": 45,
"Threshold8": 46, "Threshold9": 47
},
"SpellVisual": {
"ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8
},
"SpellVisualKit": {
"ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13
},
"SpellVisualEffectName": {
"ID": 0, "FilePath": 2
}
}

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 5, "IconID": 117,
"ID": 0, "Attributes": 5, "AttributesEx": 6, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33,
"DispelType": 4
@ -108,5 +108,14 @@
"Threshold0": 30, "Threshold1": 31, "Threshold2": 32, "Threshold3": 33,
"Threshold4": 34, "Threshold5": 35, "Threshold6": 36, "Threshold7": 37,
"Threshold8": 38, "Threshold9": 39
},
"SpellVisual": {
"ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8
},
"SpellVisualKit": {
"ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13
},
"SpellVisualEffectName": {
"ID": 0, "FilePath": 2
}
}

View file

@ -1,6 +1,6 @@
{
"Spell": {
"ID": 0, "Attributes": 4, "IconID": 133,
"ID": 0, "Attributes": 4, "AttributesEx": 5, "IconID": 133,
"Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225,
"PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49,
"DispelType": 2
@ -116,5 +116,14 @@
},
"LFGDungeons": {
"ID": 0, "Name": 1
},
"SpellVisual": {
"ID": 0, "CastKit": 2, "ImpactKit": 3, "MissileModel": 8
},
"SpellVisualKit": {
"ID": 0, "BaseEffect": 5, "SpecialEffect0": 11, "SpecialEffect1": 12, "SpecialEffect2": 13
},
"SpellVisualEffectName": {
"ID": 0, "FilePath": 2
}
}

View file

@ -497,6 +497,15 @@ public:
return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_;
}
// BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS)
struct BgPlayerPosition {
uint64_t guid = 0;
float wowX = 0.0f; // canonical WoW X (north)
float wowY = 0.0f; // canonical WoW Y (west)
int group = 0; // 0 = first list (usually ally flag carriers), 1 = second list
};
const std::vector<BgPlayerPosition>& getBgPlayerPositions() const { return bgPlayerPositions_; }
// Network latency (milliseconds, updated each PONG response)
uint32_t getLatencyMs() const { return lastLatency; }
@ -709,6 +718,8 @@ public:
void dismissPet();
void renamePet(const std::string& newName);
bool hasPet() const { return petGuid_ != 0; }
// Returns true once after SMSG_PET_RENAMEABLE; consuming the flag clears it.
bool consumePetRenameablePending() { bool v = petRenameablePending_; petRenameablePending_ = false; return v; }
uint64_t getPetGuid() const { return petGuid_; }
// ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ----
@ -798,6 +809,7 @@ public:
uint32_t spellId = 0;
float timeRemaining = 0.0f;
float timeTotal = 0.0f;
bool interruptible = true; ///< false when SPELL_ATTR_EX_NOT_INTERRUPTIBLE is set
};
// Returns cast state for any unit by GUID (empty/non-casting if not found)
const UnitCastState* getUnitCastState(uint64_t guid) const {
@ -819,6 +831,10 @@ public:
auto* s = getUnitCastState(targetGuid);
return s ? s->timeRemaining : 0.0f;
}
bool isTargetCastInterruptible() const {
auto* s = getUnitCastState(targetGuid);
return s ? s->interruptible : true;
}
// Talents
uint8_t getActiveTalentSpec() const { return activeTalentSpec_; }
@ -1160,6 +1176,11 @@ public:
uint32_t getTalentWipeCost() const { return talentWipeCost_; }
void confirmTalentWipe();
void cancelTalentWipe() { talentWipePending_ = false; }
// Pet talent respec confirm
bool showPetUnlearnDialog() const { return petUnlearnPending_; }
uint32_t getPetUnlearnCost() const { return petUnlearnCost_; }
void confirmPetUnlearn();
void cancelPetUnlearn() { petUnlearnPending_ = false; }
/** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const;
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
@ -1389,6 +1410,10 @@ public:
const LootResponseData& getCurrentLoot() const { return currentLoot; }
void setAutoLoot(bool enabled) { autoLoot_ = enabled; }
bool isAutoLoot() const { return autoLoot_; }
void setAutoSellGrey(bool enabled) { autoSellGrey_ = enabled; }
bool isAutoSellGrey() const { return autoSellGrey_; }
void setAutoRepair(bool enabled) { autoRepair_ = enabled; }
bool isAutoRepair() const { return autoRepair_; }
// Master loot candidates (from SMSG_LOOT_MASTER_LIST)
const std::vector<uint64_t>& getMasterLootCandidates() const { return masterLootCandidates_; }
@ -1435,6 +1460,9 @@ public:
void acceptQuest();
void declineQuest();
void closeGossip();
// Quest-starting items: right-click triggers quest offer dialog via questgiver protocol
void offerQuestFromItem(uint64_t itemGuid, uint32_t questId);
uint64_t getBagItemGuid(int bagIndex, int slotIndex) const;
bool isGossipWindowOpen() const { return gossipWindowOpen; }
const GossipMessageData& getCurrentGossip() const { return currentGossip; }
bool isQuestDetailsOpen() {
@ -1919,6 +1947,11 @@ public:
float x = 0, y = 0, z = 0;
};
const std::unordered_map<uint32_t, TaxiNode>& getTaxiNodes() const { return taxiNodes_; }
bool isKnownTaxiNode(uint32_t nodeId) const {
if (nodeId == 0 || nodeId > 384) return false;
uint32_t idx = nodeId - 1;
return (knownTaxiMask_[idx / 32] & (1u << (idx % 32))) != 0;
}
uint32_t getTaxiCostTo(uint32_t destNodeId) const;
bool taxiNpcHasRoutes(uint64_t guid) const {
auto it = taxiNpcHasRoutes_.find(guid);
@ -2051,6 +2084,13 @@ public:
const std::string& getSkillLineName(uint32_t spellId) const;
/// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other)
uint8_t getSpellDispelType(uint32_t spellId) const;
/// Returns true if the spell can be interrupted by abilities like Kick/Counterspell.
/// False for spells with SPELL_ATTR_EX_NOT_INTERRUPTIBLE (attrEx bit 4 = 0x10).
bool isSpellInterruptible(uint32_t spellId) const;
/// Returns the school bitmask for the spell from Spell.dbc
/// (0x01=Physical, 0x02=Holy, 0x04=Fire, 0x08=Nature, 0x10=Frost, 0x20=Shadow, 0x40=Arcane).
/// Returns 0 if unknown.
uint32_t getSpellSchoolMask(uint32_t spellId) const;
struct TrainerTab {
std::string name;
@ -2717,6 +2757,7 @@ private:
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
bool petRenameablePending_ = false; // set by SMSG_PET_RENAMEABLE, consumed by UI
std::vector<uint32_t> petSpellList_; // known pet spells
std::unordered_set<uint32_t> petAutocastSpells_; // spells with autocast on
@ -2759,6 +2800,9 @@ private:
// BG scoreboard (MSG_PVP_LOG_DATA)
BgScoreboardData bgScoreboard_;
// BG flag carrier / player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS)
std::vector<BgPlayerPosition> bgPlayerPositions_;
// Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT)
std::array<uint64_t, kMaxEncounterSlots> encounterUnitGuids_ = {}; // 0 = empty slot
@ -2862,6 +2906,8 @@ private:
// ---- Phase 5: Loot ----
bool lootWindowOpen = false;
bool autoLoot_ = false;
bool autoSellGrey_ = false;
bool autoRepair_ = false;
LootResponseData currentLoot;
std::vector<uint64_t> masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST
@ -3054,7 +3100,7 @@ private:
// Trainer
bool trainerWindowOpen_ = false;
TrainerListData currentTrainerList_;
struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; };
struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; };
std::unordered_map<uint32_t, SpellNameEntry> spellNameCache_;
bool spellNameCacheLoaded_ = false;
@ -3267,6 +3313,10 @@ private:
bool talentWipePending_ = false;
uint64_t talentWipeNpcGuid_ = 0;
uint32_t talentWipeCost_ = 0;
// ---- Pet talent respec confirm dialog ----
bool petUnlearnPending_ = false;
uint64_t petUnlearnGuid_ = 0;
uint32_t petUnlearnCost_ = 0;
bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST
uint64_t resurrectCasterGuid_ = 0;
std::string resurrectCasterName_;

View file

@ -53,7 +53,7 @@ struct CombatTextEntry {
MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK,
EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL,
ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER,
DISPEL, STEAL, INTERRUPT, INSTAKILL
DISPEL, STEAL, INTERRUPT, INSTAKILL, HONOR_GAIN, GLANCING, CRUSHING
};
Type type;
int32_t amount = 0;

View file

@ -147,9 +147,18 @@ private:
uint32_t heapSize_; // Heap size
uint32_t apiStubBase_; // API stub base address
// API hooks: DLL name -> Function name -> Handler
// API hooks: DLL name -> Function name -> stub address
std::map<std::string, std::map<std::string, uint32_t>> apiAddresses_;
// API stub dispatch: stub address -> {argCount, handler}
struct ApiHookEntry {
int argCount;
std::function<uint32_t(WardenEmulator&, const std::vector<uint32_t>&)> handler;
};
std::map<uint32_t, ApiHookEntry> apiHandlers_;
uint32_t nextApiStubAddr_; // tracks next free stub slot (replaces static local)
bool apiCodeHookRegistered_; // true once UC_HOOK_CODE for stub range is added
// Memory allocation tracking
std::map<uint32_t, size_t> allocations_;
std::map<uint32_t, size_t> freeBlocks_; // free-list keyed by base address

View file

@ -140,6 +140,7 @@ private:
size_t relocDataOffset_ = 0; // Offset into decompressedData_ where relocation data starts
WardenFuncList funcList_; // Callback functions
std::unique_ptr<WardenEmulator> emulator_; // Cross-platform x86 emulator
uint32_t emulatedPacketHandlerAddr_ = 0; // Raw emulated VA for 4-arg PacketHandler call
// Validation and loading steps
bool verifyMD5(const std::vector<uint8_t>& data,

View file

@ -1719,8 +1719,10 @@ struct AttackerStateUpdateData {
uint32_t blocked = 0;
bool isValid() const { return attackerGuid != 0; }
bool isCrit() const { return (hitInfo & 0x200) != 0; }
bool isMiss() const { return (hitInfo & 0x10) != 0; }
bool isCrit() const { return (hitInfo & 0x0200) != 0; }
bool isMiss() const { return (hitInfo & 0x0010) != 0; }
bool isGlancing() const { return (hitInfo & 0x0800) != 0; }
bool isCrushing() const { return (hitInfo & 0x1000) != 0; }
};
class AttackerStateUpdateParser {
@ -1873,6 +1875,7 @@ struct SpellGoData {
std::vector<uint64_t> hitTargets;
uint8_t missCount = 0;
std::vector<SpellGoMissEntry> missTargets;
uint64_t targetGuid = 0; ///< Primary target GUID from SpellCastTargets (0 = none/AoE)
bool isValid() const { return spellId != 0; }
};

View file

@ -44,6 +44,7 @@ public:
}
void reset();
void resetAngles();
void teleportTo(const glm::vec3& pos);
void setOnlineMode(bool online) { onlineMode = online; }

View file

@ -6,6 +6,7 @@
#include <vector>
#include <future>
#include <cstddef>
#include <unordered_map>
#include <glm/glm.hpp>
#include <vulkan/vulkan.h>
#include <vk_mem_alloc.h>
@ -152,6 +153,11 @@ public:
void playEmote(const std::string& emoteName);
void triggerLevelUpEffect(const glm::vec3& position);
void cancelEmote();
// Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT)
// useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path
void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
bool useImpactKit = false);
bool isEmoteActive() const { return emoteActive; }
static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr);
static uint32_t getEmoteDbcId(const std::string& emoteName);
@ -323,6 +329,19 @@ private:
glm::mat4 computeLightSpaceMatrix();
pipeline::AssetManager* cachedAssetManager = nullptr;
// Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL/IMPACT
struct SpellVisualInstance { uint32_t instanceId; float elapsed; };
std::vector<SpellVisualInstance> activeSpellVisuals_;
std::unordered_map<uint32_t, std::string> spellVisualCastPath_; // visualId → cast M2 path
std::unordered_map<uint32_t, std::string> spellVisualImpactPath_; // visualId → impact M2 path
std::unordered_map<std::string, uint32_t> spellVisualModelIds_; // M2 path → M2Renderer modelId
uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799
bool spellVisualDbcLoaded_ = false;
void loadSpellVisualDbc();
void updateSpellVisuals(float deltaTime);
static constexpr float SPELL_VISUAL_DURATION = 3.5f;
uint32_t currentZoneId = 0;
std::string currentZoneName;
bool inTavern_ = false;

View file

@ -25,6 +25,15 @@ struct WorldMapPartyDot {
std::string name; ///< Member name (shown as tooltip on hover)
};
/// Taxi (flight master) node passed from the UI layer for world map overlay.
struct WorldMapTaxiNode {
uint32_t id = 0; ///< TaxiNodes.dbc ID
uint32_t mapId = 0; ///< WoW internal map ID (0=EK,1=Kal,530=Outland,571=Northrend)
float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates
std::string name; ///< Node name (shown as tooltip)
bool known = false; ///< Player has discovered this node
};
struct WorldMapZone {
uint32_t wmaID = 0;
uint32_t areaID = 0; // 0 = continent level
@ -57,6 +66,14 @@ public:
void setMapName(const std::string& name);
void setServerExplorationMask(const std::vector<uint32_t>& masks, bool hasData);
void setPartyDots(std::vector<WorldMapPartyDot> dots) { partyDots_ = std::move(dots); }
void setTaxiNodes(std::vector<WorldMapTaxiNode> nodes) { taxiNodes_ = std::move(nodes); }
/// Set the player's corpse position for overlay rendering.
/// @param hasCorpse True when the player is a ghost with an unclaimed corpse on this map.
/// @param renderPos Corpse position in render-space coordinates.
void setCorpsePos(bool hasCorpse, glm::vec3 renderPos) {
hasCorpse_ = hasCorpse;
corpseRenderPos_ = renderPos;
}
bool isOpen() const { return open; }
void close() { open = false; }
@ -127,6 +144,14 @@ private:
// Party member dots (set each frame from the UI layer)
std::vector<WorldMapPartyDot> partyDots_;
// Taxi node markers (set each frame from the UI layer)
std::vector<WorldMapTaxiNode> taxiNodes_;
int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC)
// Corpse marker (ghost state — set each frame from the UI layer)
bool hasCorpse_ = false;
glm::vec3 corpseRenderPos_ = {};
// Exploration / fog of war
std::vector<uint32_t> serverExplorationMask;
bool hasServerExplorationMask = false;

View file

@ -195,6 +195,8 @@ private:
bool pendingSeparateBags = true;
bool pendingShowKeyring = true;
bool pendingAutoLoot = false;
bool pendingAutoSellGrey = false;
bool pendingAutoRepair = false;
// Keybinding customization
int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index
@ -317,6 +319,7 @@ private:
// ---- New UI renders ----
void renderActionBar(game::GameHandler& gameHandler);
void renderStanceBar(game::GameHandler& gameHandler);
void renderBagBar(game::GameHandler& gameHandler);
void renderXpBar(game::GameHandler& gameHandler);
void renderRepBar(game::GameHandler& gameHandler);
@ -355,6 +358,7 @@ private:
void renderReclaimCorpseButton(game::GameHandler& gameHandler);
void renderResurrectDialog(game::GameHandler& gameHandler);
void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler);
void renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler);
void renderEscapeMenu();
void renderSettingsWindow();
void applyGraphicsPreset(GraphicsPreset preset);
@ -375,7 +379,6 @@ private:
void renderGuildBankWindow(game::GameHandler& gameHandler);
void renderAuctionHouseWindow(game::GameHandler& gameHandler);
void renderDungeonFinderWindow(game::GameHandler& gameHandler);
void renderObjectiveTracker(game::GameHandler& gameHandler);
void renderInstanceLockouts(game::GameHandler& gameHandler);
void renderNameplates(game::GameHandler& gameHandler);
void renderBattlegroundScore(game::GameHandler& gameHandler);
@ -435,6 +438,10 @@ private:
char achievementSearchBuf_[128] = {};
void renderAchievementWindow(game::GameHandler& gameHandler);
// Skills / Professions window (K key)
bool showSkillsWindow_ = false;
void renderSkillsWindow(game::GameHandler& gameHandler);
// Titles window
bool showTitlesWindow_ = false;
void renderTitlesWindow(game::GameHandler& gameHandler);
@ -633,7 +640,9 @@ private:
float zoneTextTimer_ = 0.0f;
std::string zoneTextName_;
std::string lastKnownZoneName_;
void renderZoneText();
uint32_t lastKnownWorldStateZoneId_ = 0;
void renderZoneText(game::GameHandler& gameHandler);
void renderWeatherOverlay(game::GameHandler& gameHandler);
// Cooldown tracker
bool showCooldownTracker_ = false;

View file

@ -30,6 +30,7 @@ public:
TOGGLE_NAMEPLATES,
TOGGLE_RAID_FRAMES,
TOGGLE_ACHIEVEMENTS,
TOGGLE_SKILLS,
ACTION_COUNT
};

View file

@ -1849,12 +1849,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_CHAT_WRONG_FACTION:
addUIError("You cannot send messages to members of that faction.");
addSystemChatMessage("You cannot send messages to members of that faction.");
break;
case Opcode::SMSG_CHAT_NOT_IN_PARTY:
addUIError("You are not in a party.");
addSystemChatMessage("You are not in a party.");
break;
case Opcode::SMSG_CHAT_RESTRICTED:
addUIError("You cannot send chat messages in this area.");
addSystemChatMessage("You cannot send chat messages in this area.");
break;
@ -2049,6 +2052,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint8_t reason = packet.readUInt8();
const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason";
std::string s = std::string("Failed to tame: ") + msg;
addUIError(s);
addSystemChatMessage(s);
}
break;
@ -2168,6 +2172,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::dec, " rank=", rank);
std::string msg = "You gain " + std::to_string(honor) + " honor points.";
addSystemChatMessage(msg);
if (honor > 0)
addCombatText(CombatTextEntry::HONOR_GAIN, static_cast<int32_t>(honor), 0, true);
if (pvpHonorCallback_) {
pvpHonorCallback_(honor, victimGuid, rank);
}
@ -2369,7 +2375,8 @@ void GameHandler::handlePacket(network::Packet& packet) {
case 0x06: msg = "Pet retrieved from stable."; break;
case 0x07: msg = "Stable slot purchased."; break;
case 0x08: msg = "Stable list updated."; break;
case 0x09: msg = "Stable failed: not enough money or other error."; break;
case 0x09: msg = "Stable failed: not enough money or other error.";
addUIError(msg); break;
default: break;
}
if (msg) addSystemChatMessage(msg);
@ -2525,8 +2532,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
"Character name does not meet requirements.", // 7
};
const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr;
addSystemChatMessage(errMsg ? std::string("Rename failed: ") + errMsg
: "Character rename failed.");
std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg
: "Character rename failed.";
addUIError(renameErr);
addSystemChatMessage(renameErr);
}
LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName);
}
@ -2539,6 +2548,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (result == 0) {
addSystemChatMessage("Your home is now set to this location.");
} else {
addUIError("You are too far from the innkeeper.");
addSystemChatMessage("You are too far from the innkeeper.");
}
}
@ -2557,12 +2567,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
"You must be in a raid group", "Player not in group"
};
const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed.";
addUIError(std::string("Cannot change difficulty: ") + msg);
addSystemChatMessage(std::string("Cannot change difficulty: ") + msg);
}
}
break;
}
case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE:
addUIError("Your corpse is outside this instance.");
addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it.");
break;
case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: {
@ -2815,7 +2827,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t result = packet.readUInt32();
if (result != 4) {
const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." };
addSystemChatMessage(result < 4 ? msgs[result] : "Cannot mount.");
std::string mountErr = result < 4 ? msgs[result] : "Cannot mount.";
addUIError(mountErr);
addSystemChatMessage(mountErr);
}
break;
}
@ -2823,7 +2837,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
// uint32 result: 0=ok, others=error
if (packet.getSize() - packet.getReadPos() < 4) break;
uint32_t result = packet.readUInt32();
if (result != 0) addSystemChatMessage("Cannot dismount here.");
if (result != 0) { addUIError("Cannot dismount here."); addSystemChatMessage("Cannot dismount here."); }
break;
}
@ -3251,10 +3265,24 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleSpellDamageLog(packet);
break;
case Opcode::SMSG_PLAY_SPELL_VISUAL: {
// Minimal parse: uint64 casterGuid, uint32 visualId
// uint64 casterGuid + uint32 visualId
if (packet.getSize() - packet.getReadPos() < 12) break;
packet.readUInt64();
packet.readUInt32();
uint64_t casterGuid = packet.readUInt64();
uint32_t visualId = packet.readUInt32();
if (visualId == 0) break;
// Resolve caster world position and spawn the effect
auto* renderer = core::Application::getInstance().getRenderer();
if (!renderer) break;
glm::vec3 spawnPos;
if (casterGuid == playerGuid) {
spawnPos = renderer->getCharacterPosition();
} else {
auto entity = entityManager.getEntity(casterGuid);
if (!entity) break;
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
spawnPos = core::coords::canonicalToRender(canonical);
}
renderer->playSpellVisual(visualId, spawnPos);
break;
}
case Opcode::SMSG_SPELLHEALLOG:
@ -3455,6 +3483,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::string msg = areaName.empty()
? std::string("A zone is under attack!")
: (areaName + " is under attack!");
addUIError(msg);
addSystemChatMessage(msg);
}
break;
@ -3549,6 +3578,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
char buf[80];
std::snprintf(buf, sizeof(buf),
"You have lost %u%% of your gear's durability due to death.", pct);
addUIError(buf);
addSystemChatMessage(buf);
}
break;
@ -3586,6 +3616,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
partyData.members.clear();
partyData.memberCount = 0;
partyData.leaderGuid = 0;
addUIError("Your party has been disbanded.");
addSystemChatMessage("Your party has been disbanded.");
LOG_INFO("SMSG_GROUP_DESTROYED: party cleared");
break;
@ -3955,6 +3986,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
else if (errorCode == 2) msg += " (already known)";
else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")";
addUIError(msg);
addSystemChatMessage(msg);
// Play error sound so the player notices the failure
if (auto* renderer = core::Application::getInstance().getRenderer()) {
@ -4473,6 +4505,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint32_t len =*/ packet.readUInt32();
std::string msg = packet.readString();
if (!msg.empty()) {
addUIError(msg);
addSystemChatMessage(msg);
areaTriggerMsgs_.push_back(msg);
}
@ -4578,6 +4611,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
"Unknown error", "Only empty bag"
};
const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error";
addUIError(std::string("Sell failed: ") + msg);
addSystemChatMessage(std::string("Sell failed: ") + msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* sfx = renderer->getUiSoundManager())
@ -4611,8 +4645,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (requiredLevel > 0) {
std::snprintf(levelBuf, sizeof(levelBuf),
"You must reach level %u to use that item.", requiredLevel);
addUIError(levelBuf);
addSystemChatMessage(levelBuf);
} else {
addUIError("You must reach a higher level to use that item.");
addSystemChatMessage("You must reach a higher level to use that item.");
}
break;
@ -4675,6 +4711,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
default: break;
}
std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ").";
addUIError(msg);
addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* sfx = renderer->getUiSoundManager())
@ -4739,6 +4776,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
case 6: msg = "You can't carry any more items."; break;
default: break;
}
addUIError(msg);
addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* sfx = renderer->getUiSoundManager())
@ -4827,6 +4865,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
: (result == 2) ? "You are not at a barber shop."
: (result == 3) ? "You must stand up to use the barber shop."
: "Barber shop unavailable.";
addUIError(msg);
addSystemChatMessage(msg);
}
LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result);
@ -4911,6 +4950,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (result == 0) {
addSystemChatMessage("Gems socketed successfully.");
} else {
addUIError("Failed to socket gems.");
addSystemChatMessage("Failed to socket gems.");
}
LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result);
@ -4947,6 +4987,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
const char* msg = (reason == 1) ? "The target cannot be resurrected right now."
: (reason == 2) ? "Cannot resurrect in this area."
: "Resurrection failed.";
addUIError(msg);
addSystemChatMessage(msg);
LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason);
}
@ -4974,6 +5015,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
auto go = std::static_pointer_cast<GameObject>(goEnt);
auto* info = getCachedGameObjectInfo(go->getEntry());
if (info && info->type == 17) { // GO_TYPE_FISHINGNODE
addUIError("A fish is on your line!");
addSystemChatMessage("A fish is on your line!");
// Play a distinctive UI sound to alert the player
if (auto* renderer = core::Application::getInstance().getRenderer()) {
@ -5438,6 +5480,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
case Opcode::SMSG_QUESTLOG_FULL:
// Zero-payload notification: the player's quest log is full (25 quests).
addUIError("Your quest log is full.");
addSystemChatMessage("Your quest log is full.");
LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity");
break;
@ -5503,6 +5546,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
case 0x0C: abortMsg = "Transfer aborted."; break;
default: abortMsg = "Transfer aborted."; break;
}
addUIError(abortMsg);
addSystemChatMessage(abortMsg);
break;
}
@ -5540,12 +5584,25 @@ void GameHandler::handlePacket(network::Packet& packet) {
handleBattlefieldList(packet);
break;
case Opcode::SMSG_BATTLEFIELD_PORT_DENIED:
addUIError("Battlefield port denied.");
addSystemChatMessage("Battlefield port denied.");
break;
case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS:
// Optional map position updates for BG objectives/players.
packet.setReadPos(packet.getSize());
case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: {
bgPlayerPositions_.clear();
for (int grp = 0; grp < 2; ++grp) {
if (packet.getSize() - packet.getReadPos() < 4) break;
uint32_t count = packet.readUInt32();
for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 16; ++i) {
BgPlayerPosition pos;
pos.guid = packet.readUInt64();
pos.wowX = packet.readFloat();
pos.wowY = packet.readFloat();
pos.group = grp;
bgPlayerPositions_.push_back(pos);
}
}
break;
}
case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE:
addSystemChatMessage("You have been removed from the PvP queue.");
break;
@ -5635,6 +5692,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason.";
std::string mapLabel = getMapName(mapId);
if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId);
addUIError("Cannot reset " + mapLabel + ": " + reasonMsg);
addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg);
LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason);
}
@ -6089,6 +6147,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
// Clear cached talent data so the talent screen reflects the reset.
learnedTalents_[0].clear();
learnedTalents_[1].clear();
addUIError("Your talents have been reset by the server.");
addSystemChatMessage("Your talents have been reset by the server.");
packet.setReadPos(packet.getSize());
break;
@ -6183,7 +6242,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: {
if (packet.getSize() - packet.getReadPos() >= 1) {
uint8_t result = packet.readUInt8();
if (result != 0) addSystemChatMessage("Failed to equip item set.");
if (result != 0) { addUIError("Failed to equip item set."); addSystemChatMessage("Failed to equip item set."); }
}
break;
}
@ -6218,12 +6277,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint32_t result = packet.readUInt32();
(void)result;
}
addUIError("Dungeon Finder: Auto-join failed.");
addSystemChatMessage("Dungeon Finder: Auto-join failed.");
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER:
// No eligible players found for auto-join
addUIError("Dungeon Finder: No players available for auto-join.");
addSystemChatMessage("Dungeon Finder: No players available for auto-join.");
packet.setReadPos(packet.getSize());
break;
@ -6737,6 +6798,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim);
} else if (ikVictim == playerGuid) {
addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim);
addUIError("You were killed by an instant-kill effect.");
addSystemChatMessage("You were killed by an instant-kill effect.");
}
LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster,
@ -7070,6 +7132,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
s.spellId = chanSpellId;
s.timeTotal = chanTotalMs / 1000.0f;
s.timeRemaining = s.timeTotal;
s.interruptible = isSpellInterruptible(chanSpellId);
}
LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec,
" spell=", chanSpellId, " total=", chanTotalMs, "ms");
@ -7256,16 +7319,20 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_PLAYERBINDERROR: {
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t error = packet.readUInt32();
if (error == 0)
if (error == 0) {
addUIError("Your hearthstone is not bound.");
addSystemChatMessage("Your hearthstone is not bound.");
else
} else {
addUIError("Hearthstone bind failed.");
addSystemChatMessage("Hearthstone bind failed.");
}
}
break;
}
// ---- Instance/raid errors ----
case Opcode::SMSG_RAID_GROUP_ONLY: {
addUIError("You must be in a raid group to enter this instance.");
addSystemChatMessage("You must be in a raid group to enter this instance.");
packet.setReadPos(packet.getSize());
break;
@ -7273,13 +7340,14 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_RAID_READY_CHECK_ERROR: {
if (packet.getSize() - packet.getReadPos() >= 1) {
uint8_t err = packet.readUInt8();
if (err == 0) addSystemChatMessage("Ready check failed: not in a group.");
else if (err == 1) addSystemChatMessage("Ready check failed: in instance.");
else addSystemChatMessage("Ready check failed.");
if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); }
else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); }
else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); }
}
break;
}
case Opcode::SMSG_RESET_FAILED_NOTIFY: {
addUIError("Cannot reset instance: another player is still inside.");
addSystemChatMessage("Cannot reset instance: another player is still inside.");
packet.setReadPos(packet.getSize());
break;
@ -7344,12 +7412,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
// ---- Play object/spell sounds ----
case Opcode::SMSG_PLAY_OBJECT_SOUND:
case Opcode::SMSG_PLAY_SPELL_IMPACT:
if (packet.getSize() - packet.getReadPos() >= 12) {
// uint32 soundId + uint64 sourceGuid
uint32_t soundId = packet.readUInt32();
uint64_t srcGuid = packet.readUInt64();
LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND/SPELL_IMPACT id=", soundId, " src=0x", std::hex, srcGuid, std::dec);
LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec);
if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid);
else if (playSoundCallback_) playSoundCallback_(soundId);
} else if (packet.getSize() - packet.getReadPos() >= 4) {
@ -7358,6 +7425,28 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_PLAY_SPELL_IMPACT: {
// uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL)
if (packet.getSize() - packet.getReadPos() < 12) {
packet.setReadPos(packet.getSize()); break;
}
uint64_t impTargetGuid = packet.readUInt64();
uint32_t impVisualId = packet.readUInt32();
if (impVisualId == 0) break;
auto* renderer = core::Application::getInstance().getRenderer();
if (!renderer) break;
glm::vec3 spawnPos;
if (impTargetGuid == playerGuid) {
spawnPos = renderer->getCharacterPosition();
} else {
auto entity = entityManager.getEntity(impTargetGuid);
if (!entity) break;
glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
spawnPos = core::coords::canonicalToRender(canonical);
}
renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true);
break;
}
// ---- Resistance/combat log ----
case Opcode::SMSG_RESISTLOG: {
@ -7407,6 +7496,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_READ_ITEM_FAILED:
addUIError("You cannot read this item.");
addSystemChatMessage("You cannot read this item.");
packet.setReadPos(packet.getSize());
break;
@ -7474,6 +7564,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
// ---- NPC not responding ----
case Opcode::SMSG_NPC_WONT_TALK:
addUIError("That creature can't talk to you right now.");
addSystemChatMessage("That creature can't talk to you right now.");
packet.setReadPos(packet.getSize());
break;
@ -7569,12 +7660,26 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_PET_GUIDS:
case Opcode::SMSG_PET_DISMISS_SOUND:
case Opcode::SMSG_PET_ACTION_SOUND:
case Opcode::SMSG_PET_UNLEARN_CONFIRM:
case Opcode::SMSG_PET_RENAMEABLE:
case Opcode::SMSG_PET_UNLEARN_CONFIRM: {
// uint64 petGuid + uint32 cost (copper)
if (packet.getSize() - packet.getReadPos() >= 12) {
petUnlearnGuid_ = packet.readUInt64();
petUnlearnCost_ = packet.readUInt32();
petUnlearnPending_ = true;
}
packet.setReadPos(packet.getSize());
break;
}
case Opcode::SMSG_PET_UPDATE_COMBO_POINTS:
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_PET_RENAMEABLE:
// Server signals that the pet can now be named (first tame)
petRenameablePending_ = true;
packet.setReadPos(packet.getSize());
break;
case Opcode::SMSG_PET_NAME_INVALID:
addUIError("That pet name is invalid. Please choose a different name.");
addSystemChatMessage("That pet name is invalid. Please choose a different name.");
packet.setReadPos(packet.getSize());
break;
@ -16179,6 +16284,7 @@ void GameHandler::handleLfgJoinResult(network::Packet& packet) {
} else {
const char* msg = lfgJoinResultString(result);
std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed.");
addUIError(errMsg);
addSystemChatMessage(errMsg);
LOG_INFO("SMSG_LFG_JOIN_RESULT: result=", static_cast<int>(result),
" state=", static_cast<int>(state));
@ -16224,6 +16330,7 @@ void GameHandler::handleLfgProposalUpdate(network::Packet& packet) {
case 0:
lfgState_ = LfgState::Queued;
lfgProposalId_ = 0;
addUIError("Dungeon Finder: Group proposal failed.");
addSystemChatMessage("Dungeon Finder: Group proposal failed.");
break;
case 1: {
@ -16267,6 +16374,7 @@ void GameHandler::handleLfgRoleCheckUpdate(network::Packet& packet) {
LOG_INFO("LFG role check finished");
} else if (roleCheckState == 3) {
lfgState_ = LfgState::None;
addUIError("Dungeon Finder: Role check failed — missing required role.");
addSystemChatMessage("Dungeon Finder: Role check failed — missing required role.");
} else if (roleCheckState == 2) {
lfgState_ = LfgState::RoleCheck;
@ -17555,6 +17663,12 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) {
if (packet.getReadPos() + 4 > packet.getSize()) return;
uint32_t pointCount = packet.readUInt32();
constexpr uint32_t kMaxTransportSplinePoints = 1000;
if (pointCount > kMaxTransportSplinePoints) {
LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount,
" clamped to ", kMaxTransportSplinePoints);
pointCount = kMaxTransportSplinePoints;
}
// Read destination point (transport-local server coords)
float destLocalX = localX, destLocalY = localY, destLocalZ = localZ;
@ -17690,7 +17804,15 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) {
// VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect).
addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
} else {
auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE;
CombatTextEntry::Type type;
if (data.isCrit())
type = CombatTextEntry::CRIT_DAMAGE;
else if (data.isCrushing())
type = CombatTextEntry::CRUSHING;
else if (data.isGlancing())
type = CombatTextEntry::GLANCING;
else
type = CombatTextEntry::MELEE_DAMAGE;
addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid);
// Show partial absorb/resist from sub-damage entries
uint32_t totalAbsorbed = 0, totalResisted = 0;
@ -18155,21 +18277,20 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
}
}
// Add system message about failed cast with readable reason
// Show failure reason in the UIError overlay and in chat
int powerType = -1;
auto playerEntity = entityManager.getEntity(playerGuid);
if (auto playerUnit = std::dynamic_pointer_cast<Unit>(playerEntity)) {
powerType = playerUnit->getPowerType();
}
const char* reason = getSpellCastResultString(data.result, powerType);
std::string errMsg = reason ? reason
: ("Spell cast failed (error " + std::to_string(data.result) + ")");
addUIError(errMsg);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
if (reason) {
msg.message = reason;
} else {
msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")";
}
msg.message = errMsg;
addLocalChatMessage(msg);
}
@ -18194,6 +18315,7 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
s.spellId = data.spellId;
s.timeTotal = data.castTime / 1000.0f;
s.timeRemaining = s.timeTotal;
s.interruptible = isSpellInterruptible(data.spellId);
// Trigger cast animation on the casting unit
if (spellCastAnimCallback_) {
spellCastAnimCallback_(data.casterUnit, true, false);
@ -18760,6 +18882,20 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) {
addSystemChatMessage(msg);
}
void GameHandler::confirmPetUnlearn() {
if (!petUnlearnPending_) return;
petUnlearnPending_ = false;
if (state != WorldState::IN_WORLD || !socket) return;
// Respond with CMSG_PET_UNLEARN_TALENTS (no payload in 3.3.5a)
network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS));
socket->send(pkt);
LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS");
addSystemChatMessage("Pet talent reset confirmed.");
petUnlearnGuid_ = 0;
petUnlearnCost_ = 0;
}
void GameHandler::confirmTalentWipe() {
if (!talentWipePending_) return;
talentWipePending_ = false;
@ -18873,6 +19009,7 @@ void GameHandler::handleGroupUninvite(network::Packet& packet) {
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
msg.message = "You have been removed from the group.";
addUIError("You have been removed from the group.");
addLocalChatMessage(msg);
}
@ -18907,6 +19044,7 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) {
static_cast<uint32_t>(data.result));
}
addUIError(buf);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
@ -20420,6 +20558,34 @@ void GameHandler::closeGossip() {
currentGossip = GossipMessageData{};
}
void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) {
if (state != WorldState::IN_WORLD || !socket) return;
if (itemGuid == 0 || questId == 0) {
addSystemChatMessage("Cannot start quest right now.");
return;
}
// Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver."
// The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails()
// picks up and opens the Accept/Decline dialog.
auto queryPkt = packetParsers_
? packetParsers_->buildQueryQuestPacket(itemGuid, questId)
: QuestgiverQueryQuestPacket::build(itemGuid, questId);
socket->send(queryPkt);
LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec,
" questId=", questId);
}
uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0;
if (slotIndex < 0) return 0;
uint64_t bagGuid = equipSlotGuids_[19 + bagIndex];
if (bagGuid == 0) return 0;
auto it = containerContents_.find(bagGuid);
if (it == containerContents_.end()) return 0;
if (slotIndex >= static_cast<int>(it->second.numSlots)) return 0;
return it->second.slotGuids[slotIndex];
}
void GameHandler::openVendor(uint64_t npcGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
buybackItems_.clear();
@ -21104,6 +21270,86 @@ void GameHandler::handleListInventory(network::Packet& packet) {
vendorWindowOpen = true;
gossipWindowOpen = false; // Close gossip if vendor opens
// Auto-sell grey items if enabled
if (autoSellGrey_ && currentVendorItems.vendorGuid != 0) {
uint32_t totalSellPrice = 0;
int itemsSold = 0;
// Helper lambda to attempt selling a poor-quality slot
auto tryAutoSell = [&](const ItemSlot& slot, uint64_t itemGuid) {
if (slot.empty()) return;
if (slot.item.quality != ItemQuality::POOR) return;
// Determine sell price (slot cache first, then item info fallback)
uint32_t sp = slot.item.sellPrice;
if (sp == 0) {
if (auto* info = getItemInfo(slot.item.itemId); info && info->valid)
sp = info->sellPrice;
}
if (sp == 0 || itemGuid == 0) return;
BuybackItem sold;
sold.itemGuid = itemGuid;
sold.item = slot.item;
sold.count = 1;
buybackItems_.push_front(sold);
if (buybackItems_.size() > 12) buybackItems_.pop_back();
pendingSellToBuyback_[itemGuid] = sold;
sellItem(currentVendorItems.vendorGuid, itemGuid, 1);
totalSellPrice += sp;
++itemsSold;
};
// Backpack slots
for (int i = 0; i < inventory.getBackpackSize(); ++i) {
uint64_t guid = backpackSlotGuids_[i];
if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBackpackSlot(i).item.itemId);
tryAutoSell(inventory.getBackpackSlot(i), guid);
}
// Extra bag slots
for (int b = 0; b < inventory.NUM_BAG_SLOTS; ++b) {
uint64_t bagGuid = equipSlotGuids_[19 + b];
for (int s = 0; s < inventory.getBagSize(b); ++s) {
uint64_t guid = 0;
if (bagGuid != 0) {
auto it = containerContents_.find(bagGuid);
if (it != containerContents_.end() && s < static_cast<int>(it->second.numSlots))
guid = it->second.slotGuids[s];
}
if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBagSlot(b, s).item.itemId);
tryAutoSell(inventory.getBagSlot(b, s), guid);
}
}
if (itemsSold > 0) {
uint32_t gold = totalSellPrice / 10000;
uint32_t silver = (totalSellPrice % 10000) / 100;
uint32_t copper = totalSellPrice % 100;
char buf[128];
std::snprintf(buf, sizeof(buf),
"|cffaaaaaaAuto-sold %d grey item%s for %ug %us %uc.|r",
itemsSold, itemsSold == 1 ? "" : "s", gold, silver, copper);
addSystemChatMessage(buf);
}
}
// Auto-repair all items if enabled and vendor can repair
if (autoRepair_ && currentVendorItems.canRepair && currentVendorItems.vendorGuid != 0) {
// Check that at least one equipped item is actually damaged to avoid no-op
bool anyDamaged = false;
for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; ++i) {
const auto& slot = inventory.getEquipSlot(static_cast<EquipSlot>(i));
if (!slot.empty() && slot.item.maxDurability > 0
&& slot.item.curDurability < slot.item.maxDurability) {
anyDamaged = true;
break;
}
}
if (anyDamaged) {
repairAll(currentVendorItems.vendorGuid, false);
addSystemChatMessage("|cffaaaaaaAuto-repair triggered.|r");
}
}
// Play vendor sound
if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) {
auto entity = entityManager.getEntity(currentVendorItems.vendorGuid);
@ -21229,6 +21475,14 @@ void GameHandler::loadSpellNameCache() {
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; }
}
// AttributesEx field (bit 4 = SPELL_ATTR_EX_NOT_INTERRUPTIBLE)
uint32_t attrExField = 0xFFFFFFFF;
bool hasAttrExField = false;
if (spellL) {
uint32_t f = spellL->field("AttributesEx");
if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; }
}
// Tooltip/description field
uint32_t tooltipField = 0xFFFFFFFF;
if (spellL) {
@ -21243,7 +21497,7 @@ void GameHandler::loadSpellNameCache() {
std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136);
std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153);
if (!name.empty()) {
SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0};
SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0};
if (tooltipField != 0xFFFFFFFF) {
entry.description = dbc->getString(i, tooltipField);
}
@ -21258,6 +21512,9 @@ void GameHandler::loadSpellNameCache() {
if (hasDispelField) {
entry.dispelType = static_cast<uint8_t>(dbc->getUInt32(i, dispelField));
}
if (hasAttrExField) {
entry.attrEx = dbc->getUInt32(i, attrExField);
}
spellNameCache_[id] = std::move(entry);
}
}
@ -21463,6 +21720,22 @@ uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const {
return (it != spellNameCache_.end()) ? it->second.dispelType : 0;
}
bool GameHandler::isSpellInterruptible(uint32_t spellId) const {
if (spellId == 0) return true;
const_cast<GameHandler*>(this)->loadSpellNameCache();
auto it = spellNameCache_.find(spellId);
if (it == spellNameCache_.end()) return true; // assume interruptible if unknown
// SPELL_ATTR_EX_NOT_INTERRUPTIBLE = bit 4 of AttributesEx (0x00000010)
return (it->second.attrEx & 0x00000010u) == 0;
}
uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const {
if (spellId == 0) return 0;
const_cast<GameHandler*>(this)->loadSpellNameCache();
auto it = spellNameCache_.find(spellId);
return (it != spellNameCache_.end()) ? it->second.schoolMask : 0;
}
const std::string& GameHandler::getSkillLineName(uint32_t spellId) const {
auto slIt = spellToSkillLine_.find(spellId);
if (slIt == spellToSkillLine_.end()) return EMPTY_STRING;
@ -23936,6 +24209,7 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) {
"DB error", "Restricted account"};
const char* errName = (result.errorCode < 9) ? errors[result.errorCode] : "Unknown";
std::string msg = std::string("Auction ") + actionName + " failed: " + errName;
addUIError(msg);
addSystemChatMessage(msg);
}
LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName,

View file

@ -520,23 +520,20 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa
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) {
LOG_WARNING("[Classic] Spell start: missing targetFlags");
packet.setReadPos(startPos);
return false;
}
uint16_t targetFlags = packet.readUInt16();
// TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID
if ((targetFlags & 0x02) || (targetFlags & 0x800)) {
if (!hasFullPackedGuid(packet)) {
packet.setReadPos(startPos);
return false;
}
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
// SpellCastTargets: consume ALL target payload types so subsequent reads stay aligned.
// Previously only UNIT(0x02)/OBJECT(0x800) were handled; DEST_LOCATION(0x40),
// SOURCE_LOCATION(0x20), and ITEM(0x10) bytes were silently skipped, corrupting
// castFlags/castTime for every AOE/ground-targeted spell (Rain of Fire, Blizzard, etc.).
{
uint64_t targetGuid = 0;
// skipClassicSpellCastTargets reads uint16 targetFlags and all payloads.
// Non-fatal on truncation: self-cast spells have zero-byte targets.
skipClassicSpellCastTargets(packet, &targetGuid);
data.targetGuid = targetGuid;
}
LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms",
" targetGuid=0x", std::hex, data.targetGuid, std::dec);
return true;
}
@ -765,6 +762,10 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
return false;
}
// SpellCastTargets follows the miss list — consume all target bytes so that
// any subsequent fields (e.g. castFlags extras) are not misaligned.
skipClassicSpellCastTargets(packet, &data.targetGuid);
LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
return true;

View file

@ -1232,6 +1232,66 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector<MailMe
}
// ============================================================================
// ---------------------------------------------------------------------------
// skipTbcSpellCastTargets — consume all SpellCastTargets payload bytes for TBC.
//
// TBC uses uint32 targetFlags (Classic: uint16). Unit/item/object/corpse targets
// are PackedGuid (same as Classic). Source/dest location is 3 floats (12 bytes)
// with no transport guid (Classic: same; WotLK adds a transport PackedGuid).
//
// This helper is used by parseSpellStart to ensure the read position advances
// past ALL target payload fields so subsequent fields (e.g. those parsed by the
// caller after spell targets) are not corrupted.
// ---------------------------------------------------------------------------
static bool skipTbcSpellCastTargets(network::Packet& packet, uint64_t* primaryTargetGuid = nullptr) {
if (packet.getSize() - packet.getReadPos() < 4) return false;
const uint32_t targetFlags = packet.readUInt32();
// Returns false if the packed guid can't be read, otherwise reads and optionally captures it.
auto readPackedGuidCond = [&](uint32_t flag, bool capture) -> bool {
if (!(targetFlags & flag)) return true;
// Packed GUID: 1-byte mask + up to 8 data bytes
if (packet.getReadPos() >= packet.getSize()) return false;
uint8_t mask = packet.getData()[packet.getReadPos()];
size_t needed = 1;
for (int b = 0; b < 8; ++b) if (mask & (1u << b)) ++needed;
if (packet.getSize() - packet.getReadPos() < needed) return false;
uint64_t g = UpdateObjectParser::readPackedGuid(packet);
if (capture && primaryTargetGuid && *primaryTargetGuid == 0) *primaryTargetGuid = g;
return true;
};
auto skipFloats3 = [&](uint32_t flag) -> bool {
if (!(targetFlags & flag)) return true;
if (packet.getSize() - packet.getReadPos() < 12) return false;
(void)packet.readFloat(); (void)packet.readFloat(); (void)packet.readFloat();
return true;
};
// Process in wire order matching cmangos-tbc SpellCastTargets::write()
if (!readPackedGuidCond(0x0002, true)) return false; // UNIT
if (!readPackedGuidCond(0x0004, false)) return false; // UNIT_MINIPET
if (!readPackedGuidCond(0x0010, false)) return false; // ITEM
if (!skipFloats3(0x0020)) return false; // SOURCE_LOCATION
if (!skipFloats3(0x0040)) return false; // DEST_LOCATION
if (targetFlags & 0x1000) { // TRADE_ITEM: uint8
if (packet.getReadPos() >= packet.getSize()) return false;
(void)packet.readUInt8();
}
if (targetFlags & 0x2000) { // STRING: null-terminated
const auto& raw = packet.getData();
size_t pos = packet.getReadPos();
while (pos < raw.size() && raw[pos] != 0) ++pos;
if (pos >= raw.size()) return false;
packet.setReadPos(pos + 1);
}
if (!readPackedGuidCond(0x8200, false)) return false; // CORPSE / PVP_CORPSE
if (!readPackedGuidCond(0x0800, true)) return false; // OBJECT
return true;
}
// TbcPacketParsers::parseSpellStart — TBC 2.4.3 SMSG_SPELL_START
//
// TBC uses full uint64 GUIDs for casterGuid and casterUnit.
@ -1243,7 +1303,6 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector<MailMe
// ============================================================================
bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) {
data = SpellStartData{};
const size_t startPos = packet.getReadPos();
if (packet.getSize() - packet.getReadPos() < 22) return false;
data.casterGuid = packet.readUInt64(); // full GUID (object)
@ -1253,23 +1312,19 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData&
data.castFlags = packet.readUInt32();
data.castTime = packet.readUInt32();
if (packet.getReadPos() + 4 > packet.getSize()) {
LOG_WARNING("[TBC] Spell start: missing targetFlags");
packet.setReadPos(startPos);
return false;
// SpellCastTargets: consume ALL target payload types to keep the read position
// aligned for any bytes the caller may parse after this (ammo, etc.).
// The previous code only read UNIT(0x02)/OBJECT(0x800) target GUIDs and left
// DEST_LOCATION(0x40)/SOURCE_LOCATION(0x20)/ITEM(0x10) bytes unconsumed,
// corrupting subsequent reads for every AOE/ground-targeted spell cast.
{
uint64_t targetGuid = 0;
skipTbcSpellCastTargets(packet, &targetGuid); // non-fatal on truncation
data.targetGuid = targetGuid;
}
uint32_t targetFlags = packet.readUInt32();
const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT
if (needsTargetGuid) {
if (packet.getReadPos() + 8 > packet.getSize()) {
packet.setReadPos(startPos);
return false;
}
data.targetGuid = packet.readUInt64(); // full GUID in TBC
}
LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms",
" targetGuid=0x", std::hex, data.targetGuid, std::dec);
return true;
}
@ -1368,6 +1423,10 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
}
data.missCount = static_cast<uint8_t>(data.missTargets.size());
// SpellCastTargets follows the miss list — consume all target bytes so that
// any subsequent fields are not misaligned for ground-targeted AoE spells.
skipTbcSpellCastTargets(packet, &data.targetGuid);
LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
return true;

View file

@ -32,6 +32,8 @@ WardenEmulator::WardenEmulator()
, heapBase_(HEAP_BASE)
, heapSize_(HEAP_SIZE)
, apiStubBase_(API_STUB_BASE)
, nextApiStubAddr_(API_STUB_BASE)
, apiCodeHookRegistered_(false)
, nextHeapAddr_(HEAP_BASE)
{
}
@ -51,8 +53,11 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
allocations_.clear();
freeBlocks_.clear();
apiAddresses_.clear();
apiHandlers_.clear();
hooks_.clear();
nextHeapAddr_ = heapBase_;
nextApiStubAddr_ = apiStubBase_;
apiCodeHookRegistered_ = false;
{
char addrBuf[32];
@ -149,6 +154,13 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0);
hooks_.push_back(hh);
// Add code hook over the API stub area so Windows API calls are intercepted
uc_hook apiHook;
uc_hook_add(uc_, &apiHook, UC_HOOK_CODE, (void*)hookCode, this,
API_STUB_BASE, API_STUB_BASE + 0x10000 - 1);
hooks_.push_back(apiHook);
apiCodeHookRegistered_ = true;
{
char sBuf[128];
std::snprintf(sBuf, sizeof(sBuf), "WardenEmulator: Emulator initialized Stack: 0x%X-0x%X Heap: 0x%X-0x%X",
@ -161,23 +173,45 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
uint32_t WardenEmulator::hookAPI(const std::string& dllName,
const std::string& functionName,
[[maybe_unused]] std::function<uint32_t(WardenEmulator&, const std::vector<uint32_t>&)> handler) {
// Allocate address for this API stub
static uint32_t nextStubAddr = API_STUB_BASE;
uint32_t stubAddr = nextStubAddr;
nextStubAddr += 16; // Space for stub code
std::function<uint32_t(WardenEmulator&, const std::vector<uint32_t>&)> handler) {
// Allocate address for this API stub (16 bytes each)
uint32_t stubAddr = nextApiStubAddr_;
nextApiStubAddr_ += 16;
// Store mapping
// Store address mapping for IAT patching
apiAddresses_[dllName][functionName] = stubAddr;
{
char hBuf[32];
std::snprintf(hBuf, sizeof(hBuf), "0x%X", stubAddr);
LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf);
// Determine stdcall arg count from known Windows APIs so the hook can
// clean up the stack correctly (RETN N convention).
static const std::pair<const char*, int> knownArgCounts[] = {
{"VirtualAlloc", 4},
{"VirtualFree", 3},
{"GetTickCount", 0},
{"Sleep", 1},
{"GetCurrentThreadId", 0},
{"GetCurrentProcessId", 0},
{"ReadProcessMemory", 5},
};
int argCount = 0;
for (const auto& [name, cnt] : knownArgCounts) {
if (functionName == name) { argCount = cnt; break; }
}
// TODO: Write stub code that triggers a hook callback
// For now, just return the address for IAT patching
// Store the handler so hookCode() can dispatch to it
apiHandlers_[stubAddr] = { argCount, std::move(handler) };
// Write a RET (0xC3) at the stub address as a safe fallback in case
// the code hook fires after EIP has already advanced past our intercept.
if (uc_) {
static const uint8_t retInstr = 0xC3;
uc_mem_write(uc_, stubAddr, &retInstr, 1);
}
{
char hBuf[64];
std::snprintf(hBuf, sizeof(hBuf), "0x%X (argCount=%d)", stubAddr, argCount);
LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf);
}
return stubAddr;
}
@ -503,8 +537,40 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve
// Unicorn Callbacks
// ============================================================================
void WardenEmulator::hookCode([[maybe_unused]] uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, [[maybe_unused]] void* userData) {
(void)address; // Trace disabled by default to avoid log spam
void WardenEmulator::hookCode(uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, void* userData) {
auto* self = static_cast<WardenEmulator*>(userData);
if (!self) return;
auto it = self->apiHandlers_.find(static_cast<uint32_t>(address));
if (it == self->apiHandlers_.end()) return; // not an API stub — trace disabled to avoid spam
const ApiHookEntry& entry = it->second;
// Read stack: [ESP+0] = return address, [ESP+4..] = stdcall args
uint32_t esp = 0;
uc_reg_read(uc, UC_X86_REG_ESP, &esp);
uint32_t retAddr = 0;
uc_mem_read(uc, esp, &retAddr, 4);
std::vector<uint32_t> args(static_cast<size_t>(entry.argCount));
for (int i = 0; i < entry.argCount; ++i) {
uint32_t val = 0;
uc_mem_read(uc, esp + 4 + static_cast<uint32_t>(i) * 4, &val, 4);
args[static_cast<size_t>(i)] = val;
}
// Dispatch to the C++ handler
uint32_t retVal = 0;
if (entry.handler) {
retVal = entry.handler(*self, args);
}
// Simulate stdcall epilogue: pop return address + args
uint32_t newEsp = esp + 4 + static_cast<uint32_t>(entry.argCount) * 4;
uc_reg_write(uc, UC_X86_REG_EAX, &retVal);
uc_reg_write(uc, UC_X86_REG_ESP, &newEsp);
uc_reg_write(uc, UC_X86_REG_EIP, &retAddr);
}
void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, uint64_t address, int size, [[maybe_unused]] int64_t value, [[maybe_unused]] void* userData) {
@ -533,7 +599,8 @@ WardenEmulator::WardenEmulator()
: uc_(nullptr), moduleBase_(0), moduleSize_(0)
, stackBase_(0), stackSize_(0)
, heapBase_(0), heapSize_(0)
, apiStubBase_(0), nextHeapAddr_(0) {}
, apiStubBase_(0), nextApiStubAddr_(0), apiCodeHookRegistered_(false)
, nextHeapAddr_(0) {}
WardenEmulator::~WardenEmulator() {}
bool WardenEmulator::initialize(const void*, size_t, uint32_t) { return false; }
uint32_t WardenEmulator::hookAPI(const std::string&, const std::string&,

View file

@ -161,24 +161,53 @@ bool WardenModule::processCheckRequest(const std::vector<uint8_t>& checkData,
}
try {
// Call module's PacketHandler
// void PacketHandler(uint8_t* checkData, size_t checkSize,
// uint8_t* responseOut, size_t* responseSizeOut)
LOG_INFO("WardenModule: Calling PacketHandler...");
// For now, this is a placeholder - actual calling would depend on
// the module's exact function signature
LOG_WARNING("WardenModule: PacketHandler execution stubbed");
LOG_INFO("WardenModule: Would call emulated function to process checks");
LOG_INFO("WardenModule: This would generate REAL responses (not fakes!)");
// Clean up
if (emulatedPacketHandlerAddr_ == 0) {
LOG_ERROR("WardenModule: PacketHandler address not set (module not fully initialized)");
emulator_->freeMemory(checkDataAddr);
emulator_->freeMemory(responseAddr);
// For now, return false to use fake responses
// Once we have a real module, we'd read the response from responseAddr
return false;
}
// Allocate uint32_t for responseSizeOut in emulated memory
uint32_t initialSize = 1024;
uint32_t responseSizeAddr = emulator_->writeData(&initialSize, sizeof(uint32_t));
if (responseSizeAddr == 0) {
LOG_ERROR("WardenModule: Failed to allocate responseSizeAddr");
emulator_->freeMemory(checkDataAddr);
emulator_->freeMemory(responseAddr);
return false;
}
// Call: void PacketHandler(uint8_t* data, uint32_t size,
// uint8_t* responseOut, uint32_t* responseSizeOut)
LOG_INFO("WardenModule: Calling emulated PacketHandler...");
emulator_->callFunction(emulatedPacketHandlerAddr_, {
checkDataAddr,
static_cast<uint32_t>(checkData.size()),
responseAddr,
responseSizeAddr
});
// Read back response size and data
uint32_t responseSize = 0;
emulator_->readMemory(responseSizeAddr, &responseSize, sizeof(uint32_t));
emulator_->freeMemory(responseSizeAddr);
if (responseSize > 0 && responseSize <= 1024) {
responseOut.resize(responseSize);
if (!emulator_->readMemory(responseAddr, responseOut.data(), responseSize)) {
LOG_ERROR("WardenModule: Failed to read response data");
responseOut.clear();
} else {
LOG_INFO("WardenModule: PacketHandler wrote ", responseSize, " byte response");
}
} else {
LOG_WARNING("WardenModule: PacketHandler returned invalid responseSize=", responseSize);
}
emulator_->freeMemory(checkDataAddr);
emulator_->freeMemory(responseAddr);
return !responseOut.empty();
} catch (const std::exception& e) {
LOG_ERROR("WardenModule: Exception during PacketHandler: ", e.what());
@ -196,25 +225,18 @@ bool WardenModule::processCheckRequest(const std::vector<uint8_t>& checkData,
return false;
}
uint32_t WardenModule::tick([[maybe_unused]] uint32_t deltaMs) {
uint32_t WardenModule::tick(uint32_t deltaMs) {
if (!loaded_ || !funcList_.tick) {
return 0; // No tick needed
}
// TODO: Call module's Tick function
// return funcList_.tick(deltaMs);
return 0;
}
return funcList_.tick(deltaMs);
}
void WardenModule::generateRC4Keys([[maybe_unused]] uint8_t* packet) {
void WardenModule::generateRC4Keys(uint8_t* packet) {
if (!loaded_ || !funcList_.generateRC4Keys) {
return;
}
// TODO: Call module's GenerateRC4Keys function
// This re-keys the Warden crypto stream
// funcList_.generateRC4Keys(packet);
funcList_.generateRC4Keys(packet);
}
void WardenModule::unload() {
@ -222,8 +244,7 @@ void WardenModule::unload() {
// Call module's Unload() function if loaded
if (loaded_ && funcList_.unload) {
LOG_INFO("WardenModule: Calling module unload callback...");
// TODO: Implement callback when execution layer is complete
// funcList_.unload(nullptr);
funcList_.unload(nullptr);
}
// Free executable memory region
@ -240,6 +261,7 @@ void WardenModule::unload() {
// Clear function pointers
funcList_ = {};
emulatedPacketHandlerAddr_ = 0;
loaded_ = false;
moduleData_.clear();
@ -961,7 +983,12 @@ bool WardenModule::initializeModule() {
}
// Read WardenFuncList structure from emulated memory
// Structure has 4 function pointers (16 bytes)
// Structure has 4 function pointers (16 bytes):
// [0] generateRC4Keys(uint8_t* seed)
// [1] unload(uint8_t* rc4Keys)
// [2] packetHandler(uint8_t* data, uint32_t size,
// uint8_t* responseOut, uint32_t* responseSizeOut)
// [3] tick(uint32_t deltaMs) -> uint32_t
uint32_t funcAddrs[4] = {};
if (emulator_->readMemory(result, funcAddrs, 16)) {
char fb[4][32];
@ -973,11 +1000,48 @@ bool WardenModule::initializeModule() {
LOG_INFO("WardenModule: packetHandler: ", fb[2]);
LOG_INFO("WardenModule: tick: ", fb[3]);
// Store function addresses for later use
// funcList_.generateRC4Keys = ... (would wrap emulator calls)
// funcList_.unload = ...
// funcList_.packetHandler = ...
// funcList_.tick = ...
// Wrap emulated function addresses into std::function dispatchers
WardenEmulator* emu = emulator_.get();
if (funcAddrs[0]) {
uint32_t addr = funcAddrs[0];
funcList_.generateRC4Keys = [emu, addr](uint8_t* seed) {
// Warden RC4 seed is a fixed 4-byte value
uint32_t seedAddr = emu->writeData(seed, 4);
if (seedAddr) {
emu->callFunction(addr, {seedAddr});
emu->freeMemory(seedAddr);
}
};
}
if (funcAddrs[1]) {
uint32_t addr = funcAddrs[1];
funcList_.unload = [emu, addr]([[maybe_unused]] uint8_t* rc4Keys) {
emu->callFunction(addr, {0u}); // pass NULL; module saves its own state
};
}
if (funcAddrs[2]) {
// Store raw address for the 4-arg call in processCheckRequest
emulatedPacketHandlerAddr_ = funcAddrs[2];
uint32_t addr = funcAddrs[2];
// Simple 2-arg variant for generic callers (no response extraction)
funcList_.packetHandler = [emu, addr](uint8_t* data, size_t length) {
uint32_t dataAddr = emu->writeData(data, length);
if (dataAddr) {
emu->callFunction(addr, {dataAddr, static_cast<uint32_t>(length)});
emu->freeMemory(dataAddr);
}
};
}
if (funcAddrs[3]) {
uint32_t addr = funcAddrs[3];
funcList_.tick = [emu, addr](uint32_t deltaMs) -> uint32_t {
return emu->callFunction(addr, {deltaMs});
};
}
}
LOG_INFO("WardenModule: Module fully initialized and ready!");

View file

@ -3780,14 +3780,44 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) {
return false;
}
// WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that
// subsequent fields (e.g. school mask, cast flags 0x20 extra data) are not
// misaligned for ground-targeted or AoE spells.
uint32_t targetFlags = packet.readUInt32();
const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT
if (needsTargetGuid) {
if (!hasFullPackedGuid(packet)) {
packet.setReadPos(startPos);
return false;
auto readPackedTarget = [&](uint64_t* out) -> bool {
if (!hasFullPackedGuid(packet)) return false;
uint64_t g = UpdateObjectParser::readPackedGuid(packet);
if (out) *out = g;
return true;
};
auto skipPackedAndFloats3 = [&]() -> bool {
if (!hasFullPackedGuid(packet)) return false;
UpdateObjectParser::readPackedGuid(packet); // transport GUID (may be zero)
if (packet.getSize() - packet.getReadPos() < 12) return false;
packet.readFloat(); packet.readFloat(); packet.readFloat();
return true;
};
// UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share a single object target GUID
if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) {
readPackedTarget(&data.targetGuid); // best-effort; ignore failure
}
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
// ITEM/TRADE_ITEM share a single item target GUID
if (targetFlags & (0x0010u | 0x0100u)) {
readPackedTarget(nullptr);
}
// SOURCE_LOCATION: PackedGuid (transport) + float x,y,z
if (targetFlags & 0x0020u) {
skipPackedAndFloats3();
}
// DEST_LOCATION: PackedGuid (transport) + float x,y,z
if (targetFlags & 0x0040u) {
skipPackedAndFloats3();
}
// STRING: null-terminated
if (targetFlags & 0x0200u) {
while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {}
}
LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
@ -3901,6 +3931,50 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
}
data.missCount = static_cast<uint8_t>(data.missTargets.size());
// WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that
// any trailing fields after the target section are not misaligned for
// ground-targeted or AoE spells. Same layout as SpellStartParser.
if (packet.getReadPos() < packet.getSize()) {
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t targetFlags = packet.readUInt32();
auto readPackedTarget = [&](uint64_t* out) -> bool {
if (!hasFullPackedGuid(packet)) return false;
uint64_t g = UpdateObjectParser::readPackedGuid(packet);
if (out) *out = g;
return true;
};
auto skipPackedAndFloats3 = [&]() -> bool {
if (!hasFullPackedGuid(packet)) return false;
UpdateObjectParser::readPackedGuid(packet); // transport GUID
if (packet.getSize() - packet.getReadPos() < 12) return false;
packet.readFloat(); packet.readFloat(); packet.readFloat();
return true;
};
// UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID
if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) {
readPackedTarget(&data.targetGuid);
}
// ITEM/TRADE_ITEM share one item target GUID
if (targetFlags & (0x0010u | 0x0100u)) {
readPackedTarget(nullptr);
}
// SOURCE_LOCATION: PackedGuid (transport) + float x,y,z
if (targetFlags & 0x0020u) {
skipPackedAndFloats3();
}
// DEST_LOCATION: PackedGuid (transport) + float x,y,z
if (targetFlags & 0x0040u) {
skipPackedAndFloats3();
}
// STRING: null-terminated
if (targetFlags & 0x0200u) {
while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {}
}
}
}
LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
return true;

View file

@ -388,10 +388,11 @@ void CameraController::update(float deltaTime) {
if (mounted_) sitting = false;
xKeyWasDown = xDown;
// Reset camera with R key (edge-triggered) — only when UI doesn't want keyboard
// Reset camera angles with R key (edge-triggered) — only when UI doesn't want keyboard
// Does NOT move the player; full reset() is reserved for world-entry/respawn.
bool rDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R);
if (rDown && !rKeyWasDown) {
reset();
resetAngles();
}
rKeyWasDown = rDown;
@ -1941,6 +1942,14 @@ void CameraController::processMouseButton(const SDL_MouseButtonEvent& event) {
mouseButtonDown = anyDown;
}
void CameraController::resetAngles() {
if (!camera) return;
yaw = defaultYaw;
facingYaw = defaultYaw;
pitch = defaultPitch;
camera->setRotation(yaw, pitch);
}
void CameraController::reset() {
if (!camera) {
return;

View file

@ -2627,6 +2627,190 @@ void Renderer::stopChargeEffect() {
}
}
// ─── Spell Visual Effects ────────────────────────────────────────────────────
void Renderer::loadSpellVisualDbc() {
if (spellVisualDbcLoaded_) return;
spellVisualDbcLoaded_ = true; // Set early to prevent re-entry on failure
if (!cachedAssetManager) {
cachedAssetManager = core::Application::getInstance().getAssetManager();
}
if (!cachedAssetManager) return;
auto* layout = pipeline::getActiveDBCLayout();
const pipeline::DBCFieldMap* svLayout = layout ? layout->getLayout("SpellVisual") : nullptr;
const pipeline::DBCFieldMap* kitLayout = layout ? layout->getLayout("SpellVisualKit") : nullptr;
const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr;
uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2;
uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3;
uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8;
uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11;
uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5;
uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2;
// Helper to look up effectName path from a kit ID
// Load SpellVisualEffectName.dbc — ID → M2 path
auto fxDbc = cachedAssetManager->loadDBC("SpellVisualEffectName.dbc");
if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) {
LOG_DEBUG("SpellVisual: SpellVisualEffectName.dbc unavailable (fc=",
fxDbc ? fxDbc->getFieldCount() : 0, ")");
return;
}
std::unordered_map<uint32_t, std::string> effectPaths; // effectNameId → path
for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) {
uint32_t id = fxDbc->getUInt32(i, 0);
std::string p = fxDbc->getString(i, fxFilePathField);
if (id && !p.empty()) effectPaths[id] = p;
}
// Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID
auto kitDbc = cachedAssetManager->loadDBC("SpellVisualKit.dbc");
std::unordered_map<uint32_t, uint32_t> kitToEffectName; // kitId → effectNameId
if (kitDbc && kitDbc->isLoaded()) {
uint32_t fc = kitDbc->getFieldCount();
for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) {
uint32_t kitId = kitDbc->getUInt32(i, 0);
if (!kitId) continue;
// Prefer SpecialEffect0, fall back to BaseEffect
uint32_t eff = 0;
if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field);
if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField);
if (eff) kitToEffectName[kitId] = eff;
}
}
// Helper: resolve path for a given kit ID
auto kitPath = [&](uint32_t kitId) -> std::string {
if (!kitId) return {};
auto kitIt = kitToEffectName.find(kitId);
if (kitIt == kitToEffectName.end()) return {};
auto fxIt = effectPaths.find(kitIt->second);
return (fxIt != effectPaths.end()) ? fxIt->second : std::string{};
};
auto missilePath = [&](uint32_t effId) -> std::string {
if (!effId) return {};
auto fxIt = effectPaths.find(effId);
return (fxIt != effectPaths.end()) ? fxIt->second : std::string{};
};
// Load SpellVisual.dbc — visualId → cast/impact M2 paths via kit chain
auto svDbc = cachedAssetManager->loadDBC("SpellVisual.dbc");
if (!svDbc || !svDbc->isLoaded()) {
LOG_DEBUG("SpellVisual: SpellVisual.dbc unavailable");
return;
}
uint32_t svFc = svDbc->getFieldCount();
uint32_t loadedCast = 0, loadedImpact = 0;
for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) {
uint32_t vid = svDbc->getUInt32(i, 0);
if (!vid) continue;
// Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel
{
std::string path;
if (svCastKitField < svFc)
path = kitPath(svDbc->getUInt32(i, svCastKitField));
if (path.empty() && svMissileField < svFc)
path = missilePath(svDbc->getUInt32(i, svMissileField));
if (!path.empty()) { spellVisualCastPath_[vid] = path; ++loadedCast; }
}
// Impact path: ImpactKit → SpecialEffect0/BaseEffect, fallback to MissileModel
{
std::string path;
if (svImpactKitField < svFc)
path = kitPath(svDbc->getUInt32(i, svImpactKitField));
if (path.empty() && svMissileField < svFc)
path = missilePath(svDbc->getUInt32(i, svMissileField));
if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; }
}
}
LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact,
" visual→M2 mappings (of ", svDbc->getRecordCount(), " records)");
}
void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition,
bool useImpactKit) {
if (!m2Renderer || visualId == 0) return;
if (!cachedAssetManager)
cachedAssetManager = core::Application::getInstance().getAssetManager();
if (!cachedAssetManager) return;
if (!spellVisualDbcLoaded_) loadSpellVisualDbc();
// Select cast or impact path map
auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_;
auto pathIt = pathMap.find(visualId);
if (pathIt == pathMap.end()) return; // No model for this visual
const std::string& modelPath = pathIt->second;
// Get or assign a model ID for this path
auto midIt = spellVisualModelIds_.find(modelPath);
uint32_t modelId = 0;
if (midIt != spellVisualModelIds_.end()) {
modelId = midIt->second;
} else {
if (nextSpellVisualModelId_ >= 999800) {
LOG_WARNING("SpellVisual: model ID pool exhausted");
return;
}
modelId = nextSpellVisualModelId_++;
spellVisualModelIds_[modelPath] = modelId;
}
// Load the M2 model if not already loaded
if (!m2Renderer->hasModel(modelId)) {
auto m2Data = cachedAssetManager->readFile(modelPath);
if (m2Data.empty()) {
LOG_DEBUG("SpellVisual: could not read model: ", modelPath);
return;
}
pipeline::M2Model model = pipeline::M2Loader::load(m2Data);
if (model.vertices.empty() && model.particleEmitters.empty()) {
LOG_DEBUG("SpellVisual: empty model: ", modelPath);
return;
}
// Load skin file for WotLK-format M2s
if (model.version >= 264) {
std::string skinPath = modelPath.substr(0, modelPath.rfind('.')) + "00.skin";
auto skinData = cachedAssetManager->readFile(skinPath);
if (!skinData.empty()) pipeline::M2Loader::loadSkin(skinData, model);
}
if (!m2Renderer->loadModel(model, modelId)) {
LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath);
return;
}
LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath);
}
// Spawn instance at world position
uint32_t instanceId = m2Renderer->createInstance(modelId, worldPosition,
glm::vec3(0.0f), 1.0f);
if (instanceId == 0) {
LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId);
return;
}
activeSpellVisuals_.push_back({instanceId, 0.0f});
LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId,
" model=", modelPath);
}
void Renderer::updateSpellVisuals(float deltaTime) {
if (activeSpellVisuals_.empty() || !m2Renderer) return;
for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) {
it->elapsed += deltaTime;
if (it->elapsed >= SPELL_VISUAL_DURATION) {
m2Renderer->removeInstance(it->instanceId);
it = activeSpellVisuals_.erase(it);
} else {
++it;
}
}
}
void Renderer::triggerMeleeSwing() {
if (!characterRenderer || characterInstanceId == 0) return;
if (meleeSwingCooldown > 0.0f) return;
@ -3012,6 +3196,8 @@ void Renderer::update(float deltaTime) {
if (chargeEffect) {
chargeEffect->update(deltaTime);
}
// Update transient spell visual instances
updateSpellVisuals(deltaTime);
// Launch M2 doodad animation on background thread (overlaps with character animation + audio)

View file

@ -371,6 +371,7 @@ void WorldMap::loadZonesFromDBC() {
}
}
currentMapId_ = mapID;
LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID,
", continentIdx=", continentIdx);
}
@ -1059,6 +1060,69 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi
}
}
// Taxi node markers — flight master icons on the map
if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !taxiNodes_.empty()) {
ImVec2 mp = ImGui::GetMousePos();
for (const auto& node : taxiNodes_) {
if (!node.known) continue;
if (static_cast<int>(node.mapId) != currentMapId_) continue;
glm::vec3 rPos = core::coords::canonicalToRender(
glm::vec3(node.wowX, node.wowY, node.wowZ));
glm::vec2 uv = renderPosToMapUV(rPos, currentIdx);
if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue;
float px = imgMin.x + uv.x * displayW;
float py = imgMin.y + uv.y * displayH;
// Flight-master icon: yellow diamond with dark border
constexpr float H = 5.0f; // half-size of diamond
ImVec2 top2(px, py - H);
ImVec2 right2(px + H, py );
ImVec2 bot2(px, py + H);
ImVec2 left2(px - H, py );
drawList->AddQuadFilled(top2, right2, bot2, left2,
IM_COL32(255, 215, 0, 230));
drawList->AddQuad(top2, right2, bot2, left2,
IM_COL32(80, 50, 0, 200), 1.2f);
// Tooltip on hover
if (!node.name.empty()) {
float mdx = mp.x - px, mdy = mp.y - py;
if (mdx * mdx + mdy * mdy < 49.0f) {
ImGui::SetTooltip("%s\n(Flight Master)", node.name.c_str());
}
}
}
}
// Corpse marker — skull X shown when player is a ghost with unclaimed corpse
if (hasCorpse_ && currentIdx >= 0 && viewLevel != ViewLevel::WORLD) {
glm::vec2 uv = renderPosToMapUV(corpseRenderPos_, currentIdx);
if (uv.x >= 0.0f && uv.x <= 1.0f && uv.y >= 0.0f && uv.y <= 1.0f) {
float cx = imgMin.x + uv.x * displayW;
float cy = imgMin.y + uv.y * displayH;
constexpr float R = 5.0f; // cross arm half-length
constexpr float T = 1.8f; // line thickness
// Dark outline
drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R),
IM_COL32(0, 0, 0, 220), T + 1.5f);
drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R),
IM_COL32(0, 0, 0, 220), T + 1.5f);
// Bone-white X
drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R),
IM_COL32(230, 220, 200, 240), T);
drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R),
IM_COL32(230, 220, 200, 240), T);
// Tooltip on hover
ImVec2 mp = ImGui::GetMousePos();
float dx = mp.x - cx, dy = mp.y - cy;
if (dx * dx + dy * dy < 64.0f) {
ImGui::SetTooltip("Your corpse");
}
}
}
// Hover coordinate display — show WoW coordinates under cursor
if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) {
auto& io = ImGui::GetIO();

File diff suppressed because it is too large Load diff

View file

@ -1738,12 +1738,27 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f);
ImVec4 gray(0.6f, 0.6f, 0.6f, 1.0f);
static const char* kStatTooltips[5] = {
"Increases your melee attack power by 2.\nIncreases your block value.",
"Increases your Armor.\nIncreases ranged attack power by 2.\nIncreases your chance to dodge attacks and score critical strikes.",
"Increases Health by 10 per point.",
"Increases your Mana pool.\nIncreases your chance to score a critical strike with spells.",
"Increases Health and Mana regeneration."
};
// Armor (no base)
ImGui::BeginGroup();
if (totalArmor > 0) {
ImGui::TextColored(gold, "Armor: %d", totalArmor);
} else {
ImGui::TextColored(gray, "Armor: 0");
}
ImGui::EndGroup();
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::TextWrapped("Reduces damage taken from physical attacks.");
ImGui::EndTooltip();
}
if (serverStats) {
// Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus.
@ -1753,6 +1768,7 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
for (int i = 0; i < 5; ++i) {
int32_t total = serverStats[i];
int32_t bonus = itemBonuses[i];
ImGui::BeginGroup();
if (bonus > 0) {
ImGui::TextColored(white, "%s: %d", statNames[i], total);
ImGui::SameLine();
@ -1760,12 +1776,19 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
} else {
ImGui::TextColored(gray, "%s: %d", statNames[i], total);
}
ImGui::EndGroup();
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::TextWrapped("%s", kStatTooltips[i]);
ImGui::EndTooltip();
}
}
} else {
// Fallback: estimated base (20 + level) plus item query bonuses.
int32_t baseStat = 20 + static_cast<int32_t>(playerLevel);
auto renderStat = [&](const char* name, int32_t equipBonus) {
auto renderStat = [&](const char* name, int32_t equipBonus, const char* tooltip) {
int32_t total = baseStat + equipBonus;
ImGui::BeginGroup();
if (equipBonus > 0) {
ImGui::TextColored(white, "%s: %d", name, total);
ImGui::SameLine();
@ -1773,12 +1796,18 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
} else {
ImGui::TextColored(gray, "%s: %d", name, total);
}
ImGui::EndGroup();
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::TextWrapped("%s", tooltip);
ImGui::EndTooltip();
}
};
renderStat("Strength", itemStr);
renderStat("Agility", itemAgi);
renderStat("Stamina", itemSta);
renderStat("Intellect", itemInt);
renderStat("Spirit", itemSpi);
renderStat("Strength", itemStr, kStatTooltips[0]);
renderStat("Agility", itemAgi, kStatTooltips[1]);
renderStat("Stamina", itemSta, kStatTooltips[2]);
renderStat("Intellect", itemInt, kStatTooltips[3]);
renderStat("Spirit", itemSpi, kStatTooltips[4]);
}
// Secondary stats from equipped items
@ -1789,27 +1818,34 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play
if (hasSecondary) {
ImGui::Spacing();
ImGui::Separator();
auto renderSecondary = [&](const char* name, int32_t val) {
auto renderSecondary = [&](const char* name, int32_t val, const char* tooltip) {
if (val > 0) {
ImGui::BeginGroup();
ImGui::TextColored(green, "+%d %s", val, name);
ImGui::EndGroup();
if (ImGui::IsItemHovered()) {
ImGui::BeginTooltip();
ImGui::TextWrapped("%s", tooltip);
ImGui::EndTooltip();
}
}
};
renderSecondary("Attack Power", itemAP);
renderSecondary("Spell Power", itemSP);
renderSecondary("Hit Rating", itemHit);
renderSecondary("Crit Rating", itemCrit);
renderSecondary("Haste Rating", itemHaste);
renderSecondary("Resilience", itemResil);
renderSecondary("Expertise", itemExpertise);
renderSecondary("Defense Rating", itemDefense);
renderSecondary("Dodge Rating", itemDodge);
renderSecondary("Parry Rating", itemParry);
renderSecondary("Block Rating", itemBlock);
renderSecondary("Block Value", itemBlockVal);
renderSecondary("Armor Penetration",itemArmorPen);
renderSecondary("Spell Penetration",itemSpellPen);
renderSecondary("Mana per 5 sec", itemMp5);
renderSecondary("Health per 5 sec", itemHp5);
renderSecondary("Attack Power", itemAP, "Increases the damage of your melee and ranged attacks.");
renderSecondary("Spell Power", itemSP, "Increases the damage and healing of your spells.");
renderSecondary("Hit Rating", itemHit, "Reduces the chance your attacks will miss.");
renderSecondary("Crit Rating", itemCrit, "Increases your critical strike chance.");
renderSecondary("Haste Rating", itemHaste, "Increases attack speed and spell casting speed.");
renderSecondary("Resilience", itemResil, "Reduces the chance you will be critically hit.\nReduces damage taken from critical hits.");
renderSecondary("Expertise", itemExpertise,"Reduces the chance your attacks will be dodged or parried.");
renderSecondary("Defense Rating", itemDefense, "Reduces the chance enemies will critically hit you.");
renderSecondary("Dodge Rating", itemDodge, "Increases your chance to dodge attacks.");
renderSecondary("Parry Rating", itemParry, "Increases your chance to parry attacks.");
renderSecondary("Block Rating", itemBlock, "Increases your chance to block attacks with your shield.");
renderSecondary("Block Value", itemBlockVal, "Increases the amount of damage your shield blocks.");
renderSecondary("Armor Penetration",itemArmorPen, "Reduces the armor of your target.");
renderSecondary("Spell Penetration",itemSpellPen, "Reduces your target's resistance to your spells.");
renderSecondary("Mana per 5 sec", itemMp5, "Restores mana every 5 seconds, even while casting.");
renderSecondary("Health per 5 sec", itemHp5, "Restores health every 5 seconds.");
}
// Elemental resistances from server update fields
@ -2299,8 +2335,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
} else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) {
LOG_INFO("Right-click backpack item: name='", item.name,
"' inventoryType=", (int)item.inventoryType,
" itemId=", item.itemId);
if (item.inventoryType > 0) {
" itemId=", item.itemId,
" startQuestId=", item.startQuestId);
if (item.startQuestId != 0) {
uint64_t iGuid = gameHandler_->getBackpackItemGuid(backpackIndex);
gameHandler_->offerQuestFromItem(iGuid, item.startQuestId);
} else if (item.inventoryType > 0) {
gameHandler_->autoEquipItemBySlot(backpackIndex);
} else {
gameHandler_->useItemBySlot(backpackIndex);
@ -2308,8 +2348,12 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
LOG_INFO("Right-click bag item: name='", item.name,
"' inventoryType=", (int)item.inventoryType,
" bagIndex=", bagIndex, " slotIndex=", bagSlotIndex);
if (item.inventoryType > 0) {
" bagIndex=", bagIndex, " slotIndex=", bagSlotIndex,
" startQuestId=", item.startQuestId);
if (item.startQuestId != 0) {
uint64_t iGuid = gameHandler_->getBagItemGuid(bagIndex, bagSlotIndex);
gameHandler_->offerQuestFromItem(iGuid, item.startQuestId);
} else if (item.inventoryType > 0) {
gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex);
} else {
gameHandler_->useItemInBag(bagIndex, bagSlotIndex);

View file

@ -36,6 +36,7 @@ void KeybindingManager::initializeDefaults() {
bindings_[static_cast<int>(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V;
bindings_[static_cast<int>(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset)
bindings_[static_cast<int>(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail)
bindings_[static_cast<int>(Action::TOGGLE_SKILLS)] = ImGuiKey_K; // WoW standard: K opens Skills/Professions
}
bool KeybindingManager::isActionPressed(Action action, bool repeat) {
@ -93,6 +94,7 @@ const char* KeybindingManager::getActionName(Action action) {
case Action::TOGGLE_NAMEPLATES: return "Nameplates";
case Action::TOGGLE_RAID_FRAMES: return "Raid Frames";
case Action::TOGGLE_ACHIEVEMENTS: return "Achievements";
case Action::TOGGLE_SKILLS: return "Skills / Professions";
case Action::ACTION_COUNT: break;
}
return "Unknown";
@ -158,6 +160,7 @@ void KeybindingManager::loadFromConfigFile(const std::string& filePath) {
else if (action == "toggle_raid_frames") actionIdx = static_cast<int>(Action::TOGGLE_RAID_FRAMES);
else if (action == "toggle_quest_log") actionIdx = static_cast<int>(Action::TOGGLE_QUESTS); // legacy alias
else if (action == "toggle_achievements") actionIdx = static_cast<int>(Action::TOGGLE_ACHIEVEMENTS);
else if (action == "toggle_skills") actionIdx = static_cast<int>(Action::TOGGLE_SKILLS);
if (actionIdx < 0) continue;
@ -254,6 +257,7 @@ void KeybindingManager::saveToConfigFile(const std::string& filePath) const {
{Action::TOGGLE_NAMEPLATES, "toggle_nameplates"},
{Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"},
{Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"},
{Action::TOGGLE_SKILLS, "toggle_skills"},
};
for (const auto& [action, nameStr] : actionMap) {