Compare commits

...

55 commits

Author SHA1 Message Date
Kelsi
e0346c85df fix: salvage spell-go hit data when miss targets are truncated
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
SMSG_SPELL_GO packets with unreasonably high miss counts (48, 118, 241)
were causing the entire packet to be discarded, losing all combat hit
data. Now salvage the successfully-parsed hit targets (needed for combat
text, health bars, animations) instead of discarding everything. Also
add spellId/hitCount to truncation warnings for easier diagnosis.
2026-03-18 06:23:03 -07:00
Kelsi
379ca116d1 fix: eliminate full spatial index rebuild on M2 instance removal
M2Renderer::removeInstance() was calling rebuildSpatialIndex() for every
single removal, causing 25-90ms frame hitches during entity despawns.
Now uses O(1) lookup via instanceIndexById, incremental spatial grid
cell removal, and swap-remove from the instance vector. The auxiliary
index vectors are rebuilt cheaply since they're small.
2026-03-18 06:20:24 -07:00
Kelsi
702155ff4f fix: correct SMSG_SPELL_GO REFLECT miss payload size (WotLK/TBC)
WotLK and TBC parsers were reading uint32+uint8 (5 bytes) for
SPELL_MISS_REFLECT entries, but the server only sends uint8
reflectResult (1 byte). This caused a 4-byte misalignment after every
reflected spell, corrupting subsequent miss entries and SpellCastTargets
parsing. Classic parser was already correct.
2026-03-18 06:20:18 -07:00
Kelsi
25138b5648 fix: use CMSG_OPEN_ITEM for locked containers (lockboxes)
Right-clicking a locked container (e.g. Dead-Tooth's Strong Box) was
sending CMSG_USE_ITEM with spellId=0, which the server rejects. Locked
containers (itemClass==1, inventoryType==0) now send CMSG_OPEN_ITEM
instead, letting the server auto-check the keyring for the required key.
2026-03-18 06:06:29 -07:00
Kelsi
2fb7901cca feat: enable water refraction by default
The VK_ERROR_DEVICE_LOST crash on AMD/Mali GPUs (barrier srcAccessMask)
was fixed in 2026-03-18. Enable refraction for new sessions so players
get the improved water visuals without needing to touch Settings.
Existing saved configs that explicitly disabled it are preserved.
2026-03-18 05:44:59 -07:00
Kelsi
fabcde42a5 fix: clarify death dialog — auto-release label and resurrection hint
'Release in X:XX' implied a client-enforced forced release; renamed to
'Auto-release in X:XX' (server-driven) and added 'Or wait for a player
to resurrect you.' hint so players know they can stay dead without
clicking Release Spirit.
2026-03-18 05:39:42 -07:00
Kelsi
90843ea989 fix: don't set releasedSpirit_ optimistically in releaseSpirit()
Setting releasedSpirit_=true immediately on CMSG_REPOP_REQUEST raced
with PLAYER_FLAGS field updates that arrive from the server before it
processes the repop: the PLAYER_FLAGS handler saw wasGhost=true /
nowGhost=false and fired the 'ghost cleared' path, wiping corpseMapId_
and corpseGuid_ — so the minimap skull marker and the Resurrect from
Corpse dialog never appeared.

Ghost state is now driven entirely by the server-confirmed PLAYER_FLAGS
GHOST bit (and the login-as-ghost path), eliminating the race.
2026-03-18 05:35:23 -07:00
Kelsi
d0f544395e feat: add mounted/group/channeling/casting/vehicle macro conditionals
Extends evaluateMacroConditionals() with [mounted], [nomounted],
[group], [nogroup], [raid], [channeling], [nochanneling],
[channeling:SpellName], [casting], [nocasting], [vehicle], [novehicle].
2026-03-18 05:23:32 -07:00
Kelsi
4e13a344e8 feat: add buff:/nobuff:/debuff:/nodebuff: macro conditionals
Macro conditions now support checking aura presence:
  [buff:Power Word: Fortitude]  — player has the named buff
  [nobuff:Frost Armor]          — player does NOT have the named buff
  [debuff:Faerie Fire]          — target has the named debuff
  [nodebuff:Hunter's Mark]      — target does NOT have the named debuff

Name matching is case-insensitive. When a target override (@target etc.)
is active the check uses that unit's aura list instead of the player's.
2026-03-18 05:20:15 -07:00
Kelsi
a802e05091 feat: add /mark slash command for setting raid target icons
Adds /mark [icon], /marktarget, and /raidtarget slash commands that
set a raid mark on the current target. Accepts icon names (star,
circle, diamond, triangle, moon, square, cross, skull), numbers 1-8,
or "clear"/"none" to remove the mark. Defaults to skull when no
argument is given.
2026-03-18 05:16:14 -07:00
Kelsi
e7fe35c1f9 feat: add right-click pet spell autocast toggle via CMSG_PET_SPELL_AUTOCAST
Right-clicking a castable pet ability (actionId > 6) in the pet action
bar now sends CMSG_PET_SPELL_AUTOCAST to toggle the spell's autocast
state. The local petAutocastSpells_ set is updated optimistically and
the tooltip shows the current state with a right-click hint.
2026-03-18 05:08:10 -07:00
Kelsi
586408516b fix: correct character geoset group ranges for other-player equipment rendering
setOnlinePlayerEquipment used wrong geoset ID ranges for boots (402+ instead
of 501+), gloves (301+ instead of 401+), and chest/sleeves (501+ instead of
801+), and was missing bare-shin (502), bare-wrist (801), and bare-leg (1301)
defaults. This caused other players to render with missing shin/wrist geometry
and wrong geosets when wearing equipment (the "shin mesh" gap in status.md).

Now mirrors the CharacterPreview::applyEquipment logic exactly:
- Group 4 (4xx) forearms/gloves: default 401, equipment 401+gg
- Group 5 (5xx) shins/boots:    default 502, equipment 501+gg
- Group 8 (8xx) wrists/sleeves: default 801, equipment 801+gg
- Group 13 (13xx) legs/pants:   default 1301, equipment 1301+gg
2026-03-18 04:42:21 -07:00
Kelsi
5f3bc79653 feat: show queued spell icon in cast bar and expose getQueuedSpellId()
When a spell is queued in the 400ms window before the current cast ends,
render its icon dimmed (0.8 alpha) to the right of the cast bar progress,
with a "Queued: <name>" tooltip. The progress bar shrinks to accommodate
the icon when one is present.

Also exposes getQueuedSpellId() as a public const accessor on GameHandler
so the UI can observe the spell queue state without friend access.
2026-03-18 04:34:36 -07:00
Kelsi
277a26b351 feat: flash action bar button red when spell cast fails
Add SpellCastFailedCallback to GameHandler, fired from SMSG_CAST_RESULT
when result != 0. GameScreen registers the callback and records each failed
spellId in actionFlashEndTimes_ (keyed by spell ID, value = expiry time).

During action bar rendering, if a slot's spell has an active flash entry,
an AddRectFilled overlay is drawn over the button with alpha proportional
to remaining time (1.0→0.0 over 0.5 s), giving the same error-red flash
visual feedback as the original WoW client.
2026-03-18 04:30:33 -07:00
Kelsi
c1765b6b39 fix: defer loot item notification until item name is known from server query
When SMSG_ITEM_PUSH_RESULT arrives for an item not yet in the cache, store
a PendingItemPushNotif and fire the 'Received: [item]' chat message only
after SMSG_ITEM_QUERY_SINGLE_RESPONSE resolves the name and quality, so the
notification always shows a proper item link instead of 'item #12345'.

Notifications that are already cached emit immediately as before; multiple
pending notifs for the same item are all flushed on the single response.
2026-03-18 04:25:37 -07:00
Kelsi
09b0bea981 feat: add /stopmacro support and low durability warning for equipped items
- /stopmacro [conditions] halts remaining macro commands; supports all existing
  macro conditionals ([combat], [nocombat], [mod:shift], etc.) via the sentinel
  action trick on evaluateMacroConditionals
- macroStopped_ flag in GameScreen; executeMacroText resets and checks it after
  each command so /stopmacro mid-macro skips all subsequent lines
- Emit a "X is about to break!" UI error + system chat when an equipped item's
  durability drops below 20% via SMSG_UPDATE_OBJECT field delta; warning fires
  once per threshold crossing (prevDur >= maxDur/5, newDur < maxDur/5)
2026-03-18 04:14:44 -07:00
Kelsi
d7c377292e feat: show socket gems and consolidate enchant name DBC cache in item tooltips
Extends OnlineItemInfo to track gem enchant IDs (socket slots 2-4) from item
update fields; socket display now shows inserted gem name inline (e.g.
"Red Socket: Bold Scarlet Ruby"). Consolidates redundant SpellItemEnchantment
DBC loads into one shared static per tooltip variant.
2026-03-18 04:04:23 -07:00
Kelsi
167e710f92 feat: add /equipset macro command for saved equipment set switching
/equipset <name> equips a saved set by case-insensitive prefix match;
/equipset with no argument lists available sets in chat.
2026-03-18 03:53:59 -07:00
Kelsi
1fd3d5fdc8 feat: display permanent and temporary enchants in item tooltips for equipped items
Tracks ITEM_ENCHANTMENT_SLOT 0 (permanent) and 1 (temporary) from item update
fields in OnlineItemInfo, then looks up names from SpellItemEnchantment.dbc and
renders them in both ItemDef and ItemQueryResponseData tooltip variants.
2026-03-18 03:50:24 -07:00
Kelsi
4025e6576c feat: implement /castsequence macro command
Supports: /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ...
Cycles through the spell list on successive button presses. State is keyed
by spell list so the same sequence shared across macros stays in sync.
2026-03-18 03:36:05 -07:00
Kelsi
df7150503b feat: /assist now accepts name and macro conditional arguments
/assist TankName targets whoever TankName is targeting; /assist [target=focus]
assists your focus target. Mirrors /target and /focus conditional support.
2026-03-18 03:31:40 -07:00
Kelsi
5d4b0b0f04 feat: show target-of-focus with health bar in focus frame
Healers and tanks can now see who their focus target is targeting,
with a compact percentage health bar — mirrors the ToT in the target frame.
2026-03-18 03:29:48 -07:00
Kelsi
a151531a2a feat: show health bar on target-of-target in target frame
The ToT health bar gives healers immediate % health readout of whoever
the target is attacking, without needing to click-through to that unit.
2026-03-18 03:28:06 -07:00
Kelsi
11c07f19cb feat: add macro conditional support to /cleartarget and /startattack
/cleartarget [dead] now clears target only when it meets conditions;
/startattack [harm,nodead] respects conditionals including target=mouseover.
2026-03-18 03:25:34 -07:00
Kelsi
6cd3c613ef feat: add macro conditional support to /target and /focus commands
/target [target=mouseover], /target [mod:shift] BossName; DefaultMob,
/focus [target=mouseover], and /focus PlayerName all now evaluate WoW
macro conditionals and resolve named/mouseover targets correctly.
2026-03-18 03:21:27 -07:00
Kelsi
e2a484256c feat: show spell icon on macro buttons via #showtooltip directive
- getMacroShowtooltipArg() parses the #showtooltip [SpellName] directive
- Action bar macro buttons now display the named spell's icon when
  #showtooltip SpellName is present at the top of the macro body
- For bare #showtooltip (no argument), derives the icon from the first
  /cast line in the macro (stripping conditionals and rank suffixes)
- Falls back to "Macro" text label only when no spell can be resolved
2026-03-18 03:16:05 -07:00
Kelsi
28d7d3ec00 feat: track mouseover on party frames; fix /cast !spell; update macro editor hint 2026-03-18 03:11:34 -07:00
Kelsi
7967bfdcb1 feat: implement [target=mouseover] macro conditional via nameplate/raid hover
- Adds mouseoverGuid_ to GameHandler (set/cleared each frame by UI)
- renderNameplates() sets mouseoverGuid when the cursor is inside a
  nameplate's hit region; resets to 0 at frame start
- Raid frame cells set mouseoverGuid while hovered (IsItemHovered)
- evaluateMacroConditionals() resolves @mouseover / target=mouseover to
  the hover GUID; returns false (skip alternative) when no unit is hovered

This enables common healer macros like:
  /cast [target=mouseover,help,nodead] Renew; Renew
2026-03-18 03:09:43 -07:00
Kelsi
d2b2a25393 feat: extend macro conditionals to /use command 2026-03-18 03:06:23 -07:00
Kelsi
30513d0f06 feat: implement WoW macro conditional evaluator for /cast
Adds evaluateMacroConditionals() which parses the [cond1,cond2] Spell;
[cond3] Spell2; Default syntax and returns the first matching
alternative. Supported conditions:

- mod:shift/ctrl/alt, nomod  — keyboard modifier state
- target=player/focus/target, @player/@focus/@target — target override
- help / harm (noharm / nohelp)  — target faction check
- dead / nodead                  — target health check
- exists / noexists              — target presence check
- combat / nocombat              — player combat state
- noform / nostance / form:0     — shapeshift/stance state
- Unknown conditions are permissive (true) to avoid false negatives.

/cast now resolves conditionals before spell lookup and routes
castSpell() to the [target=X] override GUID when specified.
isHostileFaction() exposed as isHostileFactionPublic() for UI use.
2026-03-18 03:04:45 -07:00
Kelsi
ed3bca3d17 fix: escape newlines in macro cfg persistence; execute all macro lines
- Macro text is now escaped (\\n, \\\\) on save and unescaped on load,
  fixing multiline macros silently truncating after the first line in
  the character config file.
- executeMacroText() runs every non-comment line of a macro body in
  sequence (WoW behaviour), replacing the firstMacroCommand() approach
  that only fired the first actionable line. The server still enforces
  one spell-cast per click; non-cast commands (target, equip, pet, etc.)
  now all execute correctly in the same macro activation.
2026-03-18 02:44:28 -07:00
Kelsi
c676d99fc2 feat: add /petattack, /petfollow, /petstay, /petpassive, /petaggressive macro commands
Adds the standard WoW pet control slash commands used in macros:
- /petattack     — attack current target
- /petfollow     — follow player
- /petstay / /pethalt — stop and hold position
- /petpassive    — set passive react mode
- /petdefensive  — set defensive react mode
- /petaggressive — set aggressive react mode
- /petdismiss    — dismiss the pet

All commands also appear in Tab-autocomplete.
2026-03-18 02:32:49 -07:00
Kelsi
ae3e57ac3b feat: add /cancelform, /cancelshapeshift, /cancelaura slash commands
These are standard WoW macro commands:

- /cancelform / /cancelshapeshift: exits current shapeshift form by
  cancelling the first permanent aura (flag 0x20) on the player
- /cancelaura <name|#id>: cancels a specific player buff by spell name
  or numeric ID (e.g. /cancelaura Stealth, /cancelaura #1784)

Also expand the Tab-autocomplete command list to include /cancelaura,
/cancelform, /cancelshapeshift, /dismount, /sit, /stand, /startattack,
/stopcasting, /target, and other commands that were previously missing.
2026-03-18 02:30:35 -07:00
Kelsi
c3be43de58 fix: skip #showtooltip and other # directives when executing macros
Macros often start with a #showtooltip or #show directive line; these
should not be executed as chat commands.  The firstMacroCommand() helper
now scans forward through the macro text, skipping blank lines and any
line starting with '#', and executes the first actual command line.

Applies to all three execution paths: left-click, keyboard shortcut,
and right-click Execute menu item.
2026-03-18 02:27:34 -07:00
Kelsi
db0f868549 feat: extend /use command to support bag/slot notation and equip slot numbers
Adds WoW macro-standard /use argument forms alongside the existing
item-name search:

- /use 0 <slot>   — backpack slot N (1-based, bag 0)
- /use 1-4 <slot> — equipped bag slot N (1-based bag index)
- /use <N>        — equip slot N (1-based, e.g. /use 16 = main hand)

These are the standard forms used in macros like:
  #showtooltip
  /use 13          (trinket 1)
  /cast Arcane Blast
2026-03-18 02:23:47 -07:00
Kelsi
b236a85454 docs: update status.md — water refraction fix, date 2026-03-18 2026-03-18 02:20:59 -07:00
Kelsi
fa3a5ec67e fix: correct water refraction barrier srcAccessMask to prevent VK_ERROR_DEVICE_LOST
The captureSceneHistory barrier was using srcAccessMask=0 with
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT when transitioning the swapchain
image from PRESENT_SRC_KHR to TRANSFER_SRC_OPTIMAL.  This does not
flush the GPU's color attachment write caches, causing VK_ERROR_DEVICE_LOST
on strict drivers (AMD, Mali) that require explicit cache invalidation
before transfer reads.

Fix: use VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT + COLOR_ATTACHMENT_OUTPUT
as the source mask so color writes are properly made visible to the
transfer unit before the image copy begins.

Also remove the now-unnecessary "requires FSR" restriction in the
settings UI — water refraction can be enabled independently of FSR.
2026-03-18 02:20:35 -07:00
Kelsi
8abb65a813 feat: execute macros via keyboard shortcuts; support numeric /cast spell IDs
Two companion improvements for the macro system:

- Keyboard shortcut handler now executes MACRO slots (1-0 keys) by running
  the first line of their text as a command, same as left-click
- /cast now accepts a numeric spell ID or #ID prefix (e.g. /cast 133,
  /cast #133) in addition to spell names — enables standard WoW macro
  syntax and direct spell ID testing
2026-03-18 02:14:10 -07:00
Kelsi
2c86fb4fa6 feat: implement client-side macro text storage and execution
Macros in WoW are client-side — the server sends only a macro index via
SMSG_ACTION_BUTTONS, never the text. This commit adds local storage and
a UI so macro slots are actually usable.

- GameHandler: getMacroText/setMacroText accessors backed by macros_ map;
  text is persisted to the character .cfg file as macro_N_text= entries
- Action bar left-click: MACRO slot executes first line of macro text as
  a chat/slash command (same path as /cast, /use, etc.)
- Context menu: "Execute" and "Edit" items for MACRO slots; "Edit" opens
  a multiline modal editor (320×80 px, up to 255 chars) with Save/Cancel
- Tooltip: shows macro text body below the index; hints "right-click to
  Edit" when no text is set yet
2026-03-18 02:07:59 -07:00
Kelsi
1588c1029a fix: add user feedback for ATTACKSWING_NOTSTANDING and CANT_ATTACK
Both handlers silently cleared state with no visible message, leaving the
player unsure why their attack failed.  Split the shared case block:

- NOTSTANDING: show "You need to stand up to fight." (rate-limited to 1.25s
  via the existing autoAttackRangeWarnCooldown_ guard), keep auto-attack
  active so it fires once the player stands.

- CANT_ATTACK: call stopAutoAttack() to end the attack loop (target is a
  critter, civilian, or already dead — no point retrying), then show "You
  can't attack that." with the same rate limiter.
2026-03-18 01:46:19 -07:00
Kelsi
36158ae3e3 fix: show macro ID in action bar tooltip and context menu header
Macro slots stored from SMSG_ACTION_BUTTONS had no tooltip and no context
menu header — hovering or right-clicking gave a blank result.  Add an
"else if MACRO" branch to both the tooltip and the popup-context-item so
that "Macro #N" is displayed in both places.  Clearing via right-click
still works via the existing "Clear Slot" item which was already outside
the type branches.
2026-03-18 01:42:07 -07:00
Kelsi
7a0c7241ba fix: parse macro action bar slots from SMSG_ACTION_BUTTONS
Macro slots (type 0x40 / 64) were silently dropped by the default branch
of the SMSG_ACTION_BUTTONS type switch, leaving the bar empty for any slot
a player had set to a macro.  ActionBarSlot::MACRO already existed and the
UI already rendered it; only the parser was missing the case.  Add
case 0x40 to map to ActionBarSlot::MACRO for Classic (type=64), TBC, and
WotLK formats, which all share the same 0x40 encoding for macros.
2026-03-18 01:35:39 -07:00
Kelsi
5801af41bc fix: correct Turtle WoW SMSG_INIT_WORLD_STATES format and remove dead minRepeatMs branch
Turtle WoW is Classic 1.12-based and uses the Classic packet format for
SMSG_INIT_WORLD_STATES (no areaId uint32 field before count), not WotLK
format.  Including it in the WotLK branch caused the parser to consume 4
bytes of the count+first-key as a phantom areaId, misaligning all world
state key/value pairs (BG scores, zone events, flag states).

Also remove the dead `turtleMode ? 150 : 150` branch in
performGameObjectInteractionNow — both arms were identical so the ternary
had no effect; replace with a constexpr constant.
2026-03-18 01:30:20 -07:00
Kelsi
57b44d2347 fix: clear craft queue on spell failure and all cast reset paths
craftQueueSpellId_ and craftQueueRemaining_ were already cleared in
cancelCast(), stopCasting(), and SMSG_CAST_RESULT failure, but were
missing from five other cast-abort paths:

- SMSG_SPELL_FAILURE (mid-cast interrupt): queue persisted after
  combat interruption, risking a ghost re-cast on the next SMSG_SPELL_GO
- handleCastFailed() (SMSG_CAST_FAILED): queue persisted if the server
  rejected a craft before it started
- Player login state reset: leftover queue from prior session survived
  into the new world session
- Same-map resurrection (SMSG_NEW_WORLD): queue persisted through
  spirit-healer resurrection teleport
- Regular world transfer (SMSG_NEW_WORLD): queue persisted across zone
  changes and dungeon portals
2026-03-18 01:15:04 -07:00
Kelsi
6be695078b fix: clear spell queue in stopCasting; fix SMSG_SPELL_DELAYED castTimeTotal; clear cast on same-map res
- stopCasting() (invoked by /stopcasting) now clears queuedSpellId_/
  queuedSpellTarget_ and craftQueueSpellId_/craftQueueRemaining_ so a
  queued spell cannot fire silently after the player explicitly cancels.
- SMSG_SPELL_DELAYED now extends castTimeTotal alongside castTimeRemaining
  for the local player, matching the existing other-unit handling and
  keeping the cast bar progress percentage accurate after server-imposed
  cast delays.
- Same-map resurrection path (SMSG_NEW_WORLD same-map) now resets casting,
  castIsChannel, currentCastSpellId, castTimeRemaining, and the spell queue
  as a defensive measure (player is dead and cannot be casting, but this
  ensures state is clean on respawn).
2026-03-18 00:59:15 -07:00
Kelsi
76ba428b87 fix: /target command selects nearest matching entity
Previously used arbitrary map-iteration order (last match), meaning
'/target Kobold' might target a far-away enemy instead of the closest.

Now computes squared distance for every prefix-matching entity and
keeps the nearest one, matching WoW's own /target behaviour.
2026-03-18 00:39:32 -07:00
Kelsi
60d5edf97f fix: cancel timed cast immediately on movement start
When the player starts moving (forward/backward/strafe/jump) while a
timed non-channeled cast is in progress, call cancelCast() before
sending the movement packet.  Previously the cast bar kept counting
down until the server sent SMSG_SPELL_FAILED, causing a visible lag.

Channeled spells are excluded (server ends those via MSG_CHANNEL_UPDATE).
Turning opcodes are excluded (turning while casting is allowed in WoW).
2026-03-18 00:25:04 -07:00
Kelsi
4907f4124b feat: implement spell queue window (400ms pre-cast)
When castSpell() is called while a timed cast is in progress and
castTimeRemaining <= 0.4s, store the spell in queuedSpellId_ instead
of silently dropping it.  handleSpellGo() fires the queued spell
immediately after clearing the cast state, matching the ~400ms spell
queue window in Blizzlike WoW clients.

Queue is cleared on all cancel/interrupt paths: cancelCast(),
handleCastFailed(), SMSG_CAST_RESULT failure, SMSG_SPELL_FAILED,
world-teardown, and worldport ACK.  Channeled casts never queue
(cancelling a channel should remain explicit).
2026-03-18 00:21:46 -07:00
Kelsi
0f8852d290 fix: clear selfResAvailable_ when player releases spirit 2026-03-18 00:09:22 -07:00
Kelsi
5a5c2dcda3 feat: implement self-resurrection (Reincarnation/Twisting Nether)
SMSG_PRE_RESURRECT was silently discarded; Shamans with Reincarnation
and Warlocks with Twisting Nether could never see or use the self-res
ability. Now:

- SMSG_PRE_RESURRECT sets selfResAvailable_ flag when addressed to the
  local player
- Death dialog gains a "Use Self-Resurrection" button (blue, shown above
  Release Spirit) when the flag is set
- Clicking it sends CMSG_SELF_RES (empty body) and clears the flag
- selfResAvailable_ is cleared on all resurrection and session-reset
  paths so it never bleeds across deaths or logins
2026-03-18 00:06:39 -07:00
Kelsi
395a8f77c4 fix: clear corpse reclaim delay on world reset and resurrection
Reset corpseReclaimAvailableMs_ to 0 in both world-teardown/re-login
and ghost-flag-cleared paths so the PvP delay countdown never bleeds
into subsequent deaths or sessions.
2026-03-17 23:57:47 -07:00
Kelsi
b0046fa777 feat: track PvP corpse-reclaim delay and show countdown in UI
SMSG_CORPSE_RECLAIM_DELAY is now stored as an absolute expiry timestamp
(steady_clock ms) instead of being discarded after a chat message.

GameHandler::getCorpseReclaimDelaySec() returns remaining seconds (0 when
reclaim is available). The "Resurrect from Corpse" button now:
- Disables and shows the remaining seconds when a PvP delay is active
- Shows the usual "Corpse: N yards" helper text when available
Also resets corpseReclaimAvailableMs_ on world/session teardown.
2026-03-17 23:52:45 -07:00
Kelsi
2acab47eee fix: correct corpse reclaim — SMSG_DEATH_RELEASE_LOC is graveyard, not corpse
Two bugs prevented "Resurrect from Corpse" from working:

1. SMSG_DEATH_RELEASE_LOC was overwriting corpseX_/Y_/Z_/MapId_ with the
   graveyard spawn point (where the ghost appears after releasing spirit),
   not the actual corpse location.  canReclaimCorpse() was therefore comparing
   the ghost's distance to the graveyard instead of the real corpse, so the
   button never appeared when the ghost returned to the death position.
   Fix: read and log the packet but leave corpseX_/Y_/Z_ untouched.

2. reclaimCorpse() fell back to playerGuid when corpseGuid_ == 0.
   CMSG_RECLAIM_CORPSE requires the corpse object's own GUID; the server
   looks it up by GUID and silently rejects an unknown one.
   Fix: gate reclaimCorpse() on corpseGuid_ being known (set when the
   corpse object arrives in SMSG_UPDATE_OBJECT), and add canReclaimCorpse()
   guard for the same.

Corpse position is now sourced only from:
  - Health-drop detection (primary, fires immediately on death)
  - SMSG_UPDATE_OBJECT CORPSE type (updates when object enters view range)
2026-03-17 23:44:55 -07:00
Kelsi
d99fe8de0f feat: add Sort Bags button to backpack window
Adds Inventory::sortBags() which collects all items from the backpack
and equip bags, sorts them client-side by quality descending → item ID
ascending → stack count descending, then writes them back. A "Sort Bags"
SmallButton is rendered in the backpack footer with a tooltip explaining
the sort order.

The sort is purely local (no server packets) since the WoW protocol has
no sort-bags opcode; it provides an instant, session-persistent visual
reorder.
2026-03-17 23:29:50 -07:00
Kelsi
3e3bbf915e fix: parse SMSG_TRADE_STATUS_EXTENDED correctly for Classic/TBC
WotLK inserts a uint32 tradeId between isSelf and slotCount, and
appends uint32 createPlayedTime at the end of each slot (52-byte
trail vs 48 for Classic/TBC). Without the expansion check, Classic
and TBC parsers consumed tradeId as part of slotCount, resulting in
a bogus slot count and corrupted trade window item display.

Now gates the tradeId read and adjusts SLOT_TRAIL size based on
isActiveExpansion("wotlk").
2026-03-17 22:42:20 -07:00
15 changed files with 2092 additions and 237 deletions

View file

@ -1,6 +1,6 @@
# Project Status
**Last updated**: 2026-03-11
**Last updated**: 2026-03-18
## What This Repo Is
@ -35,9 +35,9 @@ Implemented (working in normal use):
In progress / known gaps:
- Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain
- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects)
- Visual edge cases: some M2/WMO rendering gaps (some particle effects)
- Lava steam particles: sparse in some areas (tuning opportunity)
- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs); currently requires FSR to be active
- Water refraction: enabled by default; srcAccessMask barrier fix (2026-03-18) resolved prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs
## Where To Look

View file

@ -375,6 +375,10 @@ public:
std::shared_ptr<Entity> getFocus() const;
bool hasFocus() const { return focusGuid != 0; }
// Mouseover targeting — set each frame by the nameplate renderer
void setMouseoverGuid(uint64_t guid) { mouseoverGuid_ = guid; }
uint64_t getMouseoverGuid() const { return mouseoverGuid_; }
// Advanced targeting
void targetLastTarget();
void targetEnemy(bool reverse = false);
@ -742,6 +746,8 @@ public:
}
// Send CMSG_PET_ACTION to issue a pet command
void sendPetAction(uint32_t action, uint64_t targetGuid = 0);
// Toggle autocast for a pet spell via CMSG_PET_SPELL_AUTOCAST
void togglePetSpellAutocast(uint32_t spellId);
const std::unordered_set<uint32_t>& getKnownSpells() const { return knownSpells; }
// ---- Pet Stable ----
@ -803,6 +809,9 @@ public:
int getCraftQueueRemaining() const { return craftQueueRemaining_; }
uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; }
// 400ms spell-queue window: next spell to cast when current finishes
uint32_t getQueuedSpellId() const { return queuedSpellId_; }
// Unit cast state (tracked per GUID for target frame + boss frames)
struct UnitCastState {
bool casting = false;
@ -885,6 +894,10 @@ public:
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
// Client-side macro text storage (server sends only macro index; text is stored locally)
const std::string& getMacroText(uint32_t macroId) const;
void setMacroText(uint32_t macroId, const std::string& text);
void saveCharacterConfig();
void loadCharacterConfig();
static std::string getCharacterConfigDir();
@ -931,6 +944,10 @@ public:
using SpellCastAnimCallback = std::function<void(uint64_t guid, bool start, bool isChannel)>;
void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); }
// Fired when the player's own spell cast fails (spellId of the failed spell).
using SpellCastFailedCallback = std::function<void(uint32_t spellId)>;
void setSpellCastFailedCallback(SpellCastFailedCallback cb) { spellCastFailedCallback_ = std::move(cb); }
// Unit animation hint: signal jump (animId=38) for other players/NPCs
using UnitAnimHintCallback = std::function<void(uint64_t guid, uint32_t animId)>;
void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); }
@ -1171,6 +1188,10 @@ public:
bool isPlayerGhost() const { return releasedSpirit_; }
bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; }
bool showResurrectDialog() const { return resurrectRequestPending_; }
/** True when SMSG_PRE_RESURRECT arrived — Reincarnation/Twisting Nether available. */
bool canSelfRes() const { return selfResAvailable_; }
/** Send CMSG_SELF_RES to use Reincarnation / Twisting Nether. */
void useSelfRes();
const std::string& getResurrectCasterName() const { return resurrectCasterName_; }
bool showTalentWipeConfirmDialog() const { return talentWipePending_; }
uint32_t getTalentWipeCost() const { return talentWipeCost_; }
@ -1183,6 +1204,8 @@ public:
void cancelPetUnlearn() { petUnlearnPending_ = false; }
/** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const;
/** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */
float getCorpseReclaimDelaySec() const;
/** Distance (yards) from ghost to corpse, or -1 if no corpse data. */
float getCorpseDistance() const {
if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f;
@ -1878,6 +1901,7 @@ public:
bool isMounted() const { return currentMountDisplayId_ != 0; }
bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
bool isHostileFactionPublic(uint32_t factionTemplateId) const { return isHostileFaction(factionTemplateId); }
float getServerRunSpeed() const { return serverRunSpeed_; }
float getServerWalkSpeed() const { return serverWalkSpeed_; }
float getServerSwimSpeed() const { return serverSwimSpeed_; }
@ -1983,6 +2007,9 @@ public:
void autoEquipItemInBag(int bagIndex, int slotIndex);
void useItemBySlot(int backpackIndex);
void useItemInBag(int bagIndex, int slotIndex);
// CMSG_OPEN_ITEM — for locked containers (lockboxes); server checks keyring automatically
void openItemBySlot(int backpackIndex);
void openItemInBag(int bagIndex, int slotIndex);
void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1);
void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot);
void swapBagSlots(int srcBagIndex, int dstBagIndex);
@ -2110,6 +2137,22 @@ public:
if (index < 0 || index >= static_cast<int>(backpackSlotGuids_.size())) return 0;
return backpackSlotGuids_[index];
}
uint64_t getEquipSlotGuid(int slot) const {
if (slot < 0 || slot >= static_cast<int>(equipSlotGuids_.size())) return 0;
return equipSlotGuids_[slot];
}
// Returns the permanent and temporary enchant IDs for an item by GUID (0 if unknown).
std::pair<uint32_t, uint32_t> getItemEnchantIds(uint64_t guid) const {
auto it = onlineItems_.find(guid);
if (it == onlineItems_.end()) return {0, 0};
return {it->second.permanentEnchantId, it->second.temporaryEnchantId};
}
// Returns the socket gem enchant IDs (3 slots; 0 = empty socket) for an item by GUID.
std::array<uint32_t, 3> getItemSocketEnchantIds(uint64_t guid) const {
auto it = onlineItems_.find(guid);
if (it == onlineItems_.end()) return {};
return it->second.socketEnchantIds;
}
uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; }
/**
@ -2526,6 +2569,7 @@ private:
uint64_t targetGuid = 0;
uint64_t focusGuid = 0; // Focus target
uint64_t lastTargetGuid = 0; // Previous target
uint64_t mouseoverGuid_ = 0; // Set each frame by nameplate renderer
std::vector<uint64_t> tabCycleList;
int tabCycleIndex = -1;
bool tabCycleStale = true;
@ -2605,10 +2649,21 @@ private:
uint32_t stackCount = 1;
uint32_t curDurability = 0;
uint32_t maxDurability = 0;
uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting)
uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons)
std::array<uint32_t, 3> socketEnchantIds{}; // ITEM_ENCHANTMENT_SLOT 2-4 (gems)
};
std::unordered_map<uint64_t, OnlineItemInfo> onlineItems_;
std::unordered_map<uint32_t, ItemQueryResponseData> itemInfoCache_;
std::unordered_set<uint32_t> pendingItemQueries_;
// Deferred SMSG_ITEM_PUSH_RESULT notifications for items whose info wasn't
// cached at arrival time; emitted once the query response arrives.
struct PendingItemPushNotif {
uint32_t itemId = 0;
uint32_t count = 1;
};
std::vector<PendingItemPushNotif> pendingItemPushNotifs_;
std::array<uint64_t, 23> equipSlotGuids_{};
std::array<uint64_t, 16> backpackSlotGuids_{};
std::array<uint64_t, 32> keyringSlotGuids_{};
@ -2719,6 +2774,9 @@ private:
// Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes
uint32_t craftQueueSpellId_ = 0;
int craftQueueRemaining_ = 0;
// Spell queue: next spell to cast within the 400ms window before current cast ends
uint32_t queuedSpellId_ = 0;
uint64_t queuedSpellTarget_ = 0;
// Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START)
std::unordered_map<uint64_t, UnitCastState> unitCastStates_;
uint64_t pendingGameObjectInteractGuid_ = 0;
@ -2750,6 +2808,7 @@ private:
float castTimeTotal = 0.0f;
std::array<ActionBarSlot, ACTION_BAR_SLOTS> actionBar{};
std::unordered_map<uint32_t, std::string> macros_; // client-side macro text (persisted in char config)
std::vector<AuraSlot> playerAuras;
std::vector<AuraSlot> targetAuras;
std::unordered_map<uint64_t, std::vector<AuraSlot>> unitAurasCache_; // per-unit aura cache
@ -3262,6 +3321,7 @@ private:
MeleeSwingCallback meleeSwingCallback_;
uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing
SpellCastAnimCallback spellCastAnimCallback_;
SpellCastFailedCallback spellCastFailedCallback_;
UnitAnimHintCallback unitAnimHintCallback_;
UnitMoveFlagsCallback unitMoveFlagsCallback_;
NpcSwingCallback npcSwingCallback_;
@ -3298,6 +3358,9 @@ private:
uint32_t corpseMapId_ = 0;
float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f;
uint64_t corpseGuid_ = 0;
// Absolute time (ms since epoch) when PvP corpse-reclaim delay expires.
// 0 means no active delay (reclaim allowed immediately upon proximity).
uint64_t corpseReclaimAvailableMs_ = 0;
// Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially
std::array<RuneSlot, 6> playerRunes_ = [] {
std::array<RuneSlot, 6> r{};
@ -3309,6 +3372,7 @@ private:
uint64_t pendingSpiritHealerGuid_ = 0;
bool resurrectPending_ = false;
bool resurrectRequestPending_ = false;
bool selfResAvailable_ = false; // SMSG_PRE_RESURRECT received — Reincarnation/Twisting Nether
// ---- Talent wipe confirm dialog ----
bool talentWipePending_ = false;
uint64_t talentWipeNpcGuid_ = 0;

View file

@ -125,6 +125,10 @@ public:
int findFreeBackpackSlot() const;
bool addItem(const ItemDef& item);
// Sort all bag slots (backpack + equip bags) by quality desc → itemId asc → stackCount desc.
// Purely client-side: reorders the local inventory struct without server interaction.
void sortBags();
// Test data
void populateTestItems();

View file

@ -2027,6 +2027,12 @@ public:
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0);
};
/** CMSG_OPEN_ITEM packet builder (for locked containers / lockboxes) */
class OpenItemPacket {
public:
static network::Packet build(uint8_t bagIndex, uint8_t slotIndex);
};
/** CMSG_AUTOEQUIP_ITEM packet builder */
class AutoEquipItemPacket {
public:

View file

@ -55,6 +55,13 @@ private:
std::vector<std::string> chatSentHistory_;
int chatHistoryIdx_ = -1; // -1 = not browsing history
// Set to true by /stopmacro; checked in executeMacroText to halt remaining commands.
bool macroStopped_ = false;
// Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends.
// Populated by the SpellCastFailedCallback; queried during action bar button rendering.
std::unordered_map<uint32_t, float> actionFlashEndTimes_;
// Tab-completion state for slash commands
std::string chatTabPrefix_; // prefix captured on first Tab press
std::vector<std::string> chatTabMatches_; // matching command list
@ -106,6 +113,8 @@ private:
std::vector<UIErrorEntry> uiErrors_;
bool uiErrorCallbackSet_ = false;
static constexpr float kUIErrorLifetime = 2.5f;
bool castFailedCallbackSet_ = false;
static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade
// Reputation change toast: brief colored slide-in below minimap
struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; };
@ -170,7 +179,7 @@ private:
int pendingResIndex = 0;
bool pendingShadows = true;
float pendingShadowDistance = 300.0f;
bool pendingWaterRefraction = false;
bool pendingWaterRefraction = true;
int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default)
int pendingMasterVolume = 100;
int pendingMusicVolume = 30;
@ -201,6 +210,10 @@ private:
// Keybinding customization
int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index
bool awaitingKeyPress = false;
// Macro editor popup state
uint32_t macroEditorId_ = 0; // macro index being edited
bool macroEditorOpen_ = false; // deferred OpenPopup flag
char macroEditorBuf_[256] = {}; // edit buffer
bool pendingUseOriginalSoundtrack = true;
bool pendingShowActionBar2 = true; // Show second action bar above main bar
float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.51.5)
@ -274,6 +287,7 @@ private:
* Send chat message
*/
void sendChatMessage(game::GameHandler& gameHandler);
void executeMacroText(game::GameHandler& gameHandler, const std::string& macroText);
/**
* Get chat type name

View file

@ -99,7 +99,7 @@ private:
std::unordered_map<uint32_t, VkDescriptorSet> iconCache_;
public:
VkDescriptorSet getItemIcon(uint32_t displayInfoId);
void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr);
void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0);
private:
// Character model preview
@ -161,7 +161,7 @@ private:
SlotKind kind, int backpackIndex,
game::EquipSlot equipSlot,
int bagIndex = -1, int bagSlotIndex = -1);
void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr);
void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0);
// Held item helpers
void pickupFromBackpack(game::Inventory& inv, int index);

View file

@ -6908,6 +6908,10 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
};
// --- Geosets ---
// Mirror the same group-range logic as CharacterPreview::applyEquipment to
// keep other-player rendering consistent with the local character preview.
// Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves,
// 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md).
std::unordered_set<uint16_t> geosets;
// Body parts (group 0: IDs 0-99, some models use up to 27)
for (uint16_t i = 0; i <= 99; i++) geosets.insert(i);
@ -6915,8 +6919,6 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
uint8_t hairStyleId = static_cast<uint8_t>((st.appearanceBytes >> 16) & 0xFF);
geosets.insert(static_cast<uint16_t>(100 + hairStyleId + 1));
geosets.insert(static_cast<uint16_t>(200 + st.facialFeatures + 1));
geosets.insert(401); // Body joint patches (knees)
geosets.insert(402); // Body joint patches (elbows)
geosets.insert(701); // Ears
geosets.insert(902); // Kneepads
geosets.insert(2002); // Bare feet mesh
@ -6924,39 +6926,47 @@ void Application::setOnlinePlayerEquipment(uint64_t guid,
const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7;
const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9;
// Chest/Shirt/Robe (invType 4,5,20)
// Per-group defaults — overridden below when equipment provides a geoset value.
uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves)
uint16_t geosetBoots = 502; // Bare shins (group 5, no boots)
uint16_t geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves)
uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings)
// Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8
{
uint32_t did = findDisplayIdByInvType({4, 5, 20});
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 501 + gg1 : 501));
if (gg1 > 0) geosetSleeves = static_cast<uint16_t>(801 + gg1);
// Robe kilt → leg group 13
uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field);
if (gg3 > 0) geosets.insert(static_cast<uint16_t>(1301 + gg3));
if (gg3 > 0) geosetPants = static_cast<uint16_t>(1301 + gg3);
}
// Legs (invType 7)
// Legs (invType 7) → leg group 13
{
uint32_t did = findDisplayIdByInvType({7});
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
if (geosets.count(1302) == 0 && geosets.count(1303) == 0) {
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 1301 + gg1 : 1301));
}
if (gg1 > 0) geosetPants = static_cast<uint16_t>(1301 + gg1);
}
// Feet (invType 8): 401/402 are body patches (always on), 403+ are boot meshes
// Feet/Boots (invType 8) → shin group 5
{
uint32_t did = findDisplayIdByInvType({8});
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
if (gg1 > 0) geosets.insert(static_cast<uint16_t>(402 + gg1));
if (gg1 > 0) geosetBoots = static_cast<uint16_t>(501 + gg1);
}
// Hands (invType 10)
// Hands/Gloves (invType 10) → forearm group 4
{
uint32_t did = findDisplayIdByInvType({10});
uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field);
geosets.insert(static_cast<uint16_t>(gg1 > 0 ? 301 + gg1 : 301));
if (gg1 > 0) geosetGloves = static_cast<uint16_t>(401 + gg1);
}
geosets.insert(geosetGloves);
geosets.insert(geosetBoots);
geosets.insert(geosetSleeves);
geosets.insert(geosetPants);
// Back/Cloak (invType 16)
geosets.insert(hasInvType({16}) ? 1502 : 1501);
// Tabard (invType 19)

View file

@ -1956,12 +1956,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
queryItemInfo(itemId, 0);
if (showInChat) {
std::string itemName = "item #" + std::to_string(itemId);
uint32_t quality = 1; // white default
if (const ItemQueryResponseData* info = getItemInfo(itemId)) {
if (!info->name.empty()) itemName = info->name;
quality = info->quality;
}
// Item info already cached — emit immediately.
std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name;
uint32_t quality = info->quality;
std::string link = buildItemLink(itemId, quality, itemName);
std::string msg = "Received: " + link;
if (count > 1) msg += " x" + std::to_string(count);
@ -1970,8 +1968,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playLootItem();
}
if (itemLootCallback_) {
itemLootCallback_(itemId, count, quality, itemName);
if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName);
} else {
// Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE.
pendingItemPushNotifs_.push_back({itemId, count});
}
}
LOG_INFO("Item push: itemId=", itemId, " count=", count,
@ -2252,9 +2252,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
lastInteractedGoGuid_ = 0;
// Cancel craft queue on cast failure
// Cancel craft queue and spell queue on cast failure
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Pass player's power type so result 85 says "Not enough rage/energy/etc."
int playerPowerType = -1;
if (auto pe = entityManager.getEntity(playerGuid)) {
@ -2265,6 +2267,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
std::string errMsg = reason ? reason
: ("Spell cast failed (error " + std::to_string(castResult) + ")");
addUIError(errMsg);
if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId);
MessageChatData msg;
msg.type = ChatType::SYSTEM;
msg.language = ChatLanguage::UNIVERSAL;
@ -2645,25 +2648,31 @@ void GameHandler::handlePacket(network::Packet& packet) {
break;
}
case Opcode::SMSG_CORPSE_RECLAIM_DELAY: {
// uint32 delayMs before player can reclaim corpse
// uint32 delayMs before player can reclaim corpse (PvP deaths)
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t delayMs = packet.readUInt32();
uint32_t delaySec = (delayMs + 999) / 1000;
addSystemChatMessage("You can reclaim your corpse in " +
std::to_string(delaySec) + " seconds.");
LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms");
auto nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
corpseReclaimAvailableMs_ = nowMs + delayMs;
LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms");
}
break;
}
case Opcode::SMSG_DEATH_RELEASE_LOC: {
// uint32 mapId + float x + float y + float z — corpse/spirit healer position
// uint32 mapId + float x + float y + float z
// This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location.
// The corpse remains at the death position (already cached when health dropped to 0,
// and updated when the corpse object arrives via SMSG_UPDATE_OBJECT).
// Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse()
// by making it check distance to the graveyard instead of the real corpse.
if (packet.getSize() - packet.getReadPos() >= 16) {
corpseMapId_ = packet.readUInt32();
corpseX_ = packet.readFloat();
corpseY_ = packet.readFloat();
corpseZ_ = packet.readFloat();
LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_,
" x=", corpseX_, " y=", corpseY_, " z=", corpseZ_);
uint32_t relMapId = packet.readUInt32();
float relX = packet.readFloat();
float relY = packet.readFloat();
float relZ = packet.readFloat();
LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId,
" x=", relX, " y=", relY, " z=", relZ);
}
break;
}
@ -3240,9 +3249,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
case Opcode::SMSG_ATTACKSWING_NOTSTANDING:
case Opcode::SMSG_ATTACKSWING_CANT_ATTACK:
autoAttackOutOfRange_ = false;
autoAttackOutOfRangeTime_ = 0.0f;
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
addSystemChatMessage("You need to stand up to fight.");
autoAttackRangeWarnCooldown_ = 1.25f;
}
break;
case Opcode::SMSG_ATTACKSWING_CANT_ATTACK:
// Target is permanently non-attackable (critter, civilian, already dead, etc.).
// Stop the auto-attack loop so the client doesn't spam the server.
stopAutoAttack();
if (autoAttackRangeWarnCooldown_ <= 0.0f) {
addSystemChatMessage("You can't attack that.");
autoAttackRangeWarnCooldown_ = 1.25f;
}
break;
case Opcode::SMSG_ATTACKERSTATEUPDATE:
handleAttackerStateUpdate(packet);
@ -3347,6 +3368,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
castIsChannel = false;
currentCastSpellId = 0;
lastInteractedGoGuid_ = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* ssm = renderer->getSpellSoundManager()) {
ssm->stopPrecast();
@ -4006,9 +4031,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
worldStateMapId_ = packet.readUInt32();
worldStateZoneId_ = packet.readUInt32();
// WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent
// WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format
size_t remaining = packet.getSize() - packet.getReadPos();
bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle");
bool isWotLKFormat = isActiveExpansion("wotlk");
if (isWotLKFormat && remaining >= 6) {
packet.readUInt32(); // areaId (WotLK only)
}
@ -4166,7 +4191,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
if (delayMs == 0) break;
float delaySec = delayMs / 1000.0f;
if (caster == playerGuid) {
if (casting) castTimeRemaining += delaySec;
if (casting) {
castTimeRemaining += delaySec;
castTimeTotal += delaySec; // keep progress percentage correct
}
} else {
auto it = unitCastStates_.find(caster);
if (it != unitCastStates_.end() && it->second.casting) {
@ -4411,9 +4439,10 @@ void GameHandler::handlePacket(network::Packet& packet) {
ActionBarSlot slot;
switch (type) {
case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break;
case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break;
case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break;
default: continue; // macro or unknown — leave as-is
case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item
case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item
case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions)
default: continue; // unknown — leave as-is
}
actionBar[i] = slot;
}
@ -7310,8 +7339,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
// ---- Pre-resurrect state ----
case Opcode::SMSG_PRE_RESURRECT: {
// packed GUID of the player to enter pre-resurrect
(void)UpdateObjectParser::readPackedGuid(packet);
// SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect.
// Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock),
// or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept.
uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet);
if (targetGuid == playerGuid || targetGuid == 0) {
selfResAvailable_ = true;
LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x",
std::hex, targetGuid, std::dec, ")");
}
break;
}
@ -9077,9 +9113,14 @@ void GameHandler::selectCharacter(uint64_t characterGuid) {
lastInteractedGoGuid_ = 0;
castTimeRemaining = 0.0f;
castTimeTotal = 0.0f;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
playerDead_ = false;
releasedSpirit_ = false;
corpseGuid_ = 0;
corpseReclaimAvailableMs_ = 0;
targetGuid = 0;
focusGuid = 0;
lastTargetGuid = 0;
@ -9186,6 +9227,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
movementInfo.jumpXYSpeed = 0.0f;
resurrectPending_ = false;
resurrectRequestPending_ = false;
selfResAvailable_ = false;
onTaxiFlight_ = false;
taxiMountActive_ = false;
taxiActivatePending_ = false;
@ -10685,6 +10727,21 @@ void GameHandler::sendMovement(Opcode opcode) {
}
}
// Cancel any timed (non-channeled) cast the moment the player starts moving.
// Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server.
// Turning (MSG_MOVE_START_TURN_*) is allowed while casting.
if (casting && !castIsChannel) {
const bool isPositionalMove =
opcode == Opcode::MSG_MOVE_START_FORWARD ||
opcode == Opcode::MSG_MOVE_START_BACKWARD ||
opcode == Opcode::MSG_MOVE_START_STRAFE_LEFT ||
opcode == Opcode::MSG_MOVE_START_STRAFE_RIGHT ||
opcode == Opcode::MSG_MOVE_JUMP;
if (isPositionalMove) {
cancelCast();
}
}
// Update movement flags based on opcode
switch (opcode) {
case Opcode::MSG_MOVE_START_FORWARD:
@ -10978,9 +11035,11 @@ void GameHandler::forceClearTaxiAndMovementState() {
vehicleId_ = 0;
resurrectPending_ = false;
resurrectRequestPending_ = false;
selfResAvailable_ = false;
playerDead_ = false;
releasedSpirit_ = false;
corpseGuid_ = 0;
corpseReclaimAvailableMs_ = 0;
repopPending_ = false;
pendingSpiritHealerGuid_ = 0;
resurrectCasterGuid_ = 0;
@ -11619,6 +11678,13 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT));
auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY));
auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY));
const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF)
? static_cast<uint16_t>(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu;
auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end();
auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end();
auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end();
auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end();
auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end();
if (entryIt != block.fields.end() && entryIt->second != 0) {
// Preserve existing info when doing partial updates
OnlineItemInfo info = onlineItems_.count(block.guid)
@ -11627,6 +11693,11 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
if (stackIt != block.fields.end()) info.stackCount = stackIt->second;
if (durIt != block.fields.end()) info.curDurability = durIt->second;
if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second;
if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second;
if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second;
if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second;
if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second;
if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second;
bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end());
onlineItems_[block.guid] = info;
if (isNew) newItemCreated = true;
@ -11878,6 +11949,7 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
} else if (wasDead && !nowDead) {
playerDead_ = false;
releasedSpirit_ = false;
selfResAvailable_ = false;
LOG_INFO("Player resurrected (dynamic flags)");
}
} else if (entity->getType() == ObjectType::UNIT) {
@ -12159,8 +12231,10 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
playerDead_ = false;
repopPending_ = false;
resurrectPending_ = false;
selfResAvailable_ = false;
corpseMapId_ = 0; // corpse reclaimed
corpseGuid_ = 0;
corpseReclaimAvailableMs_ = 0;
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
if (ghostStateCallback_) ghostStateCallback_(false);
}
@ -12208,6 +12282,15 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY);
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
// ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset
// across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8).
// Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12).
const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF;
const uint16_t itemPermEnchField = itemEnchBase;
const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF;
const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF;
const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF;
const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF;
auto it = onlineItems_.find(block.guid);
bool isItemInInventory = (it != onlineItems_.end());
@ -12220,14 +12303,61 @@ void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItem
}
} else if (key == itemDurField && isItemInInventory) {
if (it->second.curDurability != val) {
const uint32_t prevDur = it->second.curDurability;
it->second.curDurability = val;
inventoryChanged = true;
// Warn once when durability drops below 20% for an equipped item.
const uint32_t maxDur = it->second.maxDurability;
if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) {
// Check if this item is in an equip slot (not bag inventory).
bool isEquipped = false;
for (uint64_t slotGuid : equipSlotGuids_) {
if (slotGuid == block.guid) { isEquipped = true; break; }
}
if (isEquipped) {
std::string itemName;
const auto* info = getItemInfo(it->second.entry);
if (info) itemName = info->name;
char buf[128];
if (!itemName.empty())
std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str());
else
std::snprintf(buf, sizeof(buf), "An equipped item is about to break!");
addUIError(buf);
addSystemChatMessage(buf);
}
}
}
} else if (key == itemMaxDurField && isItemInInventory) {
if (it->second.maxDurability != val) {
it->second.maxDurability = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) {
if (it->second.permanentEnchantId != val) {
it->second.permanentEnchantId = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) {
if (it->second.temporaryEnchantId != val) {
it->second.temporaryEnchantId = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) {
if (it->second.socketEnchantIds[0] != val) {
it->second.socketEnchantIds[0] = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) {
if (it->second.socketEnchantIds[1] != val) {
it->second.socketEnchantIds[1] = val;
inventoryChanged = true;
}
} else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) {
if (it->second.socketEnchantIds[2] != val) {
it->second.socketEnchantIds[2] = val;
inventoryChanged = true;
}
}
}
// Update container slot GUIDs on bag content changes
@ -13895,7 +14025,7 @@ void GameHandler::stopCasting() {
socket->send(packet);
}
// Reset casting state
// Reset casting state and clear any queued spell so it doesn't fire later
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
@ -13903,6 +14033,10 @@ void GameHandler::stopCasting() {
lastInteractedGoGuid_ = 0;
castTimeRemaining = 0.0f;
castTimeTotal = 0.0f;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
LOG_INFO("Cancelled spell cast");
}
@ -13916,7 +14050,12 @@ void GameHandler::releaseSpirit() {
}
auto packet = RepopRequestPacket::build();
socket->send(packet);
releasedSpirit_ = true;
// Do NOT set releasedSpirit_ = true here. Setting it optimistically races
// with PLAYER_FLAGS field updates that arrive before the server processes
// CMSG_REPOP_REQUEST: the PLAYER_FLAGS handler sees wasGhost=true/nowGhost=false
// and fires the "ghost cleared" path, wiping corpseMapId_/corpseGuid_.
// Let the server drive ghost state via PLAYER_FLAGS_GHOST (field update path).
selfResAvailable_ = false; // self-res window closes when spirit is released
repopPending_ = true;
lastRepopRequestMs_ = static_cast<uint64_t>(now);
LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)");
@ -13924,26 +14063,47 @@ void GameHandler::releaseSpirit() {
}
bool GameHandler::canReclaimCorpse() const {
if (!releasedSpirit_ || corpseMapId_ == 0) return false;
// Only if ghost is on the same map as their corpse
// Need: ghost state + corpse object GUID (required by CMSG_RECLAIM_CORPSE) +
// corpse map known + same map + within 40 yards.
if (!releasedSpirit_ || corpseGuid_ == 0 || corpseMapId_ == 0) return false;
if (currentMapId_ != corpseMapId_) return false;
// movementInfo.x/y are canonical (x=north=server_y, y=west=server_x).
// corpseX_/Y_ are raw server coords (x=west, y=north).
// Convert corpse to canonical before comparing.
float dx = movementInfo.x - corpseY_; // canonical north - server.y
float dy = movementInfo.y - corpseX_; // canonical west - server.x
float dz = movementInfo.z - corpseZ_;
return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f);
}
float GameHandler::getCorpseReclaimDelaySec() const {
if (corpseReclaimAvailableMs_ == 0) return 0.0f;
auto nowMs = static_cast<uint64_t>(
std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now().time_since_epoch()).count());
if (nowMs >= corpseReclaimAvailableMs_) return 0.0f;
return static_cast<float>(corpseReclaimAvailableMs_ - nowMs) / 1000.0f;
}
void GameHandler::reclaimCorpse() {
if (!canReclaimCorpse() || !socket) return;
// Reclaim expects the corpse object guid when known; fallback to player guid.
uint64_t reclaimGuid = (corpseGuid_ != 0) ? corpseGuid_ : playerGuid;
auto packet = ReclaimCorpsePacket::build(reclaimGuid);
// CMSG_RECLAIM_CORPSE requires the corpse object's own GUID.
// Servers look up the corpse by this GUID; sending the player GUID silently fails.
if (corpseGuid_ == 0) {
LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim");
return;
}
auto packet = ReclaimCorpsePacket::build(corpseGuid_);
socket->send(packet);
LOG_INFO("Sent CMSG_RECLAIM_CORPSE for guid=0x", std::hex, reclaimGuid, std::dec,
(corpseGuid_ == 0 ? " (fallback player guid)" : ""));
LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec);
}
void GameHandler::useSelfRes() {
if (!selfResAvailable_ || !socket) return;
// CMSG_SELF_RES: empty body — server confirms resurrection via SMSG_UPDATE_OBJECT.
network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES));
socket->send(pkt);
selfResAvailable_ = false;
LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)");
}
void GameHandler::activateSpiritHealer(uint64_t npcGuid) {
@ -14336,6 +14496,25 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) {
rebuildOnlineInventory();
maybeDetectVisibleItemLayout();
// Flush any deferred loot notifications waiting on this item's name/quality.
for (auto it = pendingItemPushNotifs_.begin(); it != pendingItemPushNotifs_.end(); ) {
if (it->itemId == data.entry) {
std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name;
std::string link = buildItemLink(data.entry, data.quality, itemName);
std::string msg = "Received: " + link;
if (it->count > 1) msg += " x" + std::to_string(it->count);
addSystemChatMessage(msg);
if (auto* renderer = core::Application::getInstance().getRenderer()) {
if (auto* sfx = renderer->getUiSoundManager())
sfx->playLootItem();
}
if (itemLootCallback_) itemLootCallback_(data.entry, it->count, data.quality, itemName);
it = pendingItemPushNotifs_.erase(it);
} else {
++it;
}
}
// Selectively re-emit only players whose equipment references this item entry
const uint32_t resolvedEntry = data.entry;
for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) {
@ -17891,7 +18070,17 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) {
return;
}
if (casting) return; // Already casting
if (casting) {
// Spell queue: if we're within 400ms of the cast completing (and not channeling),
// store the spell so it fires automatically when the cast finishes.
if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) {
queuedSpellId_ = spellId;
queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid;
LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f,
"ms remaining)");
}
return;
}
// Hearthstone: cast spell directly (server checks item in inventory)
// Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which
@ -17993,9 +18182,11 @@ void GameHandler::cancelCast() {
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// Cancel craft queue when player manually cancels cast
// Cancel craft queue and spell queue when player manually cancels cast
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
}
void GameHandler::startCraftQueue(uint32_t spellId, int count) {
@ -18104,6 +18295,24 @@ void GameHandler::dismissPet() {
socket->send(packet);
}
void GameHandler::togglePetSpellAutocast(uint32_t spellId) {
if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return;
bool currentlyOn = petAutocastSpells_.count(spellId) != 0;
uint8_t newState = currentlyOn ? 0 : 1;
// CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1)
network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST));
pkt.writeUInt64(petGuid_);
pkt.writeUInt32(spellId);
pkt.writeUInt8(newState);
socket->send(pkt);
// Optimistically update local state; server will confirm via SMSG_PET_SPELLS
if (newState)
petAutocastSpells_.insert(spellId);
else
petAutocastSpells_.erase(spellId);
LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState);
}
void GameHandler::renamePet(const std::string& newName) {
if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return;
if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars
@ -18269,6 +18478,10 @@ void GameHandler::handleCastFailed(network::Packet& packet) {
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
lastInteractedGoGuid_ = 0;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Stop precast sound — spell failed before completing
if (auto* renderer = core::Application::getInstance().getRenderer()) {
@ -18441,6 +18654,16 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
if (spellCastAnimCallback_) {
spellCastAnimCallback_(playerGuid, false, false);
}
// Spell queue: fire the next queued spell now that casting has ended
if (queuedSpellId_ != 0) {
uint32_t nextSpell = queuedSpellId_;
uint64_t nextTarget = queuedSpellTarget_;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
LOG_INFO("Spell queue: firing queued spellId=", nextSpell);
castSpell(nextSpell, nextTarget);
}
} else {
if (spellCastAnimCallback_) {
// End cast animation on other unit
@ -19664,14 +19887,12 @@ void GameHandler::interactWithGameObject(uint64_t guid) {
void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
if (guid == 0) return;
if (state != WorldState::IN_WORLD || !socket) return;
bool turtleMode = isActiveExpansion("turtle");
// Rate-limit to prevent spamming the server
static uint64_t lastInteractGuid = 0;
static std::chrono::steady_clock::time_point lastInteractTime{};
auto now = std::chrono::steady_clock::now();
// Keep duplicate suppression, but allow quick retry clicks.
int64_t minRepeatMs = turtleMode ? 150 : 150;
constexpr int64_t minRepeatMs = 150;
if (guid == lastInteractGuid &&
std::chrono::duration_cast<std::chrono::milliseconds>(now - lastInteractTime).count() < minRepeatMs) {
return;
@ -20944,6 +21165,26 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
}
}
void GameHandler::openItemBySlot(int backpackIndex) {
if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return;
if (inventory.getBackpackSlot(backpackIndex).empty()) return;
if (state != WorldState::IN_WORLD || !socket) return;
auto packet = OpenItemPacket::build(0xFF, static_cast<uint8_t>(23 + backpackIndex));
LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex));
socket->send(packet);
}
void GameHandler::openItemInBag(int bagIndex, int slotIndex) {
if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return;
if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return;
if (inventory.getBagSlot(bagIndex, slotIndex).empty()) return;
if (state != WorldState::IN_WORLD || !socket) return;
uint8_t wowBag = static_cast<uint8_t>(19 + bagIndex);
auto packet = OpenItemPacket::build(wowBag, static_cast<uint8_t>(slotIndex));
LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex);
socket->send(packet);
}
void GameHandler::useItemById(uint32_t itemId) {
if (itemId == 0) return;
LOG_DEBUG("useItemById: searching for itemId=", itemId);
@ -21963,6 +22204,14 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
hostileAttackers_.clear();
stopAutoAttack();
tabCycleStale = true;
casting = false;
castIsChannel = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
if (socket) {
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK));
@ -22021,6 +22270,10 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
pendingGameObjectInteractGuid_ = 0;
lastInteractedGoGuid_ = 0;
castTimeRemaining = 0.0f;
craftQueueSpellId_ = 0;
craftQueueRemaining_ = 0;
queuedSpellId_ = 0;
queuedSpellTarget_ = 0;
// Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready
if (socket) {
@ -23345,6 +23598,21 @@ std::string GameHandler::getCharacterConfigDir() {
return dir;
}
static const std::string EMPTY_MACRO_TEXT;
const std::string& GameHandler::getMacroText(uint32_t macroId) const {
auto it = macros_.find(macroId);
return (it != macros_.end()) ? it->second : EMPTY_MACRO_TEXT;
}
void GameHandler::setMacroText(uint32_t macroId, const std::string& text) {
if (text.empty())
macros_.erase(macroId);
else
macros_[macroId] = text;
saveCharacterConfig();
}
void GameHandler::saveCharacterConfig() {
const Character* ch = getActiveCharacter();
if (!ch || ch->name.empty()) return;
@ -23371,6 +23639,21 @@ void GameHandler::saveCharacterConfig() {
out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n";
}
// Save client-side macro text (escape newlines as \n literal)
for (const auto& [id, text] : macros_) {
if (!text.empty()) {
std::string escaped;
escaped.reserve(text.size());
for (char c : text) {
if (c == '\n') { escaped += "\\n"; }
else if (c == '\r') { /* skip CR */ }
else if (c == '\\') { escaped += "\\\\"; }
else { escaped += c; }
}
out << "macro_" << id << "_text=" << escaped << "\n";
}
}
// Save quest log
out << "quest_log_count=" << questLog_.size() << "\n";
for (size_t i = 0; i < questLog_.size(); i++) {
@ -23411,6 +23694,28 @@ void GameHandler::loadCharacterConfig() {
try { savedGender = std::stoi(val); } catch (...) {}
} else if (key == "use_female_model") {
try { savedUseFemaleModel = std::stoi(val); } catch (...) {}
} else if (key.rfind("macro_", 0) == 0) {
// Parse macro_N_text
size_t firstUnder = 6; // length of "macro_"
size_t secondUnder = key.find('_', firstUnder);
if (secondUnder == std::string::npos) continue;
uint32_t macroId = 0;
try { macroId = static_cast<uint32_t>(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; }
if (key.substr(secondUnder + 1) == "text" && !val.empty()) {
// Unescape \n and \\ sequences
std::string unescaped;
unescaped.reserve(val.size());
for (size_t i = 0; i < val.size(); ++i) {
if (val[i] == '\\' && i + 1 < val.size()) {
if (val[i+1] == 'n') { unescaped += '\n'; ++i; }
else if (val[i+1] == '\\') { unescaped += '\\'; ++i; }
else { unescaped += val[i]; }
} else {
unescaped += val[i];
}
}
macros_[macroId] = std::move(unescaped);
}
} else if (key.rfind("action_bar_", 0) == 0) {
// Parse action_bar_N_type or action_bar_N_id
size_t firstUnderscore = 11; // length of "action_bar_"
@ -24487,28 +24792,33 @@ void GameHandler::resetTradeState() {
}
void GameHandler::handleTradeStatusExtended(network::Packet& packet) {
// WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format:
// uint8 isSelfState (1 = my trade window, 0 = peer's)
// uint32 tradeId
// uint32 slotCount (7: 6 normal + 1 extra for enchanting)
// Per slot (up to slotCount):
// uint8 slotIndex
// uint32 itemId
// uint32 displayId
// uint32 stackCount
// uint8 isWrapped
// uint64 giftCreatorGuid
// uint32 enchantId (and several more enchant/stat fields)
// ... (complex; we parse only the essential fields)
// uint64 coins (gold offered by the sender of this message)
size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 9) return;
// SMSG_TRADE_STATUS_EXTENDED format differs by expansion:
//
// Classic/TBC:
// uint8 isSelf + uint32 slotCount + [slots] + uint64 coins
// Per slot tail (after isWrapped): giftCreatorGuid(8) + enchants(24) +
// randomPropertyId(4) + suffixFactor(4) + durability(4) + maxDurability(4) = 48 bytes
//
// WotLK 3.3.5a adds:
// uint32 tradeId (after isSelf, before slotCount)
// Per slot: + createPlayedTime(4) at end of trail → trail = 52 bytes
//
// Minimum: isSelf(1) + [tradeId(4)] + slotCount(4) = 5 or 9 bytes
const bool isWotLK = isActiveExpansion("wotlk");
size_t minHdr = isWotLK ? 9u : 5u;
if (packet.getSize() - packet.getReadPos() < minHdr) return;
uint8_t isSelf = packet.readUInt8();
uint32_t tradeId = packet.readUInt32(); (void)tradeId;
if (isWotLK) {
/*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field
}
uint32_t slotCount = packet.readUInt32();
// Per-slot tail bytes after isWrapped:
// Classic/TBC: giftCreatorGuid(8) + enchants(24) + stats(16) = 48
// WotLK: same + createPlayedTime(4) = 52
const size_t SLOT_TRAIL = isWotLK ? 52u : 48u;
auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_;
for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) {
@ -24521,12 +24831,6 @@ void GameHandler::handleTradeStatusExtended(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() >= 1) {
isWrapped = (packet.readUInt8() != 0);
}
// AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped:
// giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12)
// + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24]
// + randomPropertyId (4) + suffixFactor (4)
// + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes
constexpr size_t SLOT_TRAIL = 52;
if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) {
packet.setReadPos(packet.getReadPos() + SLOT_TRAIL);
} else {

View file

@ -1,5 +1,6 @@
#include "game/inventory.hpp"
#include "core/logger.hpp"
#include <algorithm>
namespace wowee {
namespace game {
@ -185,6 +186,44 @@ bool Inventory::addItem(const ItemDef& item) {
return true;
}
void Inventory::sortBags() {
// Collect all items from backpack and equip bags into a flat list.
std::vector<ItemDef> items;
items.reserve(BACKPACK_SLOTS + NUM_BAG_SLOTS * MAX_BAG_SIZE);
for (int i = 0; i < BACKPACK_SLOTS; ++i) {
if (!backpack[i].empty())
items.push_back(backpack[i].item);
}
for (int b = 0; b < NUM_BAG_SLOTS; ++b) {
for (int s = 0; s < bags[b].size; ++s) {
if (!bags[b].slots[s].empty())
items.push_back(bags[b].slots[s].item);
}
}
// Sort: quality descending → itemId ascending → stackCount descending.
std::stable_sort(items.begin(), items.end(), [](const ItemDef& a, const ItemDef& b) {
if (a.quality != b.quality)
return static_cast<int>(a.quality) > static_cast<int>(b.quality);
if (a.itemId != b.itemId)
return a.itemId < b.itemId;
return a.stackCount > b.stackCount;
});
// Write sorted items back, filling backpack first then equip bags.
int idx = 0;
int n = static_cast<int>(items.size());
for (int i = 0; i < BACKPACK_SLOTS; ++i)
backpack[i].item = (idx < n) ? items[idx++] : ItemDef{};
for (int b = 0; b < NUM_BAG_SLOTS; ++b) {
for (int s = 0; s < bags[b].size; ++s)
bags[b].slots[s].item = (idx < n) ? items[idx++] : ItemDef{};
}
}
void Inventory::populateTestItems() {
// Equipment
{

View file

@ -1403,15 +1403,14 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
SpellGoMissEntry m;
m.targetGuid = packet.readUInt64(); // full GUID in TBC
m.missType = packet.readUInt8();
if (m.missType == 11) {
if (packet.getReadPos() + 5 > packet.getSize()) {
if (m.missType == 11) { // SPELL_MISS_REFLECT
if (packet.getReadPos() + 1 > packet.getSize()) {
LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i,
"/", (int)rawMissCount);
truncatedTargets = true;
break;
}
(void)packet.readUInt32();
(void)packet.readUInt8();
(void)packet.readUInt8(); // reflectResult
}
if (i < storedMissLimit) {
data.missTargets.push_back(m);

View file

@ -3891,46 +3891,54 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
const uint8_t rawMissCount = packet.readUInt8();
if (rawMissCount > 128) {
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ")");
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount,
") spell=", data.spellId, " hits=", (int)data.hitCount,
" remaining=", packet.getSize() - packet.getReadPos());
}
const uint8_t storedMissLimit = std::min<uint8_t>(rawMissCount, 128);
data.missTargets.reserve(storedMissLimit);
for (uint16_t i = 0; i < rawMissCount; ++i) {
// Each miss entry: packed GUID(1-8 bytes) + missType(1 byte).
// REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult.
// REFLECT additionally appends uint8 reflectResult.
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount);
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount,
" spell=", data.spellId, " hits=", (int)data.hitCount);
truncatedTargets = true;
break;
}
SpellGoMissEntry m;
m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount);
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount,
" spell=", data.spellId);
truncatedTargets = true;
break;
}
m.missType = packet.readUInt8();
if (m.missType == 11) {
if (packet.getSize() - packet.getReadPos() < 5) {
if (m.missType == 11) { // SPELL_MISS_REFLECT
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount);
truncatedTargets = true;
break;
}
(void)packet.readUInt32();
(void)packet.readUInt8();
(void)packet.readUInt8(); // reflectResult
}
if (i < storedMissLimit) {
data.missTargets.push_back(m);
}
}
if (truncatedTargets) {
packet.setReadPos(startPos);
return false;
}
data.missCount = static_cast<uint8_t>(data.missTargets.size());
// If miss targets were truncated, salvage the successfully-parsed hit data
// rather than discarding the entire spell. The server already applied effects;
// we just need the hit list for UI feedback (combat text, health bars).
if (truncatedTargets) {
LOG_DEBUG("Spell go: salvaging ", (int)data.hitCount, " hits despite miss truncation");
packet.setReadPos(packet.getSize()); // consume remaining bytes
return true;
}
// 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.
@ -4271,6 +4279,13 @@ network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64
return packet;
}
network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) {
network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM));
packet.writeUInt8(bagIndex);
packet.writeUInt8(slotIndex);
return packet;
}
network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) {
network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM));
packet.writeUInt8(srcBag);

View file

@ -4008,15 +4008,68 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran
}
void M2Renderer::removeInstance(uint32_t instanceId) {
for (auto it = instances.begin(); it != instances.end(); ++it) {
if (it->id == instanceId) {
destroyInstanceBones(*it);
instances.erase(it);
rebuildSpatialIndex();
return;
auto idxIt = instanceIndexById.find(instanceId);
if (idxIt == instanceIndexById.end()) return;
size_t idx = idxIt->second;
if (idx >= instances.size()) return;
auto& inst = instances[idx];
// Remove from spatial grid incrementally (same pattern as the move-update path)
GridCell minCell = toCell(inst.worldBoundsMin);
GridCell maxCell = toCell(inst.worldBoundsMax);
for (int z = minCell.z; z <= maxCell.z; z++) {
for (int y = minCell.y; y <= maxCell.y; y++) {
for (int x = minCell.x; x <= maxCell.x; x++) {
auto gIt = spatialGrid.find(GridCell{x, y, z});
if (gIt != spatialGrid.end()) {
auto& vec = gIt->second;
vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end());
}
}
}
}
// Remove from dedup map
if (!inst.cachedIsGroundDetail) {
DedupKey dk{inst.modelId,
static_cast<int32_t>(std::round(inst.position.x * 10.0f)),
static_cast<int32_t>(std::round(inst.position.y * 10.0f)),
static_cast<int32_t>(std::round(inst.position.z * 10.0f))};
instanceDedupMap_.erase(dk);
}
destroyInstanceBones(inst);
// Swap-remove: move last element to the hole and pop_back to avoid O(n) shift
instanceIndexById.erase(instanceId);
if (idx < instances.size() - 1) {
uint32_t movedId = instances.back().id;
instances[idx] = std::move(instances.back());
instances.pop_back();
instanceIndexById[movedId] = idx;
} else {
instances.pop_back();
}
// Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.)
// These are small vectors of indices that are rebuilt cheaply.
smokeInstanceIndices_.clear();
portalInstanceIndices_.clear();
animatedInstanceIndices_.clear();
particleOnlyInstanceIndices_.clear();
particleInstanceIndices_.clear();
for (size_t i = 0; i < instances.size(); i++) {
auto& ri = instances[i];
if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i);
if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i);
if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i);
if (ri.cachedHasAnimation && !ri.cachedDisableAnimation)
animatedInstanceIndices_.push_back(i);
else if (ri.cachedHasParticleEmitters)
particleOnlyInstanceIndices_.push_back(i);
}
}
void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) {
for (auto& inst : instances) {

View file

@ -1142,10 +1142,14 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd,
};
// Color source: final render pass layout is PRESENT_SRC.
// srcAccessMask must be COLOR_ATTACHMENT_WRITE (not 0) so that GPU cache flushes
// happen before the transfer read. Using srcAccessMask=0 with BOTTOM_OF_PIPE
// causes VK_ERROR_DEVICE_LOST on strict drivers (AMD/Mali) because color writes
// are not made visible to the transfer unit before the copy begins.
barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT,
VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL,
0, VK_ACCESS_TRANSFER_READ_BIT,
VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT);
barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT,
VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,

File diff suppressed because it is too large Load diff

View file

@ -1019,7 +1019,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
float contentH = rows * (slotSize + 4.0f) + 10.0f;
if (bagIndex < 0) {
int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns;
contentH += 25.0f; // money display for backpack
contentH += 36.0f; // separator + sort button + money display
contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots
}
float gridW = columns * (slotSize + 4.0f) + 30.0f;
@ -1094,9 +1094,21 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
}
}
// Money display at bottom of backpack
if (bagIndex < 0 && moneyCopper > 0) {
// Footer for backpack: sort button + money display
if (bagIndex < 0) {
ImGui::Spacing();
ImGui::Separator();
// Sort Bags button — client-side reorder by quality/type
if (ImGui::SmallButton("Sort Bags")) {
inventory.sortBags();
}
if (ImGui::IsItemHovered()) {
ImGui::SetTooltip("Sort all bag slots by quality (highest first),\nthen by item ID, then by stack size.");
}
if (moneyCopper > 0) {
ImGui::SameLine();
uint64_t gold = moneyCopper / 10000;
uint64_t silver = (moneyCopper / 100) % 100;
uint64_t copper = moneyCopper % 100;
@ -1105,6 +1117,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen,
static_cast<unsigned long long>(silver),
static_cast<unsigned long long>(copper));
}
}
ImGui::End();
}
@ -2342,9 +2355,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
gameHandler_->offerQuestFromItem(iGuid, item.startQuestId);
} else if (item.inventoryType > 0) {
gameHandler_->autoEquipItemBySlot(backpackIndex);
} else {
// itemClass==1 (Container) with inventoryType==0 means a lockbox;
// use CMSG_OPEN_ITEM so the server checks keyring automatically.
auto* info = gameHandler_->getItemInfo(item.itemId);
if (info && info->valid && info->itemClass == 1) {
gameHandler_->openItemBySlot(backpackIndex);
} else {
gameHandler_->useItemBySlot(backpackIndex);
}
}
} else if (kind == SlotKind::BACKPACK && isBagSlot) {
LOG_INFO("Right-click bag item: name='", item.name,
"' inventoryType=", (int)item.inventoryType,
@ -2355,11 +2375,16 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
gameHandler_->offerQuestFromItem(iGuid, item.startQuestId);
} else if (item.inventoryType > 0) {
gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex);
} else {
auto* info = gameHandler_->getItemInfo(item.itemId);
if (info && info->valid && info->itemClass == 1) {
gameHandler_->openItemInBag(bagIndex, bagSlotIndex);
} else {
gameHandler_->useItemInBag(bagIndex, bagSlotIndex);
}
}
}
}
// Shift+left-click: insert item link into chat input
if (ImGui::IsItemHovered() && !holdingItem &&
@ -2388,12 +2413,36 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
if (ImGui::IsItemHovered() && !holdingItem) {
// Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise
const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory;
renderItemTooltip(item, tooltipInv);
uint64_t slotGuid = 0;
if (kind == SlotKind::EQUIPMENT && gameHandler_)
slotGuid = gameHandler_->getEquipSlotGuid(static_cast<int>(equipSlot));
renderItemTooltip(item, tooltipInv, slotGuid);
}
}
}
void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory, uint64_t itemGuid) {
// Shared SpellItemEnchantment name lookup — used for socket gems, permanent and temp enchants.
static std::unordered_map<uint32_t, std::string> s_enchLookupB;
static bool s_enchLookupLoadedB = false;
if (!s_enchLookupLoadedB && assetManager_) {
s_enchLookupLoadedB = true;
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
if (dbc && dbc->isLoaded()) {
const auto* lay = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
uint32_t nf = lay ? lay->field("Name") : 8u;
if (nf == 0xFFFFFFFF) nf = 8;
uint32_t fc = dbc->getFieldCount();
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t eid = dbc->getUInt32(r, 0);
if (eid == 0 || nf >= fc) continue;
std::string en = dbc->getString(r, nf);
if (!en.empty()) s_enchLookupB[eid] = std::move(en);
}
}
}
void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory) {
ImGui::BeginTooltip();
ImVec4 qColor = getQualityColor(item.quality);
@ -2778,39 +2827,33 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
{ 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } },
{ 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } },
};
// Get socket gem enchant IDs for this item (filled from item update fields)
std::array<uint32_t, 3> sockGems{};
if (itemGuid != 0 && gameHandler_)
sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid);
bool hasSocket = false;
for (int i = 0; i < 3; ++i) {
if (qi2->socketColor[i] == 0) continue;
if (!hasSocket) { ImGui::Spacing(); hasSocket = true; }
for (const auto& st : kSocketTypes) {
if (qi2->socketColor[i] & st.mask) {
if (sockGems[i] != 0) {
auto git = s_enchLookupB.find(sockGems[i]);
if (git != s_enchLookupB.end())
ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str());
else
ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]);
} else {
ImGui::TextColored(st.col, "%s", st.label);
}
break;
}
}
}
if (hasSocket && qi2->socketBonus != 0) {
static std::unordered_map<uint32_t, std::string> s_enchantNamesD;
static bool s_enchantNamesLoadedD = false;
if (!s_enchantNamesLoadedD && assetManager_) {
s_enchantNamesLoadedD = true;
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
if (dbc && dbc->isLoaded()) {
const auto* lay = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
uint32_t nameField = lay ? lay->field("Name") : 8u;
if (nameField == 0xFFFFFFFF) nameField = 8;
uint32_t fc = dbc->getFieldCount();
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t eid = dbc->getUInt32(r, 0);
if (eid == 0 || nameField >= fc) continue;
std::string ename = dbc->getString(r, nameField);
if (!ename.empty()) s_enchantNamesD[eid] = std::move(ename);
}
}
}
auto enchIt = s_enchantNamesD.find(qi2->socketBonus);
if (enchIt != s_enchantNamesD.end())
auto enchIt = s_enchLookupB.find(qi2->socketBonus);
if (enchIt != s_enchLookupB.end())
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str());
else
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", qi2->socketBonus);
@ -2902,6 +2945,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}
}
// Weapon/armor enchant display for equipped items (reads from item update fields)
if (itemGuid != 0 && gameHandler_) {
auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid);
if (permId != 0) {
auto it2 = s_enchLookupB.find(permId);
const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr;
if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename);
}
if (tempId != 0) {
auto it2 = s_enchLookupB.find(tempId);
const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr;
if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename);
}
}
// "Begins a Quest" line (shown in yellow-green like the game)
if (item.startQuestId != 0) {
ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest");
@ -3054,7 +3112,28 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
// ---------------------------------------------------------------------------
// Tooltip overload for ItemQueryResponseData (used by loot window, etc.)
// ---------------------------------------------------------------------------
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory) {
void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory, uint64_t itemGuid) {
// Shared SpellItemEnchantment name lookup — used for socket gems, socket bonus, and enchants.
static std::unordered_map<uint32_t, std::string> s_enchLookup;
static bool s_enchLookupLoaded = false;
if (!s_enchLookupLoaded && assetManager_) {
s_enchLookupLoaded = true;
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
if (dbc && dbc->isLoaded()) {
const auto* lay = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
uint32_t nf = lay ? lay->field("Name") : 8u;
if (nf == 0xFFFFFFFF) nf = 8;
uint32_t fc = dbc->getFieldCount();
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t eid = dbc->getUInt32(r, 0);
if (eid == 0 || nf >= fc) continue;
std::string en = dbc->getString(r, nf);
if (!en.empty()) s_enchLookup[eid] = std::move(en);
}
}
}
ImGui::BeginTooltip();
ImVec4 qColor = getQualityColor(static_cast<game::ItemQuality>(info.quality));
@ -3389,46 +3468,54 @@ void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info,
{ 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } },
{ 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } },
};
// Get socket gem enchant IDs for this item (filled from item update fields)
std::array<uint32_t, 3> sockGems{};
if (itemGuid != 0 && gameHandler_)
sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid);
bool hasSocket = false;
for (int i = 0; i < 3; ++i) {
if (info.socketColor[i] == 0) continue;
if (!hasSocket) { ImGui::Spacing(); hasSocket = true; }
for (const auto& st : kSocketTypes) {
if (info.socketColor[i] & st.mask) {
if (sockGems[i] != 0) {
auto git = s_enchLookup.find(sockGems[i]);
if (git != s_enchLookup.end())
ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str());
else
ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]);
} else {
ImGui::TextColored(st.col, "%s", st.label);
}
break;
}
}
}
if (hasSocket && info.socketBonus != 0) {
// Socket bonus is a SpellItemEnchantment ID — look up via SpellItemEnchantment.dbc
static std::unordered_map<uint32_t, std::string> s_enchantNames;
static bool s_enchantNamesLoaded = false;
if (!s_enchantNamesLoaded && assetManager_) {
s_enchantNamesLoaded = true;
auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc");
if (dbc && dbc->isLoaded()) {
const auto* lay = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr;
uint32_t nameField = lay ? lay->field("Name") : 8u;
if (nameField == 0xFFFFFFFF) nameField = 8;
uint32_t fc = dbc->getFieldCount();
for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) {
uint32_t eid = dbc->getUInt32(r, 0);
if (eid == 0 || nameField >= fc) continue;
std::string ename = dbc->getString(r, nameField);
if (!ename.empty()) s_enchantNames[eid] = std::move(ename);
}
}
}
auto enchIt = s_enchantNames.find(info.socketBonus);
if (enchIt != s_enchantNames.end())
auto enchIt = s_enchLookup.find(info.socketBonus);
if (enchIt != s_enchLookup.end())
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str());
else
ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus);
}
}
// Weapon/armor enchant display for equipped items
if (itemGuid != 0 && gameHandler_) {
auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid);
if (permId != 0) {
auto it2 = s_enchLookup.find(permId);
const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr;
if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename);
}
if (tempId != 0) {
auto it2 = s_enchLookup.find(tempId);
const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr;
if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename);
}
}
// Item set membership
if (info.itemSetId != 0) {
// Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds)