Compare commits

...

71 commits

Author SHA1 Message Date
Kelsi
34bab8edd6 feat: use rich spell tooltip for buff/debuff frame icons
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
Player buff bar and target debuff bar icons now show full spell tooltip
(school, cost, cast time, range, description) on hover, matching the
action bar and spellbook. Falls back to plain spell name if DBC is not
loaded. Remaining aura duration is shown below the spell body.
2026-03-10 19:33:25 -07:00
Kelsi
caf0d18393 feat: show rich spell tooltip on action bar hover
Expose SpellbookScreen::renderSpellInfoTooltip() as a public method,
then use it in the action bar slot tooltip. Action bar spell tooltips
now show the same full tooltip as the spellbook: spell school (colored),
mana/rage/energy cost, cast time, range, cooldown, and description.

Falls back to a plain spell name if DBC data is not yet loaded.
Hearthstone location note is appended after the rich body.
Cooldown text moved inside each branch for consistent styling.
2026-03-10 19:31:46 -07:00
Kelsi
7bbf2c7769 refactor: improve quest offer reward item display
- Use getQualityColor() for consistent quality coloring (choice+fixed)
- Show item icons for fixed rewards (previously text-only)
- Replace useless "Reward option" tooltip with real item name+description
- Render icon before selectable label (not after) for choice rewards
- Call ensureItemInfo for all reward items to trigger async fetch
- Use structured bindings (C++17) to unify icon+color resolution
2026-03-10 19:25:26 -07:00
Kelsi
bae3477c94 feat: display spell school in spellbook tooltip
Load SchoolMask (TBC/WotLK bitmask) or SchoolEnum (Classic/Turtle 0-6
enum, converted to mask via 1<<N) from Spell.dbc into SpellInfo.

renderSpellTooltip now shows the spell school name (Holy/Fire/Nature/
Frost/Shadow/Arcane) in the appropriate school color between the spell
rank and resource cost. Physical school is suppressed as it is the
implied default. Multi-school spells display both names separated by /.

WotLK DBC fallback path uses field 225 for SchoolMask.
2026-03-10 19:22:48 -07:00
Kelsi
458a95ae8e refactor: use quality colors and hover tooltips for quest reward items
Replace flat white coloring with item quality colors and add hover tooltips
showing item name (quality-colored) and description for quest acceptance window.
Extract renderQuestRewardItem lambda to eliminate code duplication between
choice and fixed reward item rendering.
2026-03-10 19:12:43 -07:00
Kelsi
1ff48259cc feat: display quest reward items in quest details acceptance window
Parse and store reward items (choice and fixed) from SMSG_QUESTGIVER_QUEST_DETAILS
in both WotLK (QuestDetailsParser) and TBC/Classic (TbcPacketParsers) parsers.
Show item icons, names, and counts in the quest acceptance dialog alongside XP/money.
Move QuestRewardItem before QuestDetailsData in header to fix forward-reference.
2026-03-10 19:05:34 -07:00
Kelsi
9f8a0907c4 feat: add spell stats to TBC and Turtle WoW DBC layouts
TBC 2.4.3: TBC added 7 fields after position 5 vs Classic 1.12, giving
a consistent +7 offset for all fields in the middle/upper range. Derive
CastingTimeIndex (22), PowerType (35), ManaCost (36), and RangeIndex (40)
from the verified Classic positions (15/28/29/33) using this offset.
This enables mana cost, cast time, and range display in the TBC spellbook.

Turtle WoW: Inherits Classic 1.12.1 Spell.dbc field layout. Add
CastingTimeIndex (15), PowerType (28), ManaCost (29), RangeIndex (33),
and SpellRange.MaxRange (2) matching Classic 1.12. Enables spell stat
display for Turtle WoW players.

Also update README: pet action bar (10 slots, icons, autocast tinting).
2026-03-10 18:55:01 -07:00
Kelsi
373dbbf95d fix: use authoritative autocast state for pet action bar and correct tooltip labels
- Use isPetSpellAutocast() instead of parsing the slot value high byte for
  autocast detection; the authoritative source is the SMSG_PET_SPELLS spell
  list activeFlags, not the action bar slot value.
- Fix tooltip mapping: actionId==2 maps to "Follow", actionId==5 to "Attack",
  others to "Stay" (removed erroneous duplicate Follow case for actionId==4).
- Update spellbook comment: TBC Spell.dbc has ~220+ fields (not ~167).
2026-03-10 18:37:05 -07:00
Kelsi
2e38a9af65 feat: add pet action bar to pet frame UI
Show the 10 SMSG_PET_SPELLS action slots as clickable icon/text buttons
in the pet frame. Spell slots with icons render as ImageButtons; built-in
commands (Attack/Follow/Stay) render as text buttons. Autocast-on slots
are tinted green. Clicking a spell slot sends CMSG_PET_ACTION with the
current target GUID; built-in commands send without a target. Tooltips
show the spell name on hover.
2026-03-10 18:26:02 -07:00
Kelsi
53d144c51e fix: expansion-aware SpellRange.dbc loading and Classic spell tooltip fields
SpellRange.dbc layout fix:
- Classic 1.12 uses field 2 (MaxRange), TBC/WotLK use field 4 (MaxRangeHostile)
- Add SpellRange layout to each expansion's dbc_layouts.json
- Replace hardcoded field 5 with layout-driven lookup in SpellRange loading
- Corrects previously wrong range values in WotLK spellbook tooltips

Classic 1.12 Spell.dbc field additions:
- Add CastingTimeIndex=15, PowerType=28, ManaCost=29, RangeIndex=33 to
  classic/dbc_layouts.json so Classic spellbook shows mana cost, cast time,
  and range in tooltips

Trainer fieldCount guard:
- Lower Trainer::loadSpellNameCache() Spell.dbc fieldCount threshold from
  154 to 148 so Classic trainers correctly resolve spell names from Spell.dbc
2026-03-10 18:09:21 -07:00
Kelsi
2f3f9f1a21 fix: enable Classic/Turtle Spell.dbc loading and add WotLK optional spell fields to layout
Lower fieldCount threshold from 154→148 so Classic 1.12 and Turtle WoW Spell.dbc
(148 fields, Tooltip at index 147) are accepted by the spellbook loader instead of
being silently skipped.

Add PowerType/ManaCost/CastingTimeIndex/RangeIndex to the WotLK dbc_layouts.json
Spell section so mana cost, cast time, and range continue to display correctly when
the DBC layout path is active (the old hardcoded-index fallback path is now bypassed
since layout-path loads spell names first and spellData.empty() is no longer true).
2026-03-10 17:53:17 -07:00
Kelsi
fcb133dbbe fix: guard optional DBC field reads against out-of-bounds indices for Classic/TBC
When expansion DBC layouts lack PowerType/ManaCost/CastingTimeIndex/RangeIndex,
default to UINT32_MAX instead of WotLK hardcoded indices to prevent reading wrong
data from Classic/TBC Spell.dbc files. tryLoad now skips any field index >= fieldCount.
2026-03-10 17:41:05 -07:00
Kelsi
63c09163dc fix: correct SMSG_ACTION_BUTTONS parsing for Classic and TBC expansions
Classic 1.12 sends 120 action button slots with no leading mode byte
(480 bytes total). TBC 2.4.3 sends 132 slots with no mode byte (528
bytes). WotLK 3.3.5a sends a uint8 mode byte followed by 144 slots
(577 bytes total).

The previous code always consumed a mode byte and assumed 144 slots.
On Classic servers this would misparse the first action button (reading
one byte as the mode, shifting all subsequent entries), causing the
action bar to load garbage spells/items from the server.

Fixed by detecting expansion type at runtime and selecting the
appropriate slot count and presence of mode byte accordingly.
2026-03-10 17:28:20 -07:00
Kelsi
56588e0dad fix: correct SMSG_SPELL_COOLDOWN parsing for Classic 1.12 expansion
Classic 1.12 sends guid(8) + N×[spellId(4)+itemId(4)+cooldown(4)] with
no flags byte and 12 bytes per entry, while TBC/WotLK send guid(8)+
flags(1) + N×[spellId(4)+cooldown(4)] with 8 bytes per entry.

The previous parser always consumed the WotLK flags byte, which on
Classic servers would corrupt the first spell ID (reading one byte
into spellId) and misalign all subsequent entries. Fixed by detecting
isClassicLikeExpansion() and using the correct 12-byte-per-entry
format (skipping itemId) for Classic builds.
2026-03-10 17:19:43 -07:00
Kelsi
59597ff39e feat: display mana cost, cast time, and range in spellbook tooltip
Load SpellCastTimes.dbc and SpellRange.dbc during DBC init and
populate SpellInfo.castTimeMs, manaCost, powerType, rangeIndex.
renderSpellTooltip now shows resource cost (Mana/Rage/Energy/Focus),
cast time ("Instant cast" or "X.X sec cast"), and range ("X yd range"
or "Melee range") for active spells, matching WoW's native tooltip
layout with cost on left and cast time aligned to the right.
2026-03-10 17:14:46 -07:00
Kelsi
068b6bc2cb fix: parse SMSG_INVENTORY_CHANGE_FAILURE additional fields correctly
Previously the handler read only the error byte, producing:
- A literal "%d" in the "requires level" message (error 1)
- No consumption of the following item GUIDs and bag slot bytes

Now reads item_guid1(8) + item_guid2(8) + bag_slot(1) after the error
byte, and for error 1 (EQUIP_ERR_LEVEL_REQ) reads the required level
uint32 and shows the correct message: "You must reach level N to use
that item."
2026-03-10 17:10:31 -07:00
Kelsi
62b7622f75 feat: parse and display StartQuest field from item query response
Items that begin a quest (like quest starter drop items) now show
"Begins a Quest" in the tooltip.

All three expansion parsers (WotLK/TBC/Classic) now read the
PageText/LanguageID/PageMaterial/StartQuest fields after Description.
startQuestId is propagated through all 5 inventory rebuild paths and
stored in ItemDef.
2026-03-10 17:05:04 -07:00
Kelsi
5fcf71e3ff feat: add stat diff comparison in item shift-tooltip
Shift-hover tooltip now shows stat differences vs the equipped item
instead of just listing the equipped item's stats. Each compared stat
shows: value (▲ gain green / ▼ loss red / unchanged grey).

Covers: DPS (weapons), Armor, Str/Agi/Sta/Int/Spi, and all extra stats
(Hit, Crit, Haste, Expertise, AP, SP, Resilience, MP5, etc.) using a
union of stat types from both items.
2026-03-10 17:03:11 -07:00
Kelsi
321aaeae54 feat: capture and display all item stat types in tooltips
Previously only the 5 primary stats (Str/Agi/Sta/Int/Spi) were stored,
discarding hit rating, crit, haste, attack power, spell power, resilience,
expertise, armor penetration, MP5, and many others.

Changes:
- Add ItemDef::ExtraStat and ItemQueryResponseData::ExtraStat arrays
- All three expansion parsers (WotLK/TBC/Classic) now capture non-primary
  stat type/value pairs into extraStats instead of silently dropping them
- All 5 rebuildOnlineInventory paths propagate extraStats to ItemDef
- Tooltip now renders each extra stat on its own line with a name lookup
  covering all common WotLK stat types (hit, crit, haste, AP, SP, etc.)
- Also fix Classic/TBC bag-content and bank-bag paths that were missing
  bindType, description propagation from previous commits
2026-03-10 17:00:24 -07:00
Kelsi
6075207d94 fix: complete TBC item query parsing for itemLevel, spells, binding, description
TBC parser was truncating item query response after armor/resistances,
discarding itemLevel, requiredLevel, spell slots, bind type, and description.

Now stores itemLevel/requiredLevel, reads AmmoType+RangedModRange, reads
5 spell slots into data.spells[], reads bindType and description cstring.
Matches the Classic and WotLK parser fixes from the previous commits.
2026-03-10 16:53:00 -07:00
Kelsi
5004929f07 fix: complete classic/vanilla item query parsing for itemLevel, spells, binding, description
The classic packet parser was stopping after armor/resistances/delay without
reading the remaining tail fields present in vanilla 1.12.1 item packets:
- Store itemLevel and requiredLevel (were read but discarded)
- Read AmmoType and RangedModRange after delay
- Read 5 spell slots (SpellId, SpellTrigger, Charges, Cooldown, Category, CatCooldown)
- Read Bonding type (bindType) after spells
- Read Description (flavor/lore text) cstring after bonding

All new fields now flow into ItemDef via rebuildOnlineInventory and display in
the item tooltip (same as WotLK/TBC — binding text, spell effects, description).
2026-03-10 16:50:14 -07:00
Kelsi
76bd6b409e feat: enhance item tooltips with binding, description, speed, and spell effects
- Parse Bonding and Description fields from SMSG_ITEM_QUERY_SINGLE_RESPONSE
  (read after the 5 spell slots: bindType uint32, then description cstring)
- Add bindType and description to ItemQueryResponseData and ItemDef
- Propagate bindType and description through all 5 rebuildOnlineInventory paths
- Tooltip now shows: "Binds when picked up/equipped/used/quest item"
- Tooltip now shows weapon damage range ("X - Y Damage") and speed ("Speed 2.60")
  on same line, plus DPS in parentheses below
- Tooltip now shows spell effects ("Use: <SpellName>", "Equip: <SpellName>",
  "Chance on Hit: ...") using existing getSpellName() lookup
- Tooltip now shows item flavor/lore description in italic-style yellow text
2026-03-10 16:47:55 -07:00
Kelsi
f53f16a59b feat: add ITEM_FIELD_DURABILITY/MAXDURABILITY to all expansion update_fields
- Classic/Turtle: indices 48/49 (no spell-charge fields between stack
  count and durability in 1.12)
- TBC: indices 60/61 (same layout as WotLK, matches TBC 2.4.3 item fields)
- WotLK: already added in previous commit

Enables durability tracking across all supported expansion profiles.
2026-03-10 16:31:18 -07:00
Kelsi
dce8a4e442 fix: propagate sellPrice to all rebuildOnlineInventory() inventory paths
Equipment, backpack, and bag-content paths were missing def.sellPrice
assignment — only bank/bank-bag paths had it. This caused the "Sell"
price in item tooltips to show 0g 0s 0c for equipped and backpack items.
2026-03-10 16:30:01 -07:00
Kelsi
30058a8df5 fix: preserve vendor canRepair flag when SMSG_LIST_INVENTORY arrives
ListInventoryParser::parse() overwrites currentVendorItems entirely,
resetting canRepair=false. Save the flag before parsing and restore it
after so the "Repair All" button remains visible when an armorer vendor
also sells items.
2026-03-10 16:28:59 -07:00
Kelsi
1793549550 feat: parse and display item level and required level in tooltips
- Add itemLevel/requiredLevel fields to ItemQueryResponseData (parsed
  from SMSG_ITEM_QUERY_SINGLE_RESPONSE) and ItemDef
- Propagate through all 5 rebuildOnlineInventory() paths
- Show "Item Level N" and "Requires Level N" in item tooltip in
  standard WoW order (below item name, above required level/stats)
2026-03-10 16:26:20 -07:00
Kelsi
67db7383ad feat: add durability bar overlay on equipment slots in character panel
Draw a 3px color-coded strip at the bottom of each equipment slot icon
(green >50%, yellow >25%, red <=25%) so broken or near-broken gear is
immediately visible at a glance without opening the tooltip.
2026-03-10 16:23:12 -07:00
Kelsi
0afa41e908 feat: implement item durability tracking and vendor repair
- Add ITEM_FIELD_DURABILITY (60) and ITEM_FIELD_MAXDURABILITY (61) to
  update_field_table.hpp enum and wotlk/update_fields.json
- Add curDurability/maxDurability to OnlineItemInfo and ItemDef structs
- Parse durability fields in OBJECT_CREATE and OBJECT_VALUES handlers;
  preserve existing values on partial updates (fixes stale durability
  being reset to 0 on stack-count-only updates)
- Propagate durability to ItemDef in all 5 rebuildOnlineInventory() paths
- Implement GameHandler::repairItem() and repairAll() via CMSG_REPAIR_ITEM
  (itemGuid=0 repairs all equipped items per WotLK protocol)
- Add canRepair flag to ListInventoryData; set it when player selects
  GOSSIP_OPTION_ARMORER in gossip window
- Show "Repair All" button in vendor window header when canRepair=true
- Display color-coded durability in item tooltip (green >50%, yellow
  >25%, red <=25%)
2026-03-10 16:21:09 -07:00
Kelsi
094ef88e57 fix: NPC animation/position sync for distant creatures and restore WoW ding overlay
- application.cpp creature sync loop: use entity->isEntityMoving() alongside
  planarDist to detect movement; entities > 150u have stale getX/Y/Z (distance
  culled in GameHandler::update) but isEntityMoving() correctly reflects active
  startMoveTo paths from SMSG_MONSTER_MOVE. Fixes distant NPCs playing Stand
  while creatureMoveCallback drives their renderer to Run.
- Switch sync loop to getLatestX/Y/Z (server-authoritative destination) for
  both the distance check and renderPos so creature positions are never stale
  from cull lag, and don't call moveInstanceTo when only entityIsMoving (no
  planarDist): the renderer's spline-driven move from creatureMoveCallback is
  already correct and shouldn't be cancelled by the per-frame sync.
- game_screen.cpp: replace scratch-built ring-burst level-up overlay with a
  simple "You have reached level X!" centered text (WoW style). The actual 3D
  visual is already handled by Renderer::triggerLevelUpEffect (LevelUp.m2).
2026-03-10 16:08:24 -07:00
Kelsi
bee4dde9b7 ui: add side action bars, fix resize positioning, and fix player nameplates
Action bars:
- Expand from 2 bars (24 slots) to 4 bars (48 slots)
- Bar 2: right-edge vertical bar (slots 24-35), off by default
- Bar 3: left-edge vertical bar (slots 36-47), off by default
- New "Interface" settings tab with toggles and offset sliders for all bars
- XP bar Y position now tracks bar 2 visibility and vertical offset

HUD resize fix:
- All HUD elements (action bars, bag bar, XP bar, cast bar, mirror timers)
  now use ImGui::GetIO().DisplaySize instead of window->getWidth/Height()
- DisplaySize is always in sync with the current frame — eliminates the
  one-frame lag that caused bars to misalign after window resize

Player nameplates:
- Show player name only on nameplate (no level number clutter)
- Fall back to "Player (level)" while name query is pending
- NPC nameplates unchanged (still show "level Name")
2026-03-10 15:56:41 -07:00
Kelsi
ec5e7c66c3 fix: derive rest state from PLAYER_BYTES_2 and add action bar 2 settings
XP bar rest state:
- isResting_ now set from PLAYER_BYTES_2 byte 3 bit 0 (rest state flag)
  on both CREATE and VALUES update object handlers
- playerRestedXp_ was missing from VALUES handler — now tracked there too
- Eliminates dependency on SMSG_SET_REST_START (wrong in WotLK opcodes.json)

Interface settings:
- New "Interface" tab in Settings window
- "Show Second Action Bar" toggle (default: on)
- Horizontal/vertical position offset sliders for bar 2
- Settings persisted to/from save file
2026-03-10 15:45:35 -07:00
Kelsi
1a370fef76 fix: chat prefix, hostile faction display, and game object looting
- Add BG_SYSTEM_NEUTRAL/ALLIANCE/HORDE chat types (0x52-0x54) and reclassify
  them as SYSTEM in the parser — prevents bogus [Say] prefix on arena/BG
  system messages
- Remove fallback [TypeName] bracket for sender-less SAY/YELL/WHISPER messages;
  only group-channel types (Party/Guild/Raid/BG) show brackets without a sender
- Remove factionTemplate != 0 guard — units with FT=0 now get setHostile() like
  any other unit (defaulting to hostile from the map default), fixing NPCs that
  appeared friendly due to unset faction template
- Enable CMSG_LOOT for WotLK type=3 (chest) game objects in addition to
  CMSG_GAMEOBJ_USE — fixes Milly's Harvest and other quest gather objects on
  AzerothCore WotLK servers
2026-03-10 15:32:04 -07:00
Kelsi
942df21c66 ui: resolve chat sender names at render time to fix [Say] prefix
When SMSG_MESSAGECHAT arrives before the entity has spawned or its
name is cached, senderName is empty and messages fell through to the
generic '[Say] message' branch. Fix:

- GameHandler::lookupName(guid): checks playerNameCache then entity
  manager (Unit subclass cast) at call time
- Chat display: resolves senderName via lookupName() at render time
  so messages show "Name says: msg" even if the name was unavailable
  when the packet was first parsed
2026-03-10 15:18:00 -07:00
Kelsi
4987388ce7 ui: show GM/AFK/DND chat tags and fix channel/bracket name display
- Display <GM>, <AFK>, <DND> prefix before sender name in all chat
  message formats based on the chatTag bitmask byte (0x04=GM, 0x01=AFK,
  0x02=DND) from SMSG_MESSAGECHAT
- Apply tagPrefix consistently across SAY/YELL/WHISPER/EMOTE/CHANNEL
  and the generic bracket-type fallback
2026-03-10 15:09:41 -07:00
Kelsi
df47d425f4 ui: fix chat type display names and outgoing whisper format
- getChatTypeName: use WoW-style mixed-case names (Party/Guild/Raid/etc.)
  instead of all-caps (PARTY/GUILD/RAID)
- WHISPER_INFORM: display "To Name: message" instead of "[To] Name: message"
  using receiverName when available, falling back to senderName
2026-03-10 15:08:21 -07:00
Kelsi
60ebb565bb rendering: fix WMO portal culling and chat message format
- wmo_renderer: pass character position (not camera position) to portal
  visibility traversal — the 3rd-person camera can orbit outside a WMO
  while the character is inside, causing interior groups to cull; render()
  now accepts optional viewerPos that defaults to camPos for compatibility
- renderer: pass &characterPosition to wmoRenderer->render() at both
  main and single-threaded call sites; reflection pass keeps camPos
- renderer: apply mount pitch/roll to rider during all flight, not just
  taxiFlight_ (fixes zero rider tilt during player-controlled flying)
- game_screen: format SAY/YELL/WHISPER/EMOTE using WoW-style "Name says:"
  instead of "[SAY] Name:" bracket prefix
2026-03-10 14:59:02 -07:00
Kelsi
920d6ac120 physics: sync camera pitch to movement packets and mount tilt during flight
- Add setMovementPitch() and isSwimming() to GameHandler
- In the per-frame sync block, derive the pitch angle from the camera's
  forward vector (asin of the Z component) and write it to movementInfo.pitch
  whenever FLYING or SWIMMING flags are set — the server includes the pitch
  field in those packets, so sending 0 made other players see the character
  flying perfectly flat even when the camera was pitched
- Also tilt the mount model (setMountPitchRoll) to match the flight direction
  during player-controlled flight, and reset to 0 when not flying
2026-03-10 14:46:17 -07:00
Kelsi
132598fc88 physics: send MSG_MOVE_START/STOP_ASCEND and START_DESCEND during flight
When flyingActive_, detect Space/X key transitions and emit proper flight
vertical movement opcodes so the server (and other players) see the
correct ascending/descending animation state:

- MSG_MOVE_START_ASCEND  (Space pressed while flying)  → sets ASCENDING flag
- MSG_MOVE_STOP_ASCEND   (Space released while flying) → clears ASCENDING flag
- MSG_MOVE_START_DESCEND (X pressed while flying)      → clears ASCENDING flag
- MSG_MOVE_STOP_ASCEND   (X released while flying)     → clears vertical state

Track wasAscending_/wasDescending_ member state to detect transitions.
Also clear lingering vertical state when leaving flight mode.
2026-03-10 14:32:30 -07:00
Kelsi
a9ddfe70c2 physics: sync server turn rate and fix SPLINE speed handlers
- Add getServerTurnRate() accessor and turnRateOverride_ field so the
  keyboard turn speed respects SMSG_FORCE_TURN_RATE_CHANGE from server
- Convert rad/s → deg/s before applying to camera yaw logic
- Fix SMSG_SPLINE_SET_RUN_BACK/SWIM/FLIGHT/FLIGHT_BACK/SWIM_BACK/WALK/
  TURN_RATE handlers: all previously discarded the value; now update the
  corresponding serverXxxSpeed_ / serverTurnRate_ field when GUID matches
  playerGuid (camera controller syncs these every frame)
2026-03-10 14:18:25 -07:00
Kelsi
e2f65dfc59 physics: add server flight-back speed override to CameraController
SMSG_FORCE_FLIGHT_BACK_SPEED_CHANGE was already ACK'd and stored in
serverFlightBackSpeed_, but the value was never accessible or synced
to the CameraController. Backward flight movement always used forward
flight speed (flightSpeedOverride_), making it faster than the server
intended.

- Add getServerFlightBackSpeed() accessor in GameHandler
- Add flightBackSpeedOverride_ field and setter in CameraController
- Apply it in the fly movement block: backward-only flight uses the
  back speed; forward or strafing uses the forward speed as WoW does
- Fallback: 50% of forward flight speed when override is unset
- Sync per-frame in application.cpp alongside the other speed overrides
2026-03-10 14:05:50 -07:00
Kelsi
a33f635490 physics: add server swim-back speed override to CameraController
Backward swimming was using 50% of forward swim speed as a hardcoded
fallback. Wire up the server-authoritative swim back speed so Warlock
Dark Pact, buffs, and server-forced speed changes all apply correctly
when swimming backward.

- game_handler.hpp: add getServerSwimBackSpeed() accessor
- camera_controller.hpp: add swimBackSpeedOverride_ field + setter
- camera_controller.cpp: apply swimBackSpeedOverride_ when player
  swims backward without forward input; fall back to 50% of swim speed
- application.cpp: sync swim back speed each frame
2026-03-10 13:51:47 -07:00
Kelsi
23293d6453 physics: implement HOVER movement flag physics in CameraController
When the server sets MovementFlags::HOVER (SMSG_MOVE_SET_HOVER), the
player now floats 4 yards above the nearest ground surface instead of
standing on it. Uses the existing floor-snap path with a HOVER_HEIGHT
offset applied to the snap target.

- game_handler.hpp: add isHovering() accessor (reads HOVER flag from
  movementInfo.flags, which is already set by handleForceMoveFlagChange)
- camera_controller.hpp: add hoverActive_ field and setHoverActive()
- camera_controller.cpp: apply HOVER_HEIGHT = 4.0f offset at floor snap
- application.cpp: sync hover state each frame alongside other movement
  states (gravity, feather fall, water walk, flying)
2026-03-10 13:39:23 -07:00
Kelsi
56ec49f837 physics: sync all server movement speeds to CameraController
Previously only run speed was synced. Now all server-driven movement
speeds are forwarded to the camera controller each frame:
- runSpeedOverride_: server run speed (existing)
- walkSpeedOverride_: server walk speed (Ctrl key movement)
- swimSpeedOverride_: swim speed (Swim Form, Engineering fins)
- flightSpeedOverride_: flight speed (epic vs normal flying mounts)
- runBackSpeedOverride_: back-pedal speed

Each uses the server value when non-zero/sane, falling back to the
hardcoded WoW default constant otherwise.
2026-03-10 13:28:53 -07:00
Kelsi
a1ee9827d8 physics: apply server flight speed to flying mount movement
serverFlightSpeed_ (from SMSG_FORCE_FLIGHT_SPEED_CHANGE) was stored but
never synced to CameraController. Add getServerFlightSpeed() accessor,
flightSpeedOverride_ field, and use it in the flying physics path so
normal vs epic flying mounts actually move at their correct speeds.
2026-03-10 13:25:10 -07:00
Kelsi
27d18b2189 physics: implement player-controlled flying mount physics
When CAN_FLY + FLYING movement flags are both set (flying mounts, Druid
Flight Form), the CameraController now uses 3D pitch-following movement
instead of ground physics:
- Forward/back follows the camera's 3D look direction (ascend when
  looking up, descend when looking down)
- Space = ascend vertically, X (while mounted) = descend
- No gravity, no grounding, no jump coyote time
- Fall-damage checks suppressed (grounded=true)

Also wire up all remaining server movement state flags to CameraController:
- Feather Fall: cap terminal velocity at -2 m/s
- Water Walk: clamp to water surface, skip swim entry
- Flying: 3D movement with no gravity

All states synced each frame from GameHandler via isPlayerFlying(),
isFeatherFalling(), isWaterWalking(), isGravityDisabled().
2026-03-10 13:23:38 -07:00
Kelsi
1853e8aa56 physics: implement Water Walk movement state tracking and surface clamping
SMSG_MOVE_WATER_WALK / SMSG_MOVE_LAND_WALK now correctly set/clear
WATER_WALK (0x00008000) in movementInfo.flags, ensuring the flag is
included in movement ACKs sent to the server.

In CameraController, when waterWalkActive_ is set and the player is
at or above the water surface (within 0.5 units), clamp them to the
water surface and mark as grounded — preventing water entry and allowing
them to walk across the water surface as the spell intends.
2026-03-10 13:18:04 -07:00
Kelsi
0b99cbafb2 physics: implement feather fall and water walk movement flag tracking
Feather Fall (SMSG_MOVE_FEATHER_FALL / SMSG_MOVE_NORMAL_FALL):
- Add FEATHER_FALL = 0x00004000 to MovementFlags enum
- Fix handlers to set/clear the flag instead of passing flag=0
- Cap downward terminal velocity at -2.0 m/s in CameraController when
  feather fall is active (Slow Fall, Parachute, etc.)

All three handlers now correctly propagate server movement state flags
that were previously acknowledged without updating any local state.
2026-03-10 13:14:52 -07:00
Kelsi
701cb94ba6 physics: apply server walk and swim speed overrides to CameraController
serverWalkSpeed_ and serverSwimSpeed_ were stored in GameHandler but
never exposed or synced to the camera controller. The controller used
hardcoded WOW_WALK_SPEED and speed*SWIM_SPEED_FACTOR regardless of
server-sent speed changes.

Add getServerWalkSpeed()/getServerSwimSpeed() accessors, walkSpeedOverride_
and swimSpeedOverride_ fields in CameraController, and sync all three
server speeds each frame. Both swim speed sites (main and camera-collision
path) now use the override when set. This makes Slow debuffs (walk speed),
Swim Form, and Engineering fins actually affect movement speed.
2026-03-10 13:11:50 -07:00
Kelsi
f2337aeaa7 physics: disable gravity when server sends SMSG_MOVE_GRAVITY_DISABLE
SMSG_MOVE_GRAVITY_DISABLE/ENABLE now correctly set/clear the LEVITATING
movement flag instead of passing flag=0. GameHandler::isGravityDisabled()
reads the LEVITATING bit and is synced to CameraController each frame.

When gravity is disabled the physics loop bleeds off downward velocity
and skips gravity accumulation, so Levitate and similar effects actually
float the player rather than letting them fall through the world.
2026-03-10 13:07:34 -07:00
Kelsi
dd6f6d1174 physics: also block strafe input when movement is rooted 2026-03-10 13:02:35 -07:00
Kelsi
21604461fc physics: block client-side movement when server roots the player
When SMSG_FORCE_MOVE_ROOT sets ROOT in movementInfo.flags, the
camera controller was not aware and continued to accept directional
input. This caused position desync (client moves, server sees player
as rooted).

- Add movementRooted_ flag to CameraController with setter/getter.
- Block nowForward/nowBackward/nowStrafe when movementRooted_ is set.
- Sync isPlayerRooted() from GameHandler to CameraController each
  frame alongside the existing run-speed sync in application.cpp.
- Add GameHandler::isPlayerRooted() convenience accessor.
2026-03-10 13:01:44 -07:00
Kelsi
ea291179dd gameplay: fix talent reset and ignore list population on login
- SMSG_IGNORE_LIST was silently consumed; now parses guid+name pairs to
  populate ignoreCache so /unignore works correctly for pre-existing
  ignores loaded at login.

- MSG_TALENT_WIPE_CONFIRM was discarded without responding; now parses
  the NPC GUID and cost, shows a confirm dialog, and sends the required
  response packet when the player confirms. Without this, talent reset
  via Talent Master NPC was completely broken.
2026-03-10 12:53:05 -07:00
Kelsi
9291637977 movement: fix jumpXYSpeed to be 0 when jumping in place
jumpXYSpeed should reflect actual horizontal movement at jump time:
- non-zero (run/walk speed) only when movement flags indicate forward/
  backward/strafe movement
- zero when jumping straight up without horizontal movement

This prevents the server from thinking the player launched with full run
speed when they jumped in place, which could affect position prediction.
2026-03-10 12:37:53 -07:00
Kelsi
4cf73a6def movement: track fallTime and jump fields in movement packets
Previously movementInfo.fallTime was always 0 and jumpVelocity/jumpSinAngle/
jumpCosAngle/jumpXYSpeed were never populated.  The server reads fallTime
unconditionally from every movement packet and uses it to compute fall damage
and anti-cheat heuristics; the jump fields are required when FALLING is set.

Changes:
- Add isFalling_ / fallStartMs_ to track fall state across packets
- MSG_MOVE_JUMP: set isFalling_=true, record fallStartMs_, populate jump fields
  (jumpVelocity=7.96, direction from facing angle, jumpXYSpeed from server
  run speed or walk speed when WALKING flag is set)
- MSG_MOVE_FALL_LAND: clear all fall/jump fields
- sendMovement: update movementInfo.fallTime = (time - fallStartMs_) each call
  so every heartbeat and position packet carries the correct elapsed fall time
- World entry: reset all fall/jump fields alongside the flag reset
2026-03-10 12:36:56 -07:00
Kelsi
70abb12398 physics: send MSG_MOVE_JUMP on knockback to set FALLING flag correctly
applyKnockBack() sets grounded=false and applies vertical velocity, but
the normal jump detection path (nowJump && !wasJumping && grounded) never
fires during a server-driven knockback because no jump key is pressed.

Without MSG_MOVE_JUMP the game_handler never sets MovementFlags::FALLING
in movementInfo.flags, so all subsequent heartbeat packets carry incorrect
flags — the server sees the player as grounded while airborne.

Fix: fire movementCallback(MSG_MOVE_JUMP) directly from applyKnockBack()
so the FALLING flag is set immediately. MSG_MOVE_FALL_LAND is already sent
when grounded becomes true again (the existing wasFalling && grounded path).
2026-03-10 12:30:13 -07:00
Kelsi
c622fde7be physics: implement knockback simulation from SMSG_MOVE_KNOCK_BACK
Previously the handler ACKed with current position and ignored the
velocity fields entirely (vcos/vsin/hspeed/vspeed were [[maybe_unused]]).
The server expects the client to fly through the air on knockback — without
simulation the player stays in place while the server models them as airborne,
causing position desync and rubberbanding.

Changes:
- CameraController: add applyKnockBack(vcos, vsin, hspeed, vspeed)
  that sets knockbackHorizVel_ and launches verticalVelocity = -vspeed
  (server sends vspeed as negative for upward launches, matching TrinityCore)
- Physics loop: each tick adds knockbackHorizVel_ to targetPos then applies
  exponential drag (KNOCKBACK_HORIZ_DRAG=4.5/s) until velocity < 0.05 u/s
- GameHandler: parse all four fields, add KnockBackCallback, call it for
  the local player so the camera controller receives the impulse
- Application: register the callback — routes server knockback to physics

The existing ACK path is unchanged; the server gets position confirmation
as before while the client now actually simulates the trajectory.
2026-03-10 12:28:11 -07:00
Kelsi
dd3f9e5b9e wmo: enable portal culling by default after AABB transform fix
The AABB transform bug (direct min/max transform was wrong for rotated
WMOs) was fixed in a prior commit. Portal culling now uses the correct
world-space AABB computed from all 8 corners, so frustum intersection
is valid.

The AABB-based test is conservative (no portal plane-side check): a
visible portal can only be incorrectly INCLUDED, never EXCLUDED. This
means no geometry can disappear, and any overdraw is handled by the
z-buffer. Enable by default to get the performance benefit inside WMOs
and dungeons.
2026-03-10 12:11:13 -07:00
Kelsi
8856af6b2d lfg: implement CMSG_LFG_SET_BOOT_VOTE and vote-to-kick UI
CMSG_LFG_SET_BOOT_VOTE was defined in the opcode table but never sent.
- Add GameHandler::lfgSetBootVote(bool) which sends the packet
- Fix handleLfgBootProposalUpdate() to set lfgState_=Boot while the
  vote is in progress and return to InDungeon when it ends
- Add Yes/No vote buttons to the Dungeon Finder window when in Boot state
2026-03-10 12:08:58 -07:00
Kelsi
acbfe99401 anim: also trigger animation update on walk/run transitions for creatures
Extend the locomotion state-change detection to include the WALKING
movement flag. Previously a creature that switched from walking to
running (or vice versa) while staying in the moving state would keep
playing the wrong animation because only the moving/idle transition
was tracked.

Add creatureWasWalking_ alongside creatureWasSwimming_ and
creatureWasFlying_; guard the walking check with isMovingNow to avoid
spurious triggers when the flag flips while the creature is idle.
Clear and erase the new map at world reset and creature/player despawn.
2026-03-10 12:04:59 -07:00
Kelsi
2717018631 anim: fix creature animation not updating on swim/fly state transitions
Previously, the animation update for other entities (creatures, players)
was only triggered when the moving/idle state changed. This meant a
creature landing while still moving would stay in FlyForward instead of
switching to Run, and a flying-idle creature touching down would keep
the FlyIdle animation instead of returning to Stand.

Fix: track creatureWasSwimming_ and creatureWasFlying_ alongside
creatureWasMoving_, and fire the animation update whenever any of the
three locomotion flags change. Clean up the new maps on world reset and
on per-creature despawn.
2026-03-10 12:03:33 -07:00
Kelsi
8a20ccb69d anim: fix fly animation IDs to 158/159 (FlyIdle/FlyForward) 2026-03-10 11:58:19 -07:00
Kelsi
30a65320fb anim: add flying state tracking and Fly/FlyIdle animation selection for entities
Previously the move-flags callback only tracked SWIMMING and WALKING,
so flying players/mounts always played Run(5) or Stand(0) animations
instead of Fly(61)/FlyIdle(60).

Changes:
- Add creatureFlyingState_ (mirroring creatureSwimmingState_) set by
  the FLYING flag (0x01000000) in unitMoveFlagsCallback_.
- Update animation selection: moving+flying → 61 (Fly/FlyForward),
  idle+flying → 60 (FlyIdle/hover). Flying takes priority over swim
  in the priority chain: fly > swim > walk > run.
- Clear creatureFlyingState_ on world reset.
2026-03-10 11:56:50 -07:00
Kelsi
a33119c070 net: dispatch MSG_MOVE_ROOT and MSG_MOVE_UNROOT for other entities 2026-03-10 11:54:15 -07:00
Kelsi
1180f0227c rendering: fix WMO portal AABB transform for rotated WMOs
isPortalVisible() was computing the world-space AABB by directly
transforming pMin/pMax with the model matrix. This is incorrect for
rotated WMOs — when the model matrix includes rotations, components can
be swapped or negated, yielding an inverted AABB (worldMin.x >
worldMax.x) that causes frustum.intersectsAABB() to fail.

Fix: transform all 8 corners of the portal bounding box and take the
component-wise min/max, which gives the correct world-space AABB for any
rotation/scale. This was the root cause of portals being incorrectly
culled in rotated WMO instances (e.g. many dungeon and city WMOs).

Also squash the earlier spline-speed no-op fix (parse guid + float
instead of consuming the full packet for SMSG_SPLINE_SET_FLIGHT_SPEED
and friends) into this commit.
2026-03-10 11:51:43 -07:00
Kelsi
8152314ba8 net: dispatch MSG_MOVE_SET_PITCH, GRAVITY_CHNG, UPDATE_CAN_FLY, UPDATE_CAN_TRANSITION_SWIM_FLY
These four movement-broadcast opcodes (server relaying another player's
movement packet) were not dispatched at all, causing nearby entity
positions to be silently dropped for pitch changes and gravity/fly state
broadcasts. Also add them to the kMoveOpcodes batch-parse table used by
SMSG_COMPRESSED_MOVES, and parse SMSG_SPLINE_SET_FLIGHT/WALK/etc. speeds
properly instead of consuming the whole packet.
2026-03-10 11:44:57 -07:00
Kelsi
cfc6dc37c8 net: fix SMSG_SPLINE_MOVE_UNSET_FLYING and parse UNROOT/UNSET_HOVER/WATER_WALK
Previously these four spline-move opcodes were silently consumed with
packet.setReadPos(getSize()), skipping even the packed-GUID read.

- SMSG_SPLINE_MOVE_UNSET_FLYING: now reads packed guid and fires
  unitMoveFlagsCallback_(guid, 0) to clear the flying animation state on
  nearby entities (counterpart to SMSG_SPLINE_MOVE_SET_FLYING).
- SMSG_SPLINE_MOVE_UNROOT, SMSG_SPLINE_MOVE_UNSET_HOVER,
  SMSG_SPLINE_MOVE_WATER_WALK: now properly parse the packed guid instead
  of consuming the full packet; no animation-state callback needed.
2026-03-10 11:42:54 -07:00
Kelsi
84558fda69 net: ack SMSG_MOVE_SET/UNSET_CAN_TRANSITION_SWIM_FLY and SMSG_MOVE_SET_COLLISION_HGT
These three server-push opcodes were silently consumed without sending
the required client acks, causing the server to stall waiting for
confirmation before granting the capability.

- SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY →
  CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK (via handleForceMoveFlagChange)
- SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY →
  same ack opcode (no separate unset ack exists in WotLK 3.3.5a)
- SMSG_MOVE_SET_COLLISION_HGT → CMSG_MOVE_SET_COLLISION_HGT_ACK via new
  handleMoveSetCollisionHeight() which appends the float height after the
  standard movement block (required by server-side ack validation)
2026-03-10 11:40:46 -07:00
Kelsi
c72186fd11 net: ack SMSG_MOVE_GRAVITY_DISABLE/ENABLE and fix fall-through bug
These opcodes were inadvertently falling through to the LAND_WALK
handler (same case label), causing incorrect CMSG_MOVE_WATER_WALK_ACK
acks to be sent for gravity changes. Split into dedicated cases that
send CMSG_MOVE_GRAVITY_DISABLE_ACK and CMSG_MOVE_GRAVITY_ENABLE_ACK
respectively, as required by the server protocol.
2026-03-10 11:36:06 -07:00
Kelsi
b3441ee9ce net: ack SMSG_MOVE_LAND_WALK and SMSG_MOVE_NORMAL_FALL
These are the removal counterparts to SMSG_MOVE_WATER_WALK and
SMSG_MOVE_FEATHER_FALL. The server expects the matching ack with the
flag cleared; previously these packets were consumed silently which
could leave the server's state machine waiting for an acknowledgement.
2026-03-10 11:34:56 -07:00
Kelsi
ca141bb131 net: send CMSG_MOVE_FLIGHT_ACK in response to SMSG_MOVE_SET/UNSET_FLIGHT
SMSG_MOVE_SET_FLIGHT and SMSG_MOVE_UNSET_FLIGHT were previously consumed
silently without sending the required ack. Most server implementations
expect CMSG_MOVE_FLIGHT_ACK before toggling the FLYING movement flag on
the player; without it the server may not grant or revoke flight state.
Also updates movementInfo.flags so subsequent movement packets reflect
the FLYING flag correctly.
2026-03-10 11:33:47 -07:00
Kelsi
71cabddbd6 net: add MSG_MOVE_START_DESCEND to other-player movement dispatch
The complement to MSG_MOVE_START_ASCEND was missing from both the
main dispatch switch and the compressed-moves opcode table, causing
downward vertical movement of flying players to be dropped.
2026-03-10 11:30:55 -07:00
29 changed files with 2138 additions and 363 deletions

View file

@ -1,8 +1,10 @@
{ {
"Spell": { "Spell": {
"ID": 0, "Attributes": 5, "IconID": 117, "ID": 0, "Attributes": 5, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33
}, },
"SpellRange": { "MaxRange": 2 },
"ItemDisplayInfo": { "ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "ID": 0, "LeftModel": 1, "LeftModelTexture": 3,
"InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9,

View file

@ -34,6 +34,8 @@
"PLAYER_END": 1282, "PLAYER_END": 1282,
"GAMEOBJECT_DISPLAYID": 8, "GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 48,
"ITEM_FIELD_MAXDURABILITY": 49,
"CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_NUM_SLOTS": 48,
"CONTAINER_FIELD_SLOT_1": 50 "CONTAINER_FIELD_SLOT_1": 50
} }

View file

@ -1,8 +1,10 @@
{ {
"Spell": { "Spell": {
"ID": 0, "Attributes": 5, "IconID": 124, "ID": 0, "Attributes": 5, "IconID": 124,
"Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215,
"CastingTimeIndex": 22, "PowerType": 35, "ManaCost": 36, "RangeIndex": 40
}, },
"SpellRange": { "MaxRange": 4 },
"ItemDisplayInfo": { "ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "ID": 0, "LeftModel": 1, "LeftModelTexture": 3,
"InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9,

View file

@ -33,6 +33,8 @@
"PLAYER_EXPLORED_ZONES_START": 1312, "PLAYER_EXPLORED_ZONES_START": 1312,
"GAMEOBJECT_DISPLAYID": 8, "GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 60,
"ITEM_FIELD_MAXDURABILITY": 61,
"CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_NUM_SLOTS": 64,
"CONTAINER_FIELD_SLOT_1": 66 "CONTAINER_FIELD_SLOT_1": 66
} }

View file

@ -1,8 +1,10 @@
{ {
"Spell": { "Spell": {
"ID": 0, "Attributes": 5, "IconID": 117, "ID": 0, "Attributes": 5, "IconID": 117,
"Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1,
"CastingTimeIndex": 15, "PowerType": 28, "ManaCost": 29, "RangeIndex": 33
}, },
"SpellRange": { "MaxRange": 2 },
"ItemDisplayInfo": { "ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "ID": 0, "LeftModel": 1, "LeftModelTexture": 3,
"InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9,

View file

@ -34,6 +34,8 @@
"PLAYER_END": 1282, "PLAYER_END": 1282,
"GAMEOBJECT_DISPLAYID": 8, "GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 48,
"ITEM_FIELD_MAXDURABILITY": 49,
"CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_NUM_SLOTS": 48,
"CONTAINER_FIELD_SLOT_1": 50 "CONTAINER_FIELD_SLOT_1": 50
} }

View file

@ -1,8 +1,10 @@
{ {
"Spell": { "Spell": {
"ID": 0, "Attributes": 4, "IconID": 133, "ID": 0, "Attributes": 4, "IconID": 133,
"Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225 "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225,
"PowerType": 14, "ManaCost": 39, "CastingTimeIndex": 47, "RangeIndex": 49
}, },
"SpellRange": { "MaxRange": 4 },
"ItemDisplayInfo": { "ItemDisplayInfo": {
"ID": 0, "LeftModel": 1, "LeftModelTexture": 3, "ID": 0, "LeftModel": 1, "LeftModelTexture": 3,
"InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9,

View file

@ -33,6 +33,8 @@
"PLAYER_EXPLORED_ZONES_START": 1041, "PLAYER_EXPLORED_ZONES_START": 1041,
"GAMEOBJECT_DISPLAYID": 8, "GAMEOBJECT_DISPLAYID": 8,
"ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_STACK_COUNT": 14,
"ITEM_FIELD_DURABILITY": 60,
"ITEM_FIELD_MAXDURABILITY": 61,
"CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_NUM_SLOTS": 64,
"CONTAINER_FIELD_SLOT_1": 66 "CONTAINER_FIELD_SLOT_1": 66
} }

View file

@ -66,7 +66,7 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**.
- **Gossip** -- NPC interaction, dialogue options - **Gossip** -- NPC interaction, dialogue options
- **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips - **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips
- **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS - **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS
- **Pets** -- Pet tracking via SMSG_PET_SPELLS, dismiss pet button - **Pets** -- Pet tracking via SMSG_PET_SPELLS, action bar (10 slots with icon/autocast tinting/tooltips), dismiss button
- **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior - **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior
- **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine) - **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine)
- **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) - **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view)

View file

@ -187,9 +187,13 @@ private:
std::unordered_map<uint64_t, uint32_t> creatureInstances_; // guid → render instanceId std::unordered_map<uint64_t, uint32_t> creatureInstances_; // guid → render instanceId
std::unordered_map<uint64_t, uint32_t> creatureModelIds_; // guid → loaded modelId std::unordered_map<uint64_t, uint32_t> creatureModelIds_; // guid → loaded modelId
std::unordered_map<uint64_t, glm::vec3> creatureRenderPosCache_; // guid -> last synced render position std::unordered_map<uint64_t, glm::vec3> creatureRenderPosCache_; // guid -> last synced render position
std::unordered_map<uint64_t, bool> creatureWasMoving_; // guid -> previous-frame movement state std::unordered_map<uint64_t, bool> creatureWasMoving_; // guid -> previous-frame movement state
std::unordered_map<uint64_t, bool> creatureWasSwimming_; // guid -> previous-frame swim state (for anim transition detection)
std::unordered_map<uint64_t, bool> creatureWasFlying_; // guid -> previous-frame flying state (for anim transition detection)
std::unordered_map<uint64_t, bool> creatureWasWalking_; // guid -> previous-frame walking state (walk vs run transition detection)
std::unordered_map<uint64_t, bool> creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) std::unordered_map<uint64_t, bool> creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag)
std::unordered_map<uint64_t, bool> creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) std::unordered_map<uint64_t, bool> creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5))
std::unordered_map<uint64_t, bool> creatureFlyingState_; // guid -> currently flying (FLYING flag)
std::unordered_set<uint64_t> creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_set<uint64_t> creatureWeaponsAttached_; // guid set when NPC virtual weapons attached
std::unordered_map<uint64_t, uint8_t> creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map<uint64_t, uint8_t> creatureWeaponAttachAttempts_; // guid -> attach attempts
std::unordered_map<uint32_t, bool> modelIdIsWolfLike_; // modelId → cached wolf/worg check std::unordered_map<uint32_t, bool> modelIdIsWolfLike_; // modelId → cached wolf/worg check

View file

@ -588,10 +588,14 @@ public:
const std::unordered_map<uint32_t, TalentTabEntry>& getAllTalentTabs() const { return talentTabCache_; } const std::unordered_map<uint32_t, TalentTabEntry>& getAllTalentTabs() const { return talentTabCache_; }
void loadTalentDbc(); void loadTalentDbc();
// Action bar — 2 bars × 12 slots = 24 total // Action bar — 4 bars × 12 slots = 48 total
// Bar 0 (slots 0-11): main bottom bar (1-0, -, =)
// Bar 1 (slots 12-23): second bar above main (Shift+1 ... Shift+=)
// Bar 2 (slots 24-35): right side vertical bar
// Bar 3 (slots 36-47): left side vertical bar
static constexpr int SLOTS_PER_BAR = 12; static constexpr int SLOTS_PER_BAR = 12;
static constexpr int ACTION_BARS = 2; static constexpr int ACTION_BARS = 4;
static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24 static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 48
std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() { return actionBar; } std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() { return actionBar; }
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; } const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
@ -693,6 +697,11 @@ public:
using WorldEntryCallback = std::function<void(uint32_t mapId, float x, float y, float z, bool isInitialEntry)>; using WorldEntryCallback = std::function<void(uint32_t mapId, float x, float y, float z, bool isInitialEntry)>;
void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); }
// Knockback callback: called when server sends SMSG_MOVE_KNOCK_BACK for the player.
// Parameters: vcos, vsin (render-space direction), hspeed, vspeed (raw from packet).
using KnockBackCallback = std::function<void(float vcos, float vsin, float hspeed, float vspeed)>;
void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); }
// Unstuck callback (resets player Z to floor height) // Unstuck callback (resets player Z to floor height)
using UnstuckCallback = std::function<void()>; using UnstuckCallback = std::function<void()>;
void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); }
@ -818,6 +827,22 @@ public:
// Player GUID // Player GUID
uint64_t getPlayerGuid() const { return playerGuid; } uint64_t getPlayerGuid() const { return playerGuid; }
// Look up a display name for any guid: checks playerNameCache then entity manager.
// Returns empty string if unknown. Used by chat display to resolve names at render time.
const std::string& lookupName(uint64_t guid) const {
static const std::string kEmpty;
auto it = playerNameCache.find(guid);
if (it != playerNameCache.end()) return it->second;
auto entity = entityManager.getEntity(guid);
if (entity) {
if (auto* unit = dynamic_cast<const Unit*>(entity.get())) {
if (!unit->getName().empty()) return unit->getName();
}
}
return kEmpty;
}
uint8_t getPlayerClass() const { uint8_t getPlayerClass() const {
const Character* ch = getActiveCharacter(); const Character* ch = getActiveCharacter();
return ch ? static_cast<uint8_t>(ch->characterClass) : 0; return ch ? static_cast<uint8_t>(ch->characterClass) : 0;
@ -830,6 +855,10 @@ public:
bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; }
bool showResurrectDialog() const { return resurrectRequestPending_; } bool showResurrectDialog() const { return resurrectRequestPending_; }
const std::string& getResurrectCasterName() const { return resurrectCasterName_; } const std::string& getResurrectCasterName() const { return resurrectCasterName_; }
bool showTalentWipeConfirmDialog() const { return talentWipePending_; }
uint32_t getTalentWipeCost() const { return talentWipeCost_; }
void confirmTalentWipe();
void cancelTalentWipe() { talentWipePending_ = false; }
/** True when ghost is within 40 yards of corpse position (same map). */ /** True when ghost is within 40 yards of corpse position (same map). */
bool canReclaimCorpse() const; bool canReclaimCorpse() const;
/** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */
@ -944,6 +973,7 @@ public:
void lfgJoin(uint32_t dungeonId, uint8_t roles); void lfgJoin(uint32_t dungeonId, uint8_t roles);
void lfgLeave(); void lfgLeave();
void lfgAcceptProposal(uint32_t proposalId, bool accept); void lfgAcceptProposal(uint32_t proposalId, bool accept);
void lfgSetBootVote(bool vote);
void lfgTeleport(bool toLfgDungeon = true); void lfgTeleport(bool toLfgDungeon = true);
LfgState getLfgState() const { return lfgState_; } LfgState getLfgState() const { return lfgState_; }
bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } bool isLfgQueued() const { return lfgState_ == LfgState::Queued; }
@ -1142,6 +1172,39 @@ public:
bool isMounted() const { return currentMountDisplayId_ != 0; } bool isMounted() const { return currentMountDisplayId_ != 0; }
bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; }
float getServerRunSpeed() const { return serverRunSpeed_; } float getServerRunSpeed() const { return serverRunSpeed_; }
float getServerWalkSpeed() const { return serverWalkSpeed_; }
float getServerSwimSpeed() const { return serverSwimSpeed_; }
float getServerSwimBackSpeed() const { return serverSwimBackSpeed_; }
float getServerFlightSpeed() const { return serverFlightSpeed_; }
float getServerFlightBackSpeed() const { return serverFlightBackSpeed_; }
float getServerRunBackSpeed() const { return serverRunBackSpeed_; }
float getServerTurnRate() const { return serverTurnRate_; }
bool isPlayerRooted() const {
return (movementInfo.flags & static_cast<uint32_t>(MovementFlags::ROOT)) != 0;
}
bool isGravityDisabled() const {
return (movementInfo.flags & static_cast<uint32_t>(MovementFlags::LEVITATING)) != 0;
}
bool isFeatherFalling() const {
return (movementInfo.flags & static_cast<uint32_t>(MovementFlags::FEATHER_FALL)) != 0;
}
bool isWaterWalking() const {
return (movementInfo.flags & static_cast<uint32_t>(MovementFlags::WATER_WALK)) != 0;
}
bool isPlayerFlying() const {
const uint32_t flyMask = static_cast<uint32_t>(MovementFlags::CAN_FLY) |
static_cast<uint32_t>(MovementFlags::FLYING);
return (movementInfo.flags & flyMask) == flyMask;
}
bool isHovering() const {
return (movementInfo.flags & static_cast<uint32_t>(MovementFlags::HOVER)) != 0;
}
bool isSwimming() const {
return (movementInfo.flags & static_cast<uint32_t>(MovementFlags::SWIMMING)) != 0;
}
// Set the character pitch angle (radians) for movement packets (flight / swimming).
// Positive = nose up, negative = nose down.
void setMovementPitch(float radians) { movementInfo.pitch = radians; }
void dismount(); void dismount();
// Taxi / Flight Paths // Taxi / Flight Paths
@ -1195,6 +1258,8 @@ public:
uint32_t count = 1; uint32_t count = 1;
}; };
void buyBackItem(uint32_t buybackSlot); void buyBackItem(uint32_t buybackSlot);
void repairItem(uint64_t vendorGuid, uint64_t itemGuid);
void repairAll(uint64_t vendorGuid, bool useGuildBank = false);
const std::deque<BuybackItem>& getBuybackItems() const { return buybackItems_; } const std::deque<BuybackItem>& getBuybackItems() const { return buybackItems_; }
void autoEquipItemBySlot(int backpackIndex); void autoEquipItemBySlot(int backpackIndex);
void autoEquipItemInBag(int bagIndex, int slotIndex); void autoEquipItemInBag(int bagIndex, int slotIndex);
@ -1206,6 +1271,7 @@ public:
void useItemById(uint32_t itemId); void useItemById(uint32_t itemId);
bool isVendorWindowOpen() const { return vendorWindowOpen; } bool isVendorWindowOpen() const { return vendorWindowOpen; }
const ListInventoryData& getVendorItems() const { return currentVendorItems; } const ListInventoryData& getVendorItems() const { return currentVendorItems; }
void setVendorCanRepair(bool v) { currentVendorItems.canRepair = v; }
// Mail // Mail
bool isMailboxOpen() const { return mailboxOpen_; } bool isMailboxOpen() const { return mailboxOpen_; }
@ -1535,6 +1601,7 @@ private:
void handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage); void handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage);
void handleForceMoveRootState(network::Packet& packet, bool rooted); void handleForceMoveRootState(network::Packet& packet, bool rooted);
void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set); void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set);
void handleMoveSetCollisionHeight(network::Packet& packet);
void handleMoveKnockBack(network::Packet& packet); void handleMoveKnockBack(network::Packet& packet);
// ---- Area trigger detection ---- // ---- Area trigger detection ----
@ -1679,6 +1746,12 @@ private:
uint32_t lastMovementTimestampMs_ = 0; uint32_t lastMovementTimestampMs_ = 0;
bool serverMovementAllowed_ = true; bool serverMovementAllowed_ = true;
// Fall/jump tracking for movement packet correctness.
// fallTime must be the elapsed ms since the FALLING flag was set; the server
// uses it for fall-damage calculations and anti-cheat validation.
bool isFalling_ = false;
uint32_t fallStartMs_ = 0; // movementInfo.time value when FALLING started
// Inventory // Inventory
Inventory inventory; Inventory inventory;
@ -1761,6 +1834,8 @@ private:
struct OnlineItemInfo { struct OnlineItemInfo {
uint32_t entry = 0; uint32_t entry = 0;
uint32_t stackCount = 1; uint32_t stackCount = 1;
uint32_t curDurability = 0;
uint32_t maxDurability = 0;
}; };
std::unordered_map<uint64_t, OnlineItemInfo> onlineItems_; std::unordered_map<uint64_t, OnlineItemInfo> onlineItems_;
std::unordered_map<uint32_t, ItemQueryResponseData> itemInfoCache_; std::unordered_map<uint32_t, ItemQueryResponseData> itemInfoCache_;
@ -1810,6 +1885,7 @@ private:
// ---- Phase 3: Spells ---- // ---- Phase 3: Spells ----
WorldEntryCallback worldEntryCallback_; WorldEntryCallback worldEntryCallback_;
KnockBackCallback knockBackCallback_;
UnstuckCallback unstuckCallback_; UnstuckCallback unstuckCallback_;
UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckGyCallback_;
UnstuckCallback unstuckHearthCallback_; UnstuckCallback unstuckHearthCallback_;
@ -2312,6 +2388,10 @@ private:
uint64_t pendingSpiritHealerGuid_ = 0; uint64_t pendingSpiritHealerGuid_ = 0;
bool resurrectPending_ = false; bool resurrectPending_ = false;
bool resurrectRequestPending_ = false; bool resurrectRequestPending_ = false;
// ---- Talent wipe confirm dialog ----
bool talentWipePending_ = false;
uint64_t talentWipeNpcGuid_ = 0;
uint32_t talentWipeCost_ = 0;
bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST
uint64_t resurrectCasterGuid_ = 0; uint64_t resurrectCasterGuid_ = 0;
std::string resurrectCasterName_; std::string resurrectCasterName_;

View file

@ -3,6 +3,7 @@
#include <cstdint> #include <cstdint>
#include <string> #include <string>
#include <array> #include <array>
#include <vector>
namespace wowee { namespace wowee {
namespace game { namespace game {
@ -46,6 +47,16 @@ struct ItemDef {
int32_t spirit = 0; int32_t spirit = 0;
uint32_t displayInfoId = 0; uint32_t displayInfoId = 0;
uint32_t sellPrice = 0; uint32_t sellPrice = 0;
uint32_t curDurability = 0;
uint32_t maxDurability = 0;
uint32_t itemLevel = 0;
uint32_t requiredLevel = 0;
uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ
std::string description; // Flavor/lore text shown in tooltip (italic yellow)
// Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.)
struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; };
std::vector<ExtraStat> extraStats;
uint32_t startQuestId = 0; // Non-zero: item begins a quest
}; };
struct ItemSlot { struct ItemSlot {

View file

@ -56,6 +56,8 @@ enum class UF : uint16_t {
// Item fields // Item fields
ITEM_FIELD_STACK_COUNT, ITEM_FIELD_STACK_COUNT,
ITEM_FIELD_DURABILITY,
ITEM_FIELD_MAXDURABILITY,
// Container fields // Container fields
CONTAINER_FIELD_NUM_SLOTS, CONTAINER_FIELD_NUM_SLOTS,

View file

@ -395,6 +395,8 @@ enum class MovementFlags : uint32_t {
ROOT = 0x00000800, ROOT = 0x00000800,
FALLING = 0x00001000, FALLING = 0x00001000,
FALLINGFAR = 0x00002000, FALLINGFAR = 0x00002000,
FEATHER_FALL = 0x00004000, // Slow fall / Parachute
WATER_WALK = 0x00008000, // Walk on water surface
SWIMMING = 0x00200000, SWIMMING = 0x00200000,
ASCENDING = 0x00400000, ASCENDING = 0x00400000,
CAN_FLY = 0x00800000, CAN_FLY = 0x00800000,
@ -613,7 +615,11 @@ enum class ChatType : uint8_t {
MONSTER_WHISPER = 42, MONSTER_WHISPER = 42,
RAID_BOSS_WHISPER = 43, RAID_BOSS_WHISPER = 43,
RAID_BOSS_EMOTE = 44, RAID_BOSS_EMOTE = 44,
MONSTER_PARTY = 50 MONSTER_PARTY = 50,
// BG/Arena system messages (WoW 3.3.5a — no sender, treated as SYSTEM in display)
BG_SYSTEM_NEUTRAL = 82,
BG_SYSTEM_ALLIANCE = 83,
BG_SYSTEM_HORDE = 84
}; };
/** /**
@ -1546,6 +1552,8 @@ struct ItemQueryResponseData {
int32_t intellect = 0; int32_t intellect = 0;
int32_t spirit = 0; int32_t spirit = 0;
uint32_t sellPrice = 0; uint32_t sellPrice = 0;
uint32_t itemLevel = 0;
uint32_t requiredLevel = 0;
std::string subclassName; std::string subclassName;
// Item spells (up to 5) // Item spells (up to 5)
struct ItemSpell { struct ItemSpell {
@ -1553,6 +1561,12 @@ struct ItemQueryResponseData {
uint32_t spellTrigger = 0; // 0=Use, 1=Equip, 2=ChanceOnHit, 5=Learn uint32_t spellTrigger = 0; // 0=Use, 1=Equip, 2=ChanceOnHit, 5=Learn
}; };
std::array<ItemSpell, 5> spells{}; std::array<ItemSpell, 5> spells{};
uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ
std::string description; // Flavor/lore text
// Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.)
struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; };
std::vector<ExtraStat> extraStats;
uint32_t startQuestId = 0; // Non-zero: item begins a quest
bool valid = false; bool valid = false;
}; };
@ -2072,6 +2086,14 @@ public:
static network::Packet build(uint64_t npcGuid, uint32_t questId); static network::Packet build(uint64_t npcGuid, uint32_t questId);
}; };
/** Reward item entry (shared by quest detail/offer windows) */
struct QuestRewardItem {
uint32_t itemId = 0;
uint32_t count = 0;
uint32_t displayInfoId = 0;
uint32_t choiceSlot = 0; // Original reward slot index from server payload
};
/** SMSG_QUESTGIVER_QUEST_DETAILS data (simplified) */ /** SMSG_QUESTGIVER_QUEST_DETAILS data (simplified) */
struct QuestDetailsData { struct QuestDetailsData {
uint64_t npcGuid = 0; uint64_t npcGuid = 0;
@ -2082,6 +2104,8 @@ struct QuestDetailsData {
uint32_t suggestedPlayers = 0; uint32_t suggestedPlayers = 0;
uint32_t rewardMoney = 0; uint32_t rewardMoney = 0;
uint32_t rewardXp = 0; uint32_t rewardXp = 0;
std::vector<QuestRewardItem> rewardChoiceItems; // Player picks one of these
std::vector<QuestRewardItem> rewardItems; // These are always given
}; };
/** SMSG_QUESTGIVER_QUEST_DETAILS parser */ /** SMSG_QUESTGIVER_QUEST_DETAILS parser */
@ -2090,14 +2114,6 @@ public:
static bool parse(network::Packet& packet, QuestDetailsData& data); static bool parse(network::Packet& packet, QuestDetailsData& data);
}; };
/** Reward item entry (shared by quest detail/offer windows) */
struct QuestRewardItem {
uint32_t itemId = 0;
uint32_t count = 0;
uint32_t displayInfoId = 0;
uint32_t choiceSlot = 0; // Original reward slot index from server payload
};
/** SMSG_QUESTGIVER_REQUEST_ITEMS data (turn-in progress check) */ /** SMSG_QUESTGIVER_REQUEST_ITEMS data (turn-in progress check) */
struct QuestRequestItemsData { struct QuestRequestItemsData {
uint64_t npcGuid = 0; uint64_t npcGuid = 0;
@ -2173,6 +2189,7 @@ struct VendorItem {
struct ListInventoryData { struct ListInventoryData {
uint64_t vendorGuid = 0; uint64_t vendorGuid = 0;
std::vector<VendorItem> items; std::vector<VendorItem> items;
bool canRepair = false; // Set when vendor was opened via GOSSIP_OPTION_ARMORER
bool isValid() const { return true; } bool isValid() const { return true; }
}; };

View file

@ -92,6 +92,21 @@ public:
void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); }
void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setUseWoWSpeed(bool use) { useWoWSpeed = use; }
void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; }
void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; }
void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; }
void setSwimBackSpeedOverride(float speed) { swimBackSpeedOverride_ = speed; }
void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; }
void setFlightBackSpeedOverride(float speed) { flightBackSpeedOverride_ = speed; }
void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; }
// Server turn rate in rad/s (SMSG_FORCE_TURN_RATE_CHANGE); 0 = use WOW_TURN_SPEED default
void setTurnRateOverride(float rateRadS) { turnRateOverride_ = rateRadS; }
void setMovementRooted(bool rooted) { movementRooted_ = rooted; }
bool isMovementRooted() const { return movementRooted_; }
void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; }
void setFeatherFallActive(bool active) { featherFallActive_ = active; }
void setWaterWalkActive(bool active) { waterWalkActive_ = active; }
void setFlyingActive(bool active) { flyingActive_ = active; }
void setHoverActive(bool active) { hoverActive_ = active; }
void setMounted(bool m) { mounted_ = m; } void setMounted(bool m) { mounted_ = m; }
void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; }
void setExternalFollow(bool enabled) { externalFollow_ = enabled; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; }
@ -103,6 +118,12 @@ public:
// Trigger mount jump (applies vertical velocity for physics hop) // Trigger mount jump (applies vertical velocity for physics hop)
void triggerMountJump(); void triggerMountJump();
// Apply server-driven knockback impulse.
// dir: render-space 2D direction unit vector (from vcos/vsin in packet)
// hspeed: horizontal speed magnitude (units/s)
// vspeed: raw packet vspeed field (server sends negative for upward launch)
void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed);
// For first-person player hiding // For first-person player hiding
void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) {
characterRenderer = cr; characterRenderer = cr;
@ -235,6 +256,8 @@ private:
bool wasTurningRight = false; bool wasTurningRight = false;
bool wasJumping = false; bool wasJumping = false;
bool wasFalling = false; bool wasFalling = false;
bool wasAscending_ = false; // Space held while flyingActive_
bool wasDescending_ = false; // X held while flyingActive_
bool moveForwardActive = false; bool moveForwardActive = false;
bool moveBackwardActive = false; bool moveBackwardActive = false;
bool strafeLeftActive = false; bool strafeLeftActive = false;
@ -260,8 +283,27 @@ private:
return std::sqrt(2.0f * std::abs(MOUNT_GRAVITY) * MOUNT_JUMP_HEIGHT); return std::sqrt(2.0f * std::abs(MOUNT_GRAVITY) * MOUNT_JUMP_HEIGHT);
} }
// Server-driven run speed override (0 = use default WOW_RUN_SPEED) // Server-driven speed overrides (0 = use hardcoded default)
float runSpeedOverride_ = 0.0f; float runSpeedOverride_ = 0.0f;
float walkSpeedOverride_ = 0.0f;
float swimSpeedOverride_ = 0.0f;
float swimBackSpeedOverride_ = 0.0f;
float flightSpeedOverride_ = 0.0f;
float flightBackSpeedOverride_ = 0.0f;
float runBackSpeedOverride_ = 0.0f;
float turnRateOverride_ = 0.0f; // rad/s; 0 = WOW_TURN_SPEED default (π rad/s)
// Server-driven root state: when true, block all horizontal movement input.
bool movementRooted_ = false;
// Server-driven gravity disable (levitate/hover): skip gravity accumulation.
bool gravityDisabled_ = false;
// Server-driven feather fall: cap downward velocity to slow-fall terminal.
bool featherFallActive_ = false;
// Server-driven water walk: treat water surface as ground (don't swim).
bool waterWalkActive_ = false;
// Player-controlled flight (CAN_FLY + FLYING): 3D movement, no gravity.
bool flyingActive_ = false;
// Server-driven hover (HOVER flag): float at fixed height above ground.
bool hoverActive_ = false;
bool mounted_ = false; bool mounted_ = false;
float mountHeightOffset_ = 0.0f; float mountHeightOffset_ = 0.0f;
bool externalMoving_ = false; bool externalMoving_ = false;
@ -313,6 +355,14 @@ private:
float cachedFloorHeight_ = 0.0f; float cachedFloorHeight_ = 0.0f;
bool hasCachedFloor_ = false; bool hasCachedFloor_ = false;
static constexpr float COLLISION_CACHE_DISTANCE = 0.15f; // Re-check every 15cm static constexpr float COLLISION_CACHE_DISTANCE = 0.15f; // Re-check every 15cm
// Server-driven knockback state.
// When the server sends SMSG_MOVE_KNOCK_BACK, we apply horizontal + vertical
// impulse here and let the normal physics loop (gravity, collision) resolve it.
bool knockbackActive_ = false;
glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s)
// Horizontal velocity decays via WoW-like drag so the player doesn't slide forever.
static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s)
}; };
} // namespace rendering } // namespace rendering

View file

@ -150,7 +150,8 @@ public:
*/ */
/** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */ /** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */
void prepareRender(); void prepareRender();
void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera,
const glm::vec3* viewerPos = nullptr);
/** /**
* Initialize shadow pipeline (Phase 7) * Initialize shadow pipeline (Phase 7)
@ -696,7 +697,7 @@ private:
// Rendering state // Rendering state
bool wireframeMode = false; bool wireframeMode = false;
bool frustumCulling = true; bool frustumCulling = true;
bool portalCulling = false; // Disabled by default - needs debugging bool portalCulling = true; // AABB transform bug fixed; conservative frustum test (no plane-side check) is visually safe
bool distanceCulling = false; // Disabled - causes ground to disappear bool distanceCulling = false; // Disabled - causes ground to disappear
float maxGroupDistance = 500.0f; float maxGroupDistance = 500.0f;
float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2 float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2

View file

@ -112,6 +112,13 @@ private:
bool pendingSeparateBags = true; bool pendingSeparateBags = true;
bool pendingAutoLoot = false; bool pendingAutoLoot = false;
bool pendingUseOriginalSoundtrack = true; bool pendingUseOriginalSoundtrack = true;
bool pendingShowActionBar2 = true; // Show second action bar above main bar
float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position
float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1)
bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35)
bool pendingShowLeftBar = false; // Left-edge vertical action bar (bar 4, slots 36-47)
float pendingRightBarOffsetY = 0.0f; // Vertical offset from screen center
float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center
int pendingGroundClutterDensity = 100; int pendingGroundClutterDensity = 100;
int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x
bool pendingNormalMapping = true; // on by default bool pendingNormalMapping = true; // on by default
@ -232,6 +239,7 @@ private:
void renderDeathScreen(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler);
void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler);
void renderResurrectDialog(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler);
void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler);
void renderEscapeMenu(); void renderEscapeMenu();
void renderSettingsWindow(); void renderSettingsWindow();
void renderQuestMarkers(game::GameHandler& gameHandler); void renderQuestMarkers(game::GameHandler& gameHandler);

View file

@ -25,6 +25,7 @@ struct SpellInfo {
uint32_t manaCost = 0; // Mana cost uint32_t manaCost = 0; // Mana cost
uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy
uint32_t rangeIndex = 0; // Range index from SpellRange.dbc uint32_t rangeIndex = 0; // Range index from SpellRange.dbc
uint32_t schoolMask = 0; // School bitmask (1=phys,2=holy,4=fire,8=nature,16=frost,32=shadow,64=arcane)
bool isPassive() const { return (attributes & 0x40) != 0; } bool isPassive() const { return (attributes & 0x40) != 0; }
}; };
@ -43,6 +44,11 @@ public:
// Spell name lookup — triggers DBC load if needed, used by action bar tooltips // Spell name lookup — triggers DBC load if needed, used by action bar tooltips
std::string lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager); std::string lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager);
// Rich tooltip — renders a full spell tooltip (inside an already-open BeginTooltip block).
// Triggers DBC load if needed. Returns true if spell data was found.
bool renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler,
pipeline::AssetManager* assetManager);
// Drag-and-drop state for action bar assignment // Drag-and-drop state for action bar assignment
bool isDraggingSpell() const { return draggingSpell_; } bool isDraggingSpell() const { return draggingSpell_; }
uint32_t getDragSpellId() const { return dragSpellId_; } uint32_t getDragSpellId() const { return dragSpellId_; }

View file

@ -636,6 +636,11 @@ void Application::setState(AppState newState) {
renderer->triggerMeleeSwing(); renderer->triggerMeleeSwing();
} }
}); });
gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) {
if (renderer && renderer->getCameraController()) {
renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed);
}
});
} }
// Load quest marker models // Load quest marker models
loadQuestMarkerModels(); loadQuestMarkerModels();
@ -750,8 +755,12 @@ void Application::logoutToLogin() {
creatureWeaponsAttached_.clear(); creatureWeaponsAttached_.clear();
creatureWeaponAttachAttempts_.clear(); creatureWeaponAttachAttempts_.clear();
creatureWasMoving_.clear(); creatureWasMoving_.clear();
creatureWasSwimming_.clear();
creatureWasFlying_.clear();
creatureWasWalking_.clear();
creatureSwimmingState_.clear(); creatureSwimmingState_.clear();
creatureWalkingState_.clear(); creatureWalkingState_.clear();
creatureFlyingState_.clear();
deadCreatureGuids_.clear(); deadCreatureGuids_.clear();
nonRenderableCreatureDisplayIds_.clear(); nonRenderableCreatureDisplayIds_.clear();
creaturePermanentFailureGuids_.clear(); creaturePermanentFailureGuids_.clear();
@ -1000,6 +1009,42 @@ void Application::update(float deltaTime) {
runInGameStage("post-update sync", [&] { runInGameStage("post-update sync", [&] {
if (renderer && gameHandler && renderer->getCameraController()) { if (renderer && gameHandler && renderer->getCameraController()) {
renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed());
renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed());
renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed());
renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed());
renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed());
renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed());
renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed());
renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate());
renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted());
renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled());
renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling());
renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking());
renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying());
renderer->getCameraController()->setHoverActive(gameHandler->isHovering());
// Sync camera forward pitch to movement packets during flight / swimming.
// The server writes the pitch field when FLYING or SWIMMING flags are set;
// without this sync it would always be 0 (horizontal), causing other
// players to see the character flying flat even when pitching up/down.
if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) {
if (auto* cam = renderer->getCamera()) {
glm::vec3 fwd = cam->getForward();
float len = glm::length(fwd);
if (len > 1e-4f) {
float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f));
gameHandler->setMovementPitch(pitchRad);
// Tilt the mount/character model to match flight direction
// (taxi flight uses setTaxiOrientationCallback for this instead)
if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) {
renderer->setMountPitchRoll(pitchRad, 0.0f);
}
}
}
} else if (gameHandler->isMounted()) {
// Reset mount pitch when not flying
renderer->setMountPitchRoll(0.0f, 0.0f);
}
} }
bool onTaxi = gameHandler && bool onTaxi = gameHandler &&
@ -1381,14 +1426,20 @@ void Application::update(float deltaTime) {
} }
} }
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); // Distance check uses getLatestX/Y/Z (server-authoritative destination) to
// avoid false-culling entities that moved while getX/Y/Z was stale.
// Position sync still uses getX/Y/Z to preserve smooth interpolation for
// nearby entities; distant entities (> 150u) have planarDist≈0 anyway
// so the renderer remains driven correctly by creatureMoveCallback_.
glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ());
float canonDistSq = 0.0f; float canonDistSq = 0.0f;
if (havePlayerPos) { if (havePlayerPos) {
glm::vec3 d = canonical - playerPos; glm::vec3 d = latestCanonical - playerPos;
canonDistSq = glm::dot(d, d); canonDistSq = glm::dot(d, d);
if (canonDistSq > syncRadiusSq) continue; if (canonDistSq > syncRadiusSq) continue;
} }
glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ());
glm::vec3 renderPos = core::coords::canonicalToRender(canonical); glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
// Visual collision guard: keep hostile melee units from rendering inside the // Visual collision guard: keep hostile melee units from rendering inside the
@ -1469,13 +1520,22 @@ void Application::update(float deltaTime) {
auto unitPtr = std::static_pointer_cast<game::Unit>(entity); auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool deadOrCorpse = unitPtr->getHealth() == 0;
const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f);
const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f); // isEntityMoving() reflects server-authoritative move state set by
// startMoveTo() in handleMonsterMove, regardless of distance-cull.
// This correctly detects movement for distant creatures (> 150u)
// where updateMovement() is not called and getX/Y/Z() stays stale.
const bool entityIsMoving = entity->isEntityMoving();
const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f);
if (deadOrCorpse || largeCorrection) { if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos); charRenderer->setInstancePosition(instanceId, renderPos);
} else if (isMovingNow) { } else if (planarDist > 0.03f || dz > 0.08f) {
// Position changed in entity coords → drive renderer toward it.
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
charRenderer->moveInstanceTo(instanceId, renderPos, duration); charRenderer->moveInstanceTo(instanceId, renderPos, duration);
} }
// When entity is moving but getX/Y/Z is stale (distance-culled),
// don't call moveInstanceTo — creatureMoveCallback_ already drove
// the renderer to the correct destination via the spline packet.
posIt->second = renderPos; posIt->second = renderPos;
// Drive movement animation: Walk/Run/Swim (4/5/42) when moving, // Drive movement animation: Walk/Run/Swim (4/5/42) when moving,
@ -1485,17 +1545,37 @@ void Application::update(float deltaTime) {
// Don't override Death (1) animation. // Don't override Death (1) animation.
const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0;
const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; const bool isWalkingNow = creatureWalkingState_.count(guid) > 0;
bool prevMoving = creatureWasMoving_[guid]; const bool isFlyingNow = creatureFlyingState_.count(guid) > 0;
if (isMovingNow != prevMoving) { bool prevMoving = creatureWasMoving_[guid];
creatureWasMoving_[guid] = isMovingNow; bool prevSwimming = creatureWasSwimming_[guid];
bool prevFlying = creatureWasFlying_[guid];
bool prevWalking = creatureWasWalking_[guid];
// Trigger animation update on any locomotion-state transition, not just
// moving/idle — e.g. creature lands while still moving → FlyForward→Run,
// or server changes WALKING flag while creature is already running → Walk.
const bool stateChanged = (isMovingNow != prevMoving) ||
(isSwimmingNow != prevSwimming) ||
(isFlyingNow != prevFlying) ||
(isWalkingNow != prevWalking && isMovingNow);
if (stateChanged) {
creatureWasMoving_[guid] = isMovingNow;
creatureWasSwimming_[guid] = isSwimmingNow;
creatureWasFlying_[guid] = isFlyingNow;
creatureWasWalking_[guid] = isWalkingNow;
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) { if (!gotState || curAnimId != 1 /*Death*/) {
uint32_t targetAnim; uint32_t targetAnim;
if (isMovingNow) if (isMovingNow) {
targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run if (isFlyingNow) targetAnim = 159u; // FlyForward
else else if (isSwimmingNow) targetAnim = 42u; // Swim
targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand else if (isWalkingNow) targetAnim = 4u; // Walk
else targetAnim = 5u; // Run
} else {
if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover)
else if (isSwimmingNow) targetAnim = 41u; // SwimIdle
else targetAnim = 0u; // Stand
}
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
} }
} }
@ -2810,10 +2890,13 @@ void Application::setupUICallbacks() {
gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) { gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) {
const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0; const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0; const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
const bool isFlying = (moveFlags & static_cast<uint32_t>(game::MovementFlags::FLYING)) != 0;
if (isSwimming) creatureSwimmingState_[guid] = true; if (isSwimming) creatureSwimmingState_[guid] = true;
else creatureSwimmingState_.erase(guid); else creatureSwimmingState_.erase(guid);
if (isWalking) creatureWalkingState_[guid] = true; if (isWalking) creatureWalkingState_[guid] = true;
else creatureWalkingState_.erase(guid); else creatureWalkingState_.erase(guid);
if (isFlying) creatureFlyingState_[guid] = true;
else creatureFlyingState_.erase(guid);
}); });
// Emote animation callback — play server-driven emote animations on NPCs and other players // Emote animation callback — play server-driven emote animations on NPCs and other players
@ -6934,6 +7017,11 @@ void Application::despawnOnlinePlayer(uint64_t guid) {
pendingOnlinePlayerEquipment_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid);
creatureSwimmingState_.erase(guid); creatureSwimmingState_.erase(guid);
creatureWalkingState_.erase(guid); creatureWalkingState_.erase(guid);
creatureFlyingState_.erase(guid);
creatureWasMoving_.erase(guid);
creatureWasSwimming_.erase(guid);
creatureWasFlying_.erase(guid);
creatureWasWalking_.erase(guid);
} }
void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
@ -8527,8 +8615,12 @@ void Application::despawnOnlineCreature(uint64_t guid) {
creatureWeaponsAttached_.erase(guid); creatureWeaponsAttached_.erase(guid);
creatureWeaponAttachAttempts_.erase(guid); creatureWeaponAttachAttempts_.erase(guid);
creatureWasMoving_.erase(guid); creatureWasMoving_.erase(guid);
creatureWasSwimming_.erase(guid);
creatureWasFlying_.erase(guid);
creatureWasWalking_.erase(guid);
creatureSwimmingState_.erase(guid); creatureSwimmingState_.erase(guid);
creatureWalkingState_.erase(guid); creatureWalkingState_.erase(guid);
creatureFlyingState_.erase(guid);
LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec);
} }

View file

@ -1530,10 +1530,22 @@ void GameHandler::handlePacket(network::Packet& packet) {
// Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead)
handleFriendList(packet); handleFriendList(packet);
break; break;
case Opcode::SMSG_IGNORE_LIST: case Opcode::SMSG_IGNORE_LIST: {
// Ignore list: consume to avoid spurious warnings; not parsed. // uint8 count + count × (uint64 guid + string name)
packet.setReadPos(packet.getSize()); // Populate ignoreCache so /unignore works for pre-existing ignores.
if (packet.getSize() - packet.getReadPos() < 1) break;
uint8_t ignCount = packet.readUInt8();
for (uint8_t i = 0; i < ignCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 8) break;
uint64_t ignGuid = packet.readUInt64();
std::string ignName = packet.readString();
if (!ignName.empty() && ignGuid != 0) {
ignoreCache[ignName] = ignGuid;
}
}
LOG_DEBUG("SMSG_IGNORE_LIST: loaded ", (int)ignCount, " ignored players");
break; break;
}
case Opcode::MSG_RANDOM_ROLL: case Opcode::MSG_RANDOM_ROLL:
if (state == WorldState::IN_WORLD) { if (state == WorldState::IN_WORLD) {
@ -2389,9 +2401,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint64_t guid = UpdateObjectParser::readPackedGuid(packet); uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break; if (packet.getSize() - packet.getReadPos() < 4) break;
float speed = packet.readFloat(); float speed = packet.readFloat();
if (guid == playerGuid && std::isfinite(speed) && speed > 0.1f && speed < 100.0f && if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) {
*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) { if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED)
serverRunSpeed_ = speed; serverRunSpeed_ = speed;
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED)
serverRunBackSpeed_ = speed;
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_SPEED)
serverSwimSpeed_ = speed;
} }
break; break;
} }
@ -2443,10 +2459,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
static_cast<uint32_t>(MovementFlags::CAN_FLY), false); static_cast<uint32_t>(MovementFlags::CAN_FLY), false);
break; break;
case Opcode::SMSG_MOVE_FEATHER_FALL: case Opcode::SMSG_MOVE_FEATHER_FALL:
handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, true); handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK,
static_cast<uint32_t>(MovementFlags::FEATHER_FALL), true);
break; break;
case Opcode::SMSG_MOVE_WATER_WALK: case Opcode::SMSG_MOVE_WATER_WALK:
handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, true); handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK,
static_cast<uint32_t>(MovementFlags::WATER_WALK), true);
break; break;
case Opcode::SMSG_MOVE_SET_HOVER: case Opcode::SMSG_MOVE_SET_HOVER:
handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK, handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK,
@ -3385,15 +3403,28 @@ void GameHandler::handlePacket(network::Packet& packet) {
} }
case Opcode::SMSG_ACTION_BUTTONS: { case Opcode::SMSG_ACTION_BUTTONS: {
// uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons
// packed: bits 0-23 = actionId, bits 24-31 = type // packed: bits 0-23 = actionId, bits 24-31 = type
// 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip)
// Format differences:
// Classic 1.12: no mode byte, 120 slots (480 bytes)
// TBC 2.4.3: no mode byte, 132 slots (528 bytes)
// WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes)
size_t rem = packet.getSize() - packet.getReadPos(); size_t rem = packet.getSize() - packet.getReadPos();
if (rem < 1) break; const bool hasModeByteExp = isActiveExpansion("wotlk");
/*uint8_t mode =*/ packet.readUInt8(); int serverBarSlots;
rem--; if (isClassicLikeExpansion()) {
constexpr int SERVER_BAR_SLOTS = 144; serverBarSlots = 120;
for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { } else if (isActiveExpansion("tbc")) {
serverBarSlots = 132;
} else {
serverBarSlots = 144;
}
if (hasModeByteExp) {
if (rem < 1) break;
/*uint8_t mode =*/ packet.readUInt8();
rem--;
}
for (int i = 0; i < serverBarSlots; ++i) {
if (rem < 4) break; if (rem < 4) break;
uint32_t packed = packet.readUInt32(); uint32_t packed = packet.readUInt32();
rem -= 4; rem -= 4;
@ -3585,10 +3616,29 @@ void GameHandler::handlePacket(network::Packet& packet) {
uint8_t error = packet.readUInt8(); uint8_t error = packet.readUInt8();
if (error != 0) { if (error != 0) {
LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error); LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error);
// After error byte: item_guid1(8) + item_guid2(8) + bag_slot(1) = 17 bytes
uint32_t requiredLevel = 0;
if (packet.getSize() - packet.getReadPos() >= 17) {
packet.readUInt64(); // item_guid1
packet.readUInt64(); // item_guid2
packet.readUInt8(); // bag_slot
// Error 1 = EQUIP_ERR_LEVEL_REQ: server appends required level as uint32
if (error == 1 && packet.getSize() - packet.getReadPos() >= 4)
requiredLevel = packet.readUInt32();
}
// InventoryResult enum (AzerothCore 3.3.5a) // InventoryResult enum (AzerothCore 3.3.5a)
const char* errMsg = nullptr; const char* errMsg = nullptr;
char levelBuf[64];
switch (error) { switch (error) {
case 1: errMsg = "You must reach level %d to use that item."; break; case 1:
if (requiredLevel > 0) {
std::snprintf(levelBuf, sizeof(levelBuf),
"You must reach level %u to use that item.", requiredLevel);
addSystemChatMessage(levelBuf);
} else {
addSystemChatMessage("You must reach a higher level to use that item.");
}
break;
case 2: errMsg = "You don't have the required skill."; break; case 2: errMsg = "You don't have the required skill."; break;
case 3: errMsg = "That item doesn't go in that slot."; break; case 3: errMsg = "That item doesn't go in that slot."; break;
case 4: errMsg = "That bag is full."; break; case 4: errMsg = "That bag is full."; break;
@ -4452,10 +4502,20 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::MSG_INSPECT_ARENA_TEAMS: case Opcode::MSG_INSPECT_ARENA_TEAMS:
LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS");
break; break;
case Opcode::MSG_TALENT_WIPE_CONFIRM: case Opcode::MSG_TALENT_WIPE_CONFIRM: {
// Talent reset confirmation payload is not needed client-side right now. // Server sends: uint64 npcGuid + uint32 cost
packet.setReadPos(packet.getSize()); // Client must respond with the same opcode containing uint64 npcGuid to confirm.
if (packet.getSize() - packet.getReadPos() < 12) {
packet.setReadPos(packet.getSize());
break;
}
talentWipeNpcGuid_ = packet.readUInt64();
talentWipeCost_ = packet.readUInt32();
talentWipePending_ = true;
LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_,
std::dec, " cost=", talentWipeCost_);
break; break;
}
// ---- MSG_MOVE_* opcodes (server relays other players' movement) ---- // ---- MSG_MOVE_* opcodes (server relays other players' movement) ----
case Opcode::MSG_MOVE_START_FORWARD: case Opcode::MSG_MOVE_START_FORWARD:
@ -4480,6 +4540,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::MSG_MOVE_STOP_PITCH: case Opcode::MSG_MOVE_STOP_PITCH:
case Opcode::MSG_MOVE_START_ASCEND: case Opcode::MSG_MOVE_START_ASCEND:
case Opcode::MSG_MOVE_STOP_ASCEND: case Opcode::MSG_MOVE_STOP_ASCEND:
case Opcode::MSG_MOVE_START_DESCEND:
case Opcode::MSG_MOVE_SET_PITCH:
case Opcode::MSG_MOVE_GRAVITY_CHNG:
case Opcode::MSG_MOVE_UPDATE_CAN_FLY:
case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY:
case Opcode::MSG_MOVE_ROOT:
case Opcode::MSG_MOVE_UNROOT:
if (state == WorldState::IN_WORLD) { if (state == WorldState::IN_WORLD) {
handleOtherPlayerMovement(packet); handleOtherPlayerMovement(packet);
} }
@ -5158,17 +5225,45 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED:
case Opcode::SMSG_SPLINE_SET_WALK_SPEED: case Opcode::SMSG_SPLINE_SET_WALK_SPEED:
case Opcode::SMSG_SPLINE_SET_TURN_RATE: case Opcode::SMSG_SPLINE_SET_TURN_RATE:
case Opcode::SMSG_SPLINE_SET_PITCH_RATE: case Opcode::SMSG_SPLINE_SET_PITCH_RATE: {
packet.setReadPos(packet.getSize()); // Minimal parse: PackedGuid + float speed
if (packet.getSize() - packet.getReadPos() < 5) break;
uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 4) break;
float sSpeed = packet.readFloat();
if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) {
if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED)
serverFlightSpeed_ = sSpeed;
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED)
serverFlightBackSpeed_ = sSpeed;
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED)
serverSwimBackSpeed_ = sSpeed;
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_WALK_SPEED)
serverWalkSpeed_ = sSpeed;
else if (*logicalOp == Opcode::SMSG_SPLINE_SET_TURN_RATE)
serverTurnRate_ = sSpeed; // rad/s
}
break; break;
}
// ---- Spline move flag changes for other units ---- // ---- Spline move flag changes for other units ----
case Opcode::SMSG_SPLINE_MOVE_UNROOT: case Opcode::SMSG_SPLINE_MOVE_UNROOT:
case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING:
case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER: case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER:
case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: {
packet.setReadPos(packet.getSize()); // Minimal parse: PackedGuid only — no animation-relevant state change.
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
}
break; break;
}
case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: {
// PackedGuid + synthesised move-flags=0 → clears flying animation.
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break;
unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY
break;
}
// ---- Quest failure notification ---- // ---- Quest failure notification ----
case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: {
@ -5533,15 +5628,39 @@ void GameHandler::handlePacket(network::Packet& packet) {
// ---- Player movement flag changes (server-pushed) ---- // ---- Player movement flag changes (server-pushed) ----
case Opcode::SMSG_MOVE_GRAVITY_DISABLE: case Opcode::SMSG_MOVE_GRAVITY_DISABLE:
handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK,
static_cast<uint32_t>(MovementFlags::LEVITATING), true);
break;
case Opcode::SMSG_MOVE_GRAVITY_ENABLE: case Opcode::SMSG_MOVE_GRAVITY_ENABLE:
handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK,
static_cast<uint32_t>(MovementFlags::LEVITATING), false);
break;
case Opcode::SMSG_MOVE_LAND_WALK: case Opcode::SMSG_MOVE_LAND_WALK:
handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK,
static_cast<uint32_t>(MovementFlags::WATER_WALK), false);
break;
case Opcode::SMSG_MOVE_NORMAL_FALL: case Opcode::SMSG_MOVE_NORMAL_FALL:
handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK,
static_cast<uint32_t>(MovementFlags::FEATHER_FALL), false);
break;
case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY:
handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY",
Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true);
break;
case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY:
handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY",
Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false);
break;
case Opcode::SMSG_MOVE_SET_COLLISION_HGT: case Opcode::SMSG_MOVE_SET_COLLISION_HGT:
handleMoveSetCollisionHeight(packet);
break;
case Opcode::SMSG_MOVE_SET_FLIGHT: case Opcode::SMSG_MOVE_SET_FLIGHT:
handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK,
static_cast<uint32_t>(MovementFlags::FLYING), true);
break;
case Opcode::SMSG_MOVE_UNSET_FLIGHT: case Opcode::SMSG_MOVE_UNSET_FLIGHT:
packet.setReadPos(packet.getSize()); handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK,
static_cast<uint32_t>(MovementFlags::FLYING), false);
break; break;
default: default:
@ -6069,6 +6188,13 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
movementClockStart_ = std::chrono::steady_clock::now(); movementClockStart_ = std::chrono::steady_clock::now();
lastMovementTimestampMs_ = 0; lastMovementTimestampMs_ = 0;
movementInfo.time = nextMovementTimestampMs(); movementInfo.time = nextMovementTimestampMs();
isFalling_ = false;
fallStartMs_ = 0;
movementInfo.fallTime = 0;
movementInfo.jumpVelocity = 0.0f;
movementInfo.jumpSinAngle = 0.0f;
movementInfo.jumpCosAngle = 0.0f;
movementInfo.jumpXYSpeed = 0.0f;
resurrectPending_ = false; resurrectPending_ = false;
resurrectRequestPending_ = false; resurrectRequestPending_ = false;
onTaxiFlight_ = false; onTaxiFlight_ = false;
@ -7186,6 +7312,31 @@ void GameHandler::sendMovement(Opcode opcode) {
break; break;
case Opcode::MSG_MOVE_JUMP: case Opcode::MSG_MOVE_JUMP:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FALLING); movementInfo.flags |= static_cast<uint32_t>(MovementFlags::FALLING);
// Record fall start and capture horizontal velocity for jump fields.
isFalling_ = true;
fallStartMs_ = movementInfo.time;
movementInfo.fallTime = 0;
// jumpVelocity: WoW convention is the upward speed at launch.
movementInfo.jumpVelocity = 7.96f; // WOW_JUMP_VELOCITY from CameraController
{
// Facing direction encodes the horizontal movement direction at launch.
const float facingRad = movementInfo.orientation;
movementInfo.jumpCosAngle = std::cos(facingRad);
movementInfo.jumpSinAngle = std::sin(facingRad);
// Horizontal speed: only non-zero when actually moving at jump time.
const uint32_t horizFlags =
static_cast<uint32_t>(MovementFlags::FORWARD) |
static_cast<uint32_t>(MovementFlags::BACKWARD) |
static_cast<uint32_t>(MovementFlags::STRAFE_LEFT) |
static_cast<uint32_t>(MovementFlags::STRAFE_RIGHT);
const bool movingHoriz = (movementInfo.flags & horizFlags) != 0;
if (movingHoriz) {
const bool isWalking = (movementInfo.flags & static_cast<uint32_t>(MovementFlags::WALKING)) != 0;
movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f);
} else {
movementInfo.jumpXYSpeed = 0.0f;
}
}
break; break;
case Opcode::MSG_MOVE_START_TURN_LEFT: case Opcode::MSG_MOVE_START_TURN_LEFT:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_LEFT); movementInfo.flags |= static_cast<uint32_t>(MovementFlags::TURN_LEFT);
@ -7199,14 +7350,50 @@ void GameHandler::sendMovement(Opcode opcode) {
break; break;
case Opcode::MSG_MOVE_FALL_LAND: case Opcode::MSG_MOVE_FALL_LAND:
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::FALLING); movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::FALLING);
isFalling_ = false;
fallStartMs_ = 0;
movementInfo.fallTime = 0;
movementInfo.jumpVelocity = 0.0f;
movementInfo.jumpSinAngle = 0.0f;
movementInfo.jumpCosAngle = 0.0f;
movementInfo.jumpXYSpeed = 0.0f;
break; break;
case Opcode::MSG_MOVE_HEARTBEAT: case Opcode::MSG_MOVE_HEARTBEAT:
// No flag changes — just sends current position // No flag changes — just sends current position
break; break;
case Opcode::MSG_MOVE_START_ASCEND:
movementInfo.flags |= static_cast<uint32_t>(MovementFlags::ASCENDING);
break;
case Opcode::MSG_MOVE_STOP_ASCEND:
// Clears ascending (and descending) — one stop opcode for both directions
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ASCENDING);
break;
case Opcode::MSG_MOVE_START_DESCEND:
// Descending: no separate flag; clear ASCENDING so they don't conflict
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ASCENDING);
break;
default: default:
break; break;
} }
// Keep fallTime current: it must equal the elapsed milliseconds since FALLING
// was set, so the server can compute fall damage correctly.
if (isFalling_ && movementInfo.hasFlag(MovementFlags::FALLING)) {
// movementInfo.time is the strictly-increasing client clock (ms).
// Subtract fallStartMs_ to get elapsed fall time; clamp to non-negative.
uint32_t elapsed = (movementInfo.time >= fallStartMs_)
? (movementInfo.time - fallStartMs_)
: 0u;
movementInfo.fallTime = elapsed;
} else if (!movementInfo.hasFlag(MovementFlags::FALLING)) {
// Ensure fallTime is zeroed whenever we're not falling.
if (isFalling_) {
isFalling_ = false;
fallStartMs_ = 0;
}
movementInfo.fallTime = 0;
}
if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) { if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) {
sanitizeMovementForTaxi(); sanitizeMovementForTaxi();
} }
@ -7775,10 +7962,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (ghostStateCallback_) ghostStateCallback_(true); if (ghostStateCallback_) ghostStateCallback_(true);
} }
} }
// Determine hostility from faction template for online creatures // Determine hostility from faction template for online creatures.
if (unit->getFactionTemplate() != 0) { // Always call isHostileFaction — factionTemplate=0 defaults to hostile
unit->setHostile(isHostileFaction(unit->getFactionTemplate())); // in the lookup rather than silently staying at the struct default (false).
} unit->setHostile(isHostileFaction(unit->getFactionTemplate()));
// Trigger creature spawn callback for units/players with displayId // Trigger creature spawn callback for units/players with displayId
if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) {
LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec,
@ -7868,10 +8055,16 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) {
auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY));
auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); 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));
if (entryIt != block.fields.end() && entryIt->second != 0) { if (entryIt != block.fields.end() && entryIt->second != 0) {
OnlineItemInfo info; // Preserve existing info when doing partial updates
OnlineItemInfo info = onlineItems_.count(block.guid)
? onlineItems_[block.guid] : OnlineItemInfo{};
info.entry = entryIt->second; info.entry = entryIt->second;
info.stackCount = (stackIt != block.fields.end()) ? stackIt->second : 1; 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;
bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end());
onlineItems_[block.guid] = info; onlineItems_[block.guid] = info;
if (isNew) newItemCreated = true; if (isNew) newItemCreated = true;
@ -7931,6 +8124,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots)); " bankBagSlots=", static_cast<int>(bankBagSlots));
inventory.setPurchasedBankBagSlots(bankBagSlots); inventory.setPurchasedBankBagSlots(bankBagSlots);
// Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city
uint8_t restStateByte = static_cast<uint8_t>((val >> 24) & 0xFF);
isResting_ = (restStateByte & 0x01) != 0;
} }
// Do not synthesize quest-log entries from raw update-field slots. // Do not synthesize quest-log entries from raw update-field slots.
// Slot layouts differ on some classic-family realms and can produce // Slot layouts differ on some classic-family realms and can produce
@ -8199,6 +8395,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
bool slotsChanged = false; bool slotsChanged = false;
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS);
@ -8213,6 +8410,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
playerNextLevelXp_ = val; playerNextLevelXp_ = val;
LOG_DEBUG("Next level XP updated: ", val); LOG_DEBUG("Next level XP updated: ", val);
} }
else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) {
playerRestedXp_ = val;
}
else if (key == ufPlayerLevel) { else if (key == ufPlayerLevel) {
serverPlayerLevel_ = val; serverPlayerLevel_ = val;
LOG_DEBUG("Level updated: ", val); LOG_DEBUG("Level updated: ", val);
@ -8235,6 +8435,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec,
" bankBagSlots=", static_cast<int>(bankBagSlots)); " bankBagSlots=", static_cast<int>(bankBagSlots));
inventory.setPurchasedBankBagSlots(bankBagSlots); inventory.setPurchasedBankBagSlots(bankBagSlots);
// Byte 3 (bits 24-31): REST_STATE — bit 0 set means in inn/city
uint8_t restStateByte = static_cast<uint8_t>((val >> 24) & 0xFF);
isResting_ = (restStateByte & 0x01) != 0;
} }
else if (key == ufPlayerFlags) { else if (key == ufPlayerFlags) {
constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010;
@ -8262,19 +8465,31 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
extractExploredZoneFields(lastPlayerFields_); extractExploredZoneFields(lastPlayerFields_);
} }
// Update item stack count for online items // Update item stack count / durability for online items
if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) {
bool inventoryChanged = false; bool inventoryChanged = false;
const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT);
const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY);
const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY);
const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS);
const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1);
for (const auto& [key, val] : block.fields) { for (const auto& [key, val] : block.fields) {
auto it = onlineItems_.find(block.guid);
if (key == itemStackField) { if (key == itemStackField) {
auto it = onlineItems_.find(block.guid);
if (it != onlineItems_.end() && it->second.stackCount != val) { if (it != onlineItems_.end() && it->second.stackCount != val) {
it->second.stackCount = val; it->second.stackCount = val;
inventoryChanged = true; inventoryChanged = true;
} }
} else if (key == itemDurField) {
if (it != onlineItems_.end() && it->second.curDurability != val) {
it->second.curDurability = val;
inventoryChanged = true;
}
} else if (key == itemMaxDurField) {
if (it != onlineItems_.end() && it->second.maxDurability != val) {
it->second.maxDurability = val;
inventoryChanged = true;
}
} }
} }
// Update container slot GUIDs on bag content changes // Update container slot GUIDs on bag content changes
@ -10554,6 +10769,8 @@ void GameHandler::rebuildOnlineInventory() {
ItemDef def; ItemDef def;
def.itemId = itemIt->second.entry; def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount; def.stackCount = itemIt->second.stackCount;
def.curDurability = itemIt->second.curDurability;
def.maxDurability = itemIt->second.maxDurability;
def.maxStack = 1; def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry); auto infoIt = itemInfoCache_.find(itemIt->second.entry);
@ -10573,6 +10790,15 @@ void GameHandler::rebuildOnlineInventory() {
def.agility = infoIt->second.agility; def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect; def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit; def.spirit = infoIt->second.spirit;
def.sellPrice = infoIt->second.sellPrice;
def.itemLevel = infoIt->second.itemLevel;
def.requiredLevel = infoIt->second.requiredLevel;
def.bindType = infoIt->second.bindType;
def.description = infoIt->second.description;
def.startQuestId = infoIt->second.startQuestId;
def.extraStats.clear();
for (const auto& es : infoIt->second.extraStats)
def.extraStats.push_back({es.statType, es.statValue});
} else { } else {
def.name = "Item " + std::to_string(def.itemId); def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid); queryItemInfo(def.itemId, guid);
@ -10592,6 +10818,8 @@ void GameHandler::rebuildOnlineInventory() {
ItemDef def; ItemDef def;
def.itemId = itemIt->second.entry; def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount; def.stackCount = itemIt->second.stackCount;
def.curDurability = itemIt->second.curDurability;
def.maxDurability = itemIt->second.maxDurability;
def.maxStack = 1; def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry); auto infoIt = itemInfoCache_.find(itemIt->second.entry);
@ -10611,6 +10839,15 @@ void GameHandler::rebuildOnlineInventory() {
def.agility = infoIt->second.agility; def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect; def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit; def.spirit = infoIt->second.spirit;
def.sellPrice = infoIt->second.sellPrice;
def.itemLevel = infoIt->second.itemLevel;
def.requiredLevel = infoIt->second.requiredLevel;
def.bindType = infoIt->second.bindType;
def.description = infoIt->second.description;
def.startQuestId = infoIt->second.startQuestId;
def.extraStats.clear();
for (const auto& es : infoIt->second.extraStats)
def.extraStats.push_back({es.statType, es.statValue});
} else { } else {
def.name = "Item " + std::to_string(def.itemId); def.name = "Item " + std::to_string(def.itemId);
queryItemInfo(def.itemId, guid); queryItemInfo(def.itemId, guid);
@ -10665,6 +10902,8 @@ void GameHandler::rebuildOnlineInventory() {
ItemDef def; ItemDef def;
def.itemId = itemIt->second.entry; def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount; def.stackCount = itemIt->second.stackCount;
def.curDurability = itemIt->second.curDurability;
def.maxDurability = itemIt->second.maxDurability;
def.maxStack = 1; def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry); auto infoIt = itemInfoCache_.find(itemIt->second.entry);
@ -10684,6 +10923,15 @@ void GameHandler::rebuildOnlineInventory() {
def.agility = infoIt->second.agility; def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect; def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit; def.spirit = infoIt->second.spirit;
def.sellPrice = infoIt->second.sellPrice;
def.itemLevel = infoIt->second.itemLevel;
def.requiredLevel = infoIt->second.requiredLevel;
def.bindType = infoIt->second.bindType;
def.description = infoIt->second.description;
def.startQuestId = infoIt->second.startQuestId;
def.extraStats.clear();
for (const auto& es : infoIt->second.extraStats)
def.extraStats.push_back({es.statType, es.statValue});
def.bagSlots = infoIt->second.containerSlots; def.bagSlots = infoIt->second.containerSlots;
} else { } else {
def.name = "Item " + std::to_string(def.itemId); def.name = "Item " + std::to_string(def.itemId);
@ -10705,6 +10953,8 @@ void GameHandler::rebuildOnlineInventory() {
ItemDef def; ItemDef def;
def.itemId = itemIt->second.entry; def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount; def.stackCount = itemIt->second.stackCount;
def.curDurability = itemIt->second.curDurability;
def.maxDurability = itemIt->second.maxDurability;
def.maxStack = 1; def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry); auto infoIt = itemInfoCache_.find(itemIt->second.entry);
@ -10724,6 +10974,14 @@ void GameHandler::rebuildOnlineInventory() {
def.agility = infoIt->second.agility; def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect; def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit; def.spirit = infoIt->second.spirit;
def.itemLevel = infoIt->second.itemLevel;
def.requiredLevel = infoIt->second.requiredLevel;
def.bindType = infoIt->second.bindType;
def.description = infoIt->second.description;
def.startQuestId = infoIt->second.startQuestId;
def.extraStats.clear();
for (const auto& es : infoIt->second.extraStats)
def.extraStats.push_back({es.statType, es.statValue});
def.sellPrice = infoIt->second.sellPrice; def.sellPrice = infoIt->second.sellPrice;
def.bagSlots = infoIt->second.containerSlots; def.bagSlots = infoIt->second.containerSlots;
} else { } else {
@ -10786,6 +11044,8 @@ void GameHandler::rebuildOnlineInventory() {
ItemDef def; ItemDef def;
def.itemId = itemIt->second.entry; def.itemId = itemIt->second.entry;
def.stackCount = itemIt->second.stackCount; def.stackCount = itemIt->second.stackCount;
def.curDurability = itemIt->second.curDurability;
def.maxDurability = itemIt->second.maxDurability;
def.maxStack = 1; def.maxStack = 1;
auto infoIt = itemInfoCache_.find(itemIt->second.entry); auto infoIt = itemInfoCache_.find(itemIt->second.entry);
@ -10805,7 +11065,15 @@ void GameHandler::rebuildOnlineInventory() {
def.agility = infoIt->second.agility; def.agility = infoIt->second.agility;
def.intellect = infoIt->second.intellect; def.intellect = infoIt->second.intellect;
def.spirit = infoIt->second.spirit; def.spirit = infoIt->second.spirit;
def.itemLevel = infoIt->second.itemLevel;
def.requiredLevel = infoIt->second.requiredLevel;
def.sellPrice = infoIt->second.sellPrice; def.sellPrice = infoIt->second.sellPrice;
def.bindType = infoIt->second.bindType;
def.description = infoIt->second.description;
def.startQuestId = infoIt->second.startQuestId;
def.extraStats.clear();
for (const auto& es : infoIt->second.extraStats)
def.extraStats.push_back({es.statType, es.statValue});
def.bagSlots = infoIt->second.containerSlots; def.bagSlots = infoIt->second.containerSlots;
} else { } else {
def.name = "Item " + std::to_string(def.itemId); def.name = "Item " + std::to_string(def.itemId);
@ -11398,6 +11666,47 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char*
socket->send(ack); socket->send(ack);
} }
void GameHandler::handleMoveSetCollisionHeight(network::Packet& packet) {
// SMSG_MOVE_SET_COLLISION_HGT: packed guid + counter + float (height)
// ACK: CMSG_MOVE_SET_COLLISION_HGT_ACK = packed guid + counter + movement block + float (height)
const bool legacyGuid = isClassicLikeExpansion() || isActiveExpansion("tbc");
if (packet.getSize() - packet.getReadPos() < (legacyGuid ? 8u : 2u)) return;
uint64_t guid = legacyGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 8) return; // counter(4) + height(4)
uint32_t counter = packet.readUInt32();
float height = packet.readFloat();
LOG_INFO("SMSG_MOVE_SET_COLLISION_HGT: guid=0x", std::hex, guid, std::dec,
" counter=", counter, " height=", height);
if (guid != playerGuid) return;
if (!socket) return;
uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_SET_COLLISION_HGT_ACK);
if (ackWire == 0xFFFF) return;
network::Packet ack(ackWire);
const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid);
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
ack.writeUInt32(counter);
MovementInfo wire = movementInfo;
wire.time = nextMovementTimestampMs();
glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z));
wire.x = serverPos.x;
wire.y = serverPos.y;
wire.z = serverPos.z;
if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire);
else MovementPacket::writeMovementPayload(ack, wire);
ack.writeFloat(height);
socket->send(ack);
}
void GameHandler::handleMoveKnockBack(network::Packet& packet) { void GameHandler::handleMoveKnockBack(network::Packet& packet) {
// WotLK: packed GUID; TBC/Classic: full uint64 // WotLK: packed GUID; TBC/Classic: full uint64
const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc");
@ -11406,16 +11715,23 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) {
? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet);
if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4) if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4)
uint32_t counter = packet.readUInt32(); uint32_t counter = packet.readUInt32();
[[maybe_unused]] float vcos = packet.readFloat(); float vcos = packet.readFloat();
[[maybe_unused]] float vsin = packet.readFloat(); float vsin = packet.readFloat();
[[maybe_unused]] float hspeed = packet.readFloat(); float hspeed = packet.readFloat();
[[maybe_unused]] float vspeed = packet.readFloat(); float vspeed = packet.readFloat();
LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec, LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec,
" counter=", counter, " hspeed=", hspeed, " vspeed=", vspeed); " counter=", counter, " vcos=", vcos, " vsin=", vsin,
" hspeed=", hspeed, " vspeed=", vspeed);
if (guid != playerGuid) return; if (guid != playerGuid) return;
// Apply knockback physics locally so the player visually flies through the air.
// The callback forwards to CameraController::applyKnockBack().
if (knockBackCallback_) {
knockBackCallback_(vcos, vsin, hspeed, vspeed);
}
if (!socket) return; if (!socket) return;
uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK); uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK);
if (ackWire == 0xFFFF) return; if (ackWire == 0xFFFF) return;
@ -11867,13 +12183,18 @@ void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) {
(void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded;
if (inProgress) { if (inProgress) {
lfgState_ = LfgState::Boot;
addSystemChatMessage( addSystemChatMessage(
std::string("Dungeon Finder: Vote to kick in progress (") + std::string("Dungeon Finder: Vote to kick in progress (") +
std::to_string(timeLeft) + "s remaining)."); std::to_string(timeLeft) + "s remaining).");
} else if (myAnswer) {
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
} else { } else {
addSystemChatMessage("Dungeon Finder: Vote kick failed."); // Boot vote ended — return to InDungeon state regardless of outcome
lfgState_ = LfgState::InDungeon;
if (myAnswer) {
addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed.");
} else {
addSystemChatMessage("Dungeon Finder: Vote kick failed.");
}
} }
LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress,
@ -11942,6 +12263,18 @@ void GameHandler::lfgTeleport(bool toLfgDungeon) {
LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon); LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon);
} }
void GameHandler::lfgSetBootVote(bool vote) {
if (!socket) return;
uint16_t wireOp = wireOpcode(Opcode::CMSG_LFG_SET_BOOT_VOTE);
if (wireOp == 0xFFFF) return;
network::Packet pkt(wireOp);
pkt.writeUInt8(vote ? 1 : 0);
socket->send(pkt);
LOG_INFO("Sent CMSG_LFG_SET_BOOT_VOTE: vote=", vote);
}
void GameHandler::loadAreaTriggerDbc() { void GameHandler::loadAreaTriggerDbc() {
if (areaTriggerDbcLoaded_) return; if (areaTriggerDbcLoaded_) return;
areaTriggerDbcLoaded_ = true; areaTriggerDbcLoaded_ = true;
@ -12245,7 +12578,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) {
// Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*)
// Not static — wireOpcode() depends on runtime active opcode table. // Not static — wireOpcode() depends on runtime active opcode table.
const std::array<uint16_t, 22> kMoveOpcodes = { const std::array<uint16_t, 29> kMoveOpcodes = {
wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_FORWARD),
wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD),
wireOpcode(Opcode::MSG_MOVE_STOP), wireOpcode(Opcode::MSG_MOVE_STOP),
@ -12268,6 +12601,13 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) {
wireOpcode(Opcode::MSG_MOVE_STOP_PITCH), wireOpcode(Opcode::MSG_MOVE_STOP_PITCH),
wireOpcode(Opcode::MSG_MOVE_START_ASCEND), wireOpcode(Opcode::MSG_MOVE_START_ASCEND),
wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND),
wireOpcode(Opcode::MSG_MOVE_START_DESCEND),
wireOpcode(Opcode::MSG_MOVE_SET_PITCH),
wireOpcode(Opcode::MSG_MOVE_GRAVITY_CHNG),
wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_FLY),
wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY),
wireOpcode(Opcode::MSG_MOVE_ROOT),
wireOpcode(Opcode::MSG_MOVE_UNROOT),
}; };
// Track unhandled sub-opcodes once per compressed packet (avoid log spam) // Track unhandled sub-opcodes once per compressed packet (avoid log spam)
@ -13160,20 +13500,35 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
} }
void GameHandler::handleSpellCooldown(network::Packet& packet) { void GameHandler::handleSpellCooldown(network::Packet& packet) {
SpellCooldownData data; // Classic 1.12: guid(8) + N×[spellId(4) + itemId(4) + cooldown(4)] — no flags byte, 12 bytes/entry
if (!SpellCooldownParser::parse(packet, data)) return; // TBC 2.4.3 / WotLK 3.3.5a: guid(8) + flags(1) + N×[spellId(4) + cooldown(4)] — 8 bytes/entry
const bool isClassicFormat = isClassicLikeExpansion();
if (packet.getSize() - packet.getReadPos() < 8) return;
/*data.guid =*/ packet.readUInt64(); // guid (not used further)
if (!isClassicFormat) {
if (packet.getSize() - packet.getReadPos() < 1) return;
/*data.flags =*/ packet.readUInt8(); // flags (consumed but not stored)
}
const size_t entrySize = isClassicFormat ? 12u : 8u;
while (packet.getSize() - packet.getReadPos() >= entrySize) {
uint32_t spellId = packet.readUInt32();
if (isClassicFormat) packet.readUInt32(); // itemId — consumed, not used
uint32_t cooldownMs = packet.readUInt32();
for (const auto& [spellId, cooldownMs] : data.cooldowns) {
float seconds = cooldownMs / 1000.0f; float seconds = cooldownMs / 1000.0f;
spellCooldowns[spellId] = seconds; spellCooldowns[spellId] = seconds;
// Update action bar cooldowns
for (auto& slot : actionBar) { for (auto& slot : actionBar) {
if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) {
slot.cooldownTotal = seconds; slot.cooldownTotal = seconds;
slot.cooldownRemaining = seconds; slot.cooldownRemaining = seconds;
} }
} }
} }
LOG_DEBUG("handleSpellCooldown: parsed for ",
isClassicFormat ? "Classic" : "TBC/WotLK", " format");
} }
void GameHandler::handleCooldownEvent(network::Packet& packet) { void GameHandler::handleCooldownEvent(network::Packet& packet) {
@ -13395,6 +13750,24 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) {
addSystemChatMessage(msg); addSystemChatMessage(msg);
} }
void GameHandler::confirmTalentWipe() {
if (!talentWipePending_) return;
talentWipePending_ = false;
if (state != WorldState::IN_WORLD || !socket) return;
// Respond to MSG_TALENT_WIPE_CONFIRM with the trainer GUID to trigger the reset.
// Packet: opcode(2) + uint64 npcGuid = 10 bytes.
network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM));
pkt.writeUInt64(talentWipeNpcGuid_);
socket->send(pkt);
LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec);
addSystemChatMessage("Talent reset confirmed. The server will update your talents.");
talentWipeNpcGuid_ = 0;
talentWipeCost_ = 0;
}
// ============================================================ // ============================================================
// Phase 4: Group/Party // Phase 4: Group/Party
// ============================================================ // ============================================================
@ -14042,8 +14415,9 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
// animation/sound and expects the client to request the mail list. // animation/sound and expects the client to request the mail list.
bool isMailbox = false; bool isMailbox = false;
bool chestLike = false; bool chestLike = false;
// Stock-like behavior: GO use opens GO loot context. Keep eager CMSG_LOOT only // Chest-type game objects (type=3): on all expansions, also send CMSG_LOOT so
// as Classic/Turtle fallback behavior. // the server opens the loot response. Other harvestable/interactive types rely
// on the server auto-sending SMSG_LOOT_RESPONSE after CMSG_GAMEOBJ_USE.
bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle"); bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle");
if (entity && entity->getType() == ObjectType::GAMEOBJECT) { if (entity && entity->getType() == ObjectType::GAMEOBJECT) {
auto go = std::static_pointer_cast<GameObject>(entity); auto go = std::static_pointer_cast<GameObject>(entity);
@ -14060,6 +14434,8 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
refreshMailList(); refreshMailList();
} else if (info && info->type == 3) { } else if (info && info->type == 3) {
chestLike = true; chestLike = true;
// Type-3 chests require CMSG_LOOT on all expansions (AzerothCore WotLK included)
shouldSendLoot = true;
} else if (turtleMode) { } else if (turtleMode) {
// Turtle compatibility: keep eager loot open behavior. // Turtle compatibility: keep eager loot open behavior.
shouldSendLoot = true; shouldSendLoot = true;
@ -14070,21 +14446,19 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) {
std::transform(lower.begin(), lower.end(), lower.begin(), std::transform(lower.begin(), lower.end(), lower.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); }); [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
chestLike = (lower.find("chest") != std::string::npos); chestLike = (lower.find("chest") != std::string::npos);
if (chestLike) shouldSendLoot = true;
} }
// For WotLK chest-like gameobjects, report use but let server open loot. // For WotLK chest-like gameobjects, also send CMSG_GAMEOBJ_REPORT_USE.
if (!isMailbox && chestLike) { if (!isMailbox && chestLike && isActiveExpansion("wotlk")) {
if (isActiveExpansion("wotlk")) { network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE));
network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); reportUse.writeUInt64(guid);
reportUse.writeUInt64(guid); socket->send(reportUse);
socket->send(reportUse);
}
} }
if (shouldSendLoot) { if (shouldSendLoot) {
lootTarget(guid); lootTarget(guid);
} }
// Retry use briefly to survive packet loss/order races. Keep loot retries only // Retry use briefly to survive packet loss/order races.
// when we intentionally use eager loot-open mode. const bool retryLoot = shouldSendLoot;
const bool retryLoot = shouldSendLoot && (turtleMode || isActiveExpansion("classic"));
const bool retryUse = turtleMode || isActiveExpansion("classic"); const bool retryUse = turtleMode || isActiveExpansion("classic");
if (retryUse || retryLoot) { if (retryUse || retryLoot) {
pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot}); pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot});
@ -14610,6 +14984,26 @@ void GameHandler::buyBackItem(uint32_t buybackSlot) {
socket->send(packet); socket->send(packet);
} }
void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) {
if (state != WorldState::IN_WORLD || !socket) return;
// CMSG_REPAIR_ITEM: npcGuid(8) + itemGuid(8) + useGuildBank(uint8)
network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM));
packet.writeUInt64(vendorGuid);
packet.writeUInt64(itemGuid);
packet.writeUInt8(0); // do not use guild bank
socket->send(packet);
}
void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) {
if (state != WorldState::IN_WORLD || !socket) return;
// itemGuid = 0 signals "repair all equipped" to the server
network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM));
packet.writeUInt64(vendorGuid);
packet.writeUInt64(0);
packet.writeUInt8(useGuildBank ? 1 : 0);
socket->send(packet);
}
void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) {
if (state != WorldState::IN_WORLD || !socket) return; if (state != WorldState::IN_WORLD || !socket) return;
LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid, LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid,
@ -15173,7 +15567,9 @@ void GameHandler::handleGossipComplete(network::Packet& packet) {
} }
void GameHandler::handleListInventory(network::Packet& packet) { void GameHandler::handleListInventory(network::Packet& packet) {
bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set before openVendor()
if (!ListInventoryParser::parse(packet, currentVendorItems)) return; if (!ListInventoryParser::parse(packet, currentVendorItems)) return;
currentVendorItems.canRepair = savedCanRepair;
vendorWindowOpen = true; vendorWindowOpen = true;
gossipWindowOpen = false; // Close gossip if vendor opens gossipWindowOpen = false; // Close gossip if vendor opens
@ -15275,8 +15671,10 @@ void GameHandler::loadSpellNameCache() {
return; return;
} }
if (dbc->getFieldCount() < 154) { // Classic 1.12 Spell.dbc has 148 fields; TBC/WotLK have more.
LOG_WARNING("Trainer: Spell.dbc has too few fields"); // Require at least 148 so Classic trainers can resolve spell names.
if (dbc->getFieldCount() < 148) {
LOG_WARNING("Trainer: Spell.dbc has too few fields (", dbc->getFieldCount(), ")");
return; return;
} }

View file

@ -1242,8 +1242,8 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace packet.readUInt32(); // AllowableRace
packet.readUInt32(); // ItemLevel data.itemLevel = packet.readUInt32();
packet.readUInt32(); // RequiredLevel data.requiredLevel = packet.readUInt32();
packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkill
packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSkillRank
packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredSpell
@ -1266,7 +1266,10 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
case 5: data.intellect = statValue; break; case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break; case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break; case 7: data.stamina = statValue; break;
default: break; default:
if (statValue != 0)
data.extraStats.push_back({statType, statValue});
break;
} }
} }
} }
@ -1302,6 +1305,40 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ
data.delayMs = packet.readUInt32(); data.delayMs = packet.readUInt32();
} }
// AmmoType + RangedModRange (2 fields, 8 bytes)
if (packet.getSize() - packet.getReadPos() >= 8) {
packet.readUInt32(); // AmmoType
packet.readFloat(); // RangedModRange
}
// 2 item spells in Vanilla (3 fields each: SpellId, Trigger, Charges)
// Actually vanilla has 5 spells: SpellId, Trigger, Charges, Cooldown, Category, CatCooldown = 24 bytes each
for (int i = 0; i < 5; i++) {
if (packet.getReadPos() + 24 > packet.getSize()) break;
data.spells[i].spellId = packet.readUInt32();
data.spells[i].spellTrigger = packet.readUInt32();
packet.readUInt32(); // SpellCharges
packet.readUInt32(); // SpellCooldown
packet.readUInt32(); // SpellCategory
packet.readUInt32(); // SpellCategoryCooldown
}
// Bonding type
if (packet.getReadPos() + 4 <= packet.getSize())
data.bindType = packet.readUInt32();
// Description (flavor/lore text)
if (packet.getReadPos() < packet.getSize())
data.description = packet.readString();
// Post-description: PageText, LanguageID, PageMaterial, StartQuest
if (packet.getReadPos() + 16 <= packet.getSize()) {
packet.readUInt32(); // PageText
packet.readUInt32(); // LanguageID
packet.readUInt32(); // PageMaterial
data.startQuestId = packet.readUInt32(); // StartQuest
}
data.valid = !data.name.empty(); data.valid = !data.name.empty();
LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality, LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality,
" invType=", data.inventoryType, " stack=", data.maxStack, ")"); " invType=", data.inventoryType, " stack=", data.maxStack, ")");

View file

@ -739,9 +739,15 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa
if (packet.getReadPos() + 4 <= packet.getSize()) { if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t choiceCount = packet.readUInt32(); uint32_t choiceCount = packet.readUInt32();
for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
packet.readUInt32(); // itemId uint32_t itemId = packet.readUInt32();
packet.readUInt32(); // count uint32_t count = packet.readUInt32();
packet.readUInt32(); // displayInfo uint32_t dispId = packet.readUInt32();
if (itemId != 0) {
QuestRewardItem ri;
ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId;
ri.choiceSlot = i;
data.rewardChoiceItems.push_back(ri);
}
} }
} }
@ -749,9 +755,14 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa
if (packet.getReadPos() + 4 <= packet.getSize()) { if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t rewardCount = packet.readUInt32(); uint32_t rewardCount = packet.readUInt32();
for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
packet.readUInt32(); // itemId uint32_t itemId = packet.readUInt32();
packet.readUInt32(); // count uint32_t count = packet.readUInt32();
packet.readUInt32(); // displayInfo uint32_t dispId = packet.readUInt32();
if (itemId != 0) {
QuestRewardItem ri;
ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId;
data.rewardItems.push_back(ri);
}
} }
} }
@ -906,8 +917,8 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace packet.readUInt32(); // AllowableRace
packet.readUInt32(); // ItemLevel data.itemLevel = packet.readUInt32();
packet.readUInt32(); // RequiredLevel data.requiredLevel = packet.readUInt32();
packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkill
packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSkillRank
packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredSpell
@ -931,7 +942,10 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
case 5: data.intellect = statValue; break; case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break; case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break; case 7: data.stamina = statValue; break;
default: break; default:
if (statValue != 0)
data.extraStats.push_back({statType, statValue});
break;
} }
} }
// TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only) // TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only)
@ -963,6 +977,39 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery
data.delayMs = packet.readUInt32(); data.delayMs = packet.readUInt32();
} }
// AmmoType + RangedModRange
if (packet.getSize() - packet.getReadPos() >= 8) {
packet.readUInt32(); // AmmoType
packet.readFloat(); // RangedModRange
}
// 5 item spells
for (int i = 0; i < 5; i++) {
if (packet.getReadPos() + 24 > packet.getSize()) break;
data.spells[i].spellId = packet.readUInt32();
data.spells[i].spellTrigger = packet.readUInt32();
packet.readUInt32(); // SpellCharges
packet.readUInt32(); // SpellCooldown
packet.readUInt32(); // SpellCategory
packet.readUInt32(); // SpellCategoryCooldown
}
// Bonding type
if (packet.getReadPos() + 4 <= packet.getSize())
data.bindType = packet.readUInt32();
// Flavor/lore text
if (packet.getReadPos() < packet.getSize())
data.description = packet.readString();
// Post-description: PageText, LanguageID, PageMaterial, StartQuest
if (packet.getReadPos() + 16 <= packet.getSize()) {
packet.readUInt32(); // PageText
packet.readUInt32(); // LanguageID
packet.readUInt32(); // PageMaterial
data.startQuestId = packet.readUInt32(); // StartQuest
}
data.valid = !data.name.empty(); data.valid = !data.name.empty();
LOG_DEBUG("[TBC] Item query: ", data.name, " quality=", data.quality, LOG_DEBUG("[TBC] Item query: ", data.name, " quality=", data.quality,
" invType=", data.inventoryType, " armor=", data.armor); " invType=", data.inventoryType, " armor=", data.armor);

View file

@ -1422,6 +1422,14 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) {
break; break;
} }
case ChatType::BG_SYSTEM_NEUTRAL:
case ChatType::BG_SYSTEM_ALLIANCE:
case ChatType::BG_SYSTEM_HORDE:
// BG/Arena system messages — no sender GUID or name field, just message.
// Reclassify as SYSTEM for consistent display.
data.type = ChatType::SYSTEM;
break;
default: default:
// SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc. // SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc.
// All have receiverGuid (typically senderGuid repeated) // All have receiverGuid (typically senderGuid repeated)
@ -2441,8 +2449,8 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
packet.readUInt32(); // AllowableClass packet.readUInt32(); // AllowableClass
packet.readUInt32(); // AllowableRace packet.readUInt32(); // AllowableRace
packet.readUInt32(); // ItemLevel data.itemLevel = packet.readUInt32();
packet.readUInt32(); // RequiredLevel data.requiredLevel = packet.readUInt32();
packet.readUInt32(); // RequiredSkill packet.readUInt32(); // RequiredSkill
packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSkillRank
packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredSpell
@ -2466,7 +2474,10 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
case 5: data.intellect = statValue; break; case 5: data.intellect = statValue; break;
case 6: data.spirit = statValue; break; case 6: data.spirit = statValue; break;
case 7: data.stamina = statValue; break; case 7: data.stamina = statValue; break;
default: break; default:
if (statValue != 0)
data.extraStats.push_back({statType, statValue});
break;
} }
} }
@ -2510,6 +2521,22 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa
packet.readUInt32(); // SpellCategoryCooldown packet.readUInt32(); // SpellCategoryCooldown
} }
// Bonding type (0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ)
if (packet.getReadPos() + 4 <= packet.getSize())
data.bindType = packet.readUInt32();
// Flavor/lore text (Description cstring)
if (packet.getReadPos() < packet.getSize())
data.description = packet.readString();
// Post-description fields: PageText, LanguageID, PageMaterial, StartQuest
if (packet.getReadPos() + 16 <= packet.getSize()) {
packet.readUInt32(); // PageText
packet.readUInt32(); // LanguageID
packet.readUInt32(); // PageMaterial
data.startQuestId = packet.readUInt32(); // StartQuest
}
data.valid = !data.name.empty(); data.valid = !data.name.empty();
return true; return true;
} }
@ -3419,9 +3446,15 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
/*choiceCount*/ packet.readUInt32(); /*choiceCount*/ packet.readUInt32();
for (int i = 0; i < 6; i++) { for (int i = 0; i < 6; i++) {
if (packet.getReadPos() + 12 > packet.getSize()) break; if (packet.getReadPos() + 12 > packet.getSize()) break;
packet.readUInt32(); // itemId uint32_t itemId = packet.readUInt32();
packet.readUInt32(); // count uint32_t count = packet.readUInt32();
packet.readUInt32(); // displayInfo uint32_t dispId = packet.readUInt32();
if (itemId != 0) {
QuestRewardItem ri;
ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId;
ri.choiceSlot = static_cast<uint32_t>(i);
data.rewardChoiceItems.push_back(ri);
}
} }
} }
@ -3430,9 +3463,14 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
/*rewardCount*/ packet.readUInt32(); /*rewardCount*/ packet.readUInt32();
for (int i = 0; i < 4; i++) { for (int i = 0; i < 4; i++) {
if (packet.getReadPos() + 12 > packet.getSize()) break; if (packet.getReadPos() + 12 > packet.getSize()) break;
packet.readUInt32(); // itemId uint32_t itemId = packet.readUInt32();
packet.readUInt32(); // count uint32_t count = packet.readUInt32();
packet.readUInt32(); // displayInfo uint32_t dispId = packet.readUInt32();
if (itemId != 0) {
QuestRewardItem ri;
ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId;
data.rewardItems.push_back(ri);
}
} }
} }

View file

@ -217,6 +217,7 @@ void CameraController::update(float deltaTime) {
bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT));
bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL));
bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE);
bool spaceDown = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_SPACE);
// Idle camera: any input resets the timer; timeout triggers a slow orbit pan // Idle camera: any input resets the timer; timeout triggers a slow orbit pan
bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump;
@ -275,8 +276,10 @@ void CameraController::update(float deltaTime) {
if (mouseAutorun) { if (mouseAutorun) {
autoRunning = false; autoRunning = false;
} }
bool nowForward = keyW || mouseAutorun || autoRunning; // When the server has rooted the player, suppress all horizontal movement input.
bool nowBackward = keyS; const bool movBlocked = movementRooted_;
bool nowForward = !movBlocked && (keyW || mouseAutorun || autoRunning);
bool nowBackward = !movBlocked && keyS;
bool nowStrafeLeft = false; bool nowStrafeLeft = false;
bool nowStrafeRight = false; bool nowStrafeRight = false;
bool nowTurnLeft = false; bool nowTurnLeft = false;
@ -285,21 +288,27 @@ void CameraController::update(float deltaTime) {
// WoW-like third-person keyboard behavior: // WoW-like third-person keyboard behavior:
// - RMB held: A/D strafe // - RMB held: A/D strafe
// - RMB released: A/D turn character+camera, Q/E strafe // - RMB released: A/D turn character+camera, Q/E strafe
// Turning is allowed even while rooted; only positional movement is blocked.
if (thirdPerson && !rightMouseDown) { if (thirdPerson && !rightMouseDown) {
nowTurnLeft = keyA; nowTurnLeft = keyA;
nowTurnRight = keyD; nowTurnRight = keyD;
nowStrafeLeft = keyQ; nowStrafeLeft = !movBlocked && keyQ;
nowStrafeRight = keyE; nowStrafeRight = !movBlocked && keyE;
} else { } else {
nowStrafeLeft = keyA || keyQ; nowStrafeLeft = !movBlocked && (keyA || keyQ);
nowStrafeRight = keyD || keyE; nowStrafeRight = !movBlocked && (keyD || keyE);
} }
// Keyboard turning updates camera yaw (character follows yaw in renderer) // Keyboard turning updates camera yaw (character follows yaw in renderer).
// Use server turn rate (rad/s) when set; otherwise fall back to WOW_TURN_SPEED (deg/s).
const float activeTurnSpeedDeg = (turnRateOverride_ > 0.0f && turnRateOverride_ < 20.0f
&& !std::isnan(turnRateOverride_))
? glm::degrees(turnRateOverride_)
: WOW_TURN_SPEED;
if (nowTurnLeft && !nowTurnRight) { if (nowTurnLeft && !nowTurnRight) {
yaw += WOW_TURN_SPEED * deltaTime; yaw += activeTurnSpeedDeg * deltaTime;
} else if (nowTurnRight && !nowTurnLeft) { } else if (nowTurnRight && !nowTurnLeft) {
yaw -= WOW_TURN_SPEED * deltaTime; yaw -= activeTurnSpeedDeg * deltaTime;
} }
if (nowTurnLeft || nowTurnRight) { if (nowTurnLeft || nowTurnRight) {
camera->setRotation(yaw, pitch); camera->setRotation(yaw, pitch);
@ -315,9 +324,12 @@ void CameraController::update(float deltaTime) {
if (useWoWSpeed) { if (useWoWSpeed) {
// Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower) // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower)
if (nowBackward && !nowForward) { if (nowBackward && !nowForward) {
speed = WOW_BACK_SPEED; speed = (runBackSpeedOverride_ > 0.0f && runBackSpeedOverride_ < 100.0f
&& !std::isnan(runBackSpeedOverride_))
? runBackSpeedOverride_ : WOW_BACK_SPEED;
} else if (ctrlDown) { } else if (ctrlDown) {
speed = WOW_WALK_SPEED; speed = (walkSpeedOverride_ > 0.0f && walkSpeedOverride_ < 100.0f && !std::isnan(walkSpeedOverride_))
? walkSpeedOverride_ : WOW_WALK_SPEED;
} else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) { } else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) {
speed = runSpeedOverride_; speed = runSpeedOverride_;
} else { } else {
@ -406,7 +418,14 @@ void CameraController::update(float deltaTime) {
constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f; constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f;
constexpr float MIN_SWIM_WATER_DEPTH = 1.0f; constexpr float MIN_SWIM_WATER_DEPTH = 1.0f;
bool inWater = false; bool inWater = false;
if (waterH && targetPos.z < *waterH) { // Water Walk: treat water surface as ground — player walks on top, not through.
if (waterWalkActive_ && waterH && targetPos.z >= *waterH - 0.5f) {
// Clamp to water surface so the player stands on it
targetPos.z = *waterH;
verticalVelocity = 0.0f;
grounded = true;
inWater = false;
} else if (waterH && targetPos.z < *waterH) {
std::optional<uint16_t> waterType; std::optional<uint16_t> waterType;
if (waterRenderer) { if (waterRenderer) {
waterType = waterRenderer->getWaterTypeAt(targetPos.x, targetPos.y); waterType = waterRenderer->getWaterTypeAt(targetPos.x, targetPos.y);
@ -504,7 +523,8 @@ void CameraController::update(float deltaTime) {
swimming = true; swimming = true;
// Swim movement follows look pitch (forward/back), while strafe stays // Swim movement follows look pitch (forward/back), while strafe stays
// lateral for stable control. // lateral for stable control.
float swimSpeed = speed * SWIM_SPEED_FACTOR; float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_))
? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR;
float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z; float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z;
// For auto-run/auto-swim: use character facing (immune to camera pan) // For auto-run/auto-swim: use character facing (immune to camera pan)
@ -523,6 +543,10 @@ void CameraController::update(float deltaTime) {
// Use character's facing direction for strafe, not camera's right vector // Use character's facing direction for strafe, not camera's right vector
glm::vec3 swimRight = right; // Character's right (horizontal facing), not camera's glm::vec3 swimRight = right; // Character's right (horizontal facing), not camera's
float swimBackSpeed = (swimBackSpeedOverride_ > 0.0f && swimBackSpeedOverride_ < 100.0f
&& !std::isnan(swimBackSpeedOverride_))
? swimBackSpeedOverride_ : swimSpeed * 0.5f;
glm::vec3 swimMove(0.0f); glm::vec3 swimMove(0.0f);
if (nowForward) swimMove += swimForward; if (nowForward) swimMove += swimForward;
if (nowBackward) swimMove -= swimForward; if (nowBackward) swimMove -= swimForward;
@ -531,7 +555,9 @@ void CameraController::update(float deltaTime) {
if (glm::length(swimMove) > 0.001f) { if (glm::length(swimMove) > 0.001f) {
swimMove = glm::normalize(swimMove); swimMove = glm::normalize(swimMove);
targetPos += swimMove * swimSpeed * physicsDeltaTime; // Use backward swim speed when moving backwards only (not when combining with strafe)
float applySpeed = (nowBackward && !nowForward) ? swimBackSpeed : swimSpeed;
targetPos += swimMove * applySpeed * physicsDeltaTime;
} }
// Spacebar = swim up (continuous, not a jump) // Spacebar = swim up (continuous, not a jump)
@ -680,11 +706,60 @@ void CameraController::update(float deltaTime) {
} }
swimming = false; swimming = false;
// Player-controlled flight (flying mount / druid Flight Form):
// Use 3D pitch-following movement with no gravity or grounding.
if (flyingActive_) {
grounded = true; // suppress fall-damage checks
verticalVelocity = 0.0f;
jumpBufferTimer = 0.0f;
coyoteTimer = 0.0f;
// Forward/back follows camera 3D direction (same as swim)
glm::vec3 flyFwd = glm::normalize(forward3D);
if (glm::length(flyFwd) < 1e-4f) flyFwd = forward;
glm::vec3 flyMove(0.0f);
if (nowForward) flyMove += flyFwd;
if (nowBackward) flyMove -= flyFwd;
if (nowStrafeLeft) flyMove += right;
if (nowStrafeRight) flyMove -= right;
// Space = ascend, X = descend while airborne
bool flyDescend = !uiWantsKeyboard && xDown && mounted_;
if (nowJump) flyMove.z += 1.0f;
if (flyDescend) flyMove.z -= 1.0f;
if (glm::length(flyMove) > 0.001f) {
flyMove = glm::normalize(flyMove);
float flyFwdSpeed = (flightSpeedOverride_ > 0.0f && flightSpeedOverride_ < 200.0f
&& !std::isnan(flightSpeedOverride_))
? flightSpeedOverride_ : speed;
float flyBackSpeed = (flightBackSpeedOverride_ > 0.0f && flightBackSpeedOverride_ < 200.0f
&& !std::isnan(flightBackSpeedOverride_))
? flightBackSpeedOverride_ : flyFwdSpeed * 0.5f;
float flySpeed = (nowBackward && !nowForward) ? flyBackSpeed : flyFwdSpeed;
targetPos += flyMove * flySpeed * physicsDeltaTime;
}
targetPos.z += verticalVelocity * physicsDeltaTime;
// Skip all ground physics — go straight to collision/WMO sections
} else {
if (glm::length(movement) > 0.001f) { if (glm::length(movement) > 0.001f) {
movement = glm::normalize(movement); movement = glm::normalize(movement);
targetPos += movement * speed * physicsDeltaTime; targetPos += movement * speed * physicsDeltaTime;
} }
// Apply server-driven knockback horizontal velocity (decays over time).
if (knockbackActive_) {
targetPos.x += knockbackHorizVel_.x * physicsDeltaTime;
targetPos.y += knockbackHorizVel_.y * physicsDeltaTime;
// Exponential drag: reduce each frame so the player decelerates naturally.
float drag = std::exp(-KNOCKBACK_HORIZ_DRAG * physicsDeltaTime);
knockbackHorizVel_ *= drag;
// Once negligible, clear the flag so collision/grounding work normally.
if (glm::length(knockbackHorizVel_) < 0.05f) {
knockbackActive_ = false;
knockbackHorizVel_ = glm::vec2(0.0f);
}
}
// Jump with input buffering and coyote time // Jump with input buffering and coyote time
if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME; if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME;
if (grounded) coyoteTimer = COYOTE_TIME; if (grounded) coyoteTimer = COYOTE_TIME;
@ -700,10 +775,20 @@ void CameraController::update(float deltaTime) {
jumpBufferTimer -= physicsDeltaTime; jumpBufferTimer -= physicsDeltaTime;
coyoteTimer -= physicsDeltaTime; coyoteTimer -= physicsDeltaTime;
// Apply gravity // Apply gravity (skip when server has disabled gravity, e.g. Levitate spell)
verticalVelocity += gravity * physicsDeltaTime; if (gravityDisabled_) {
targetPos.z += verticalVelocity * physicsDeltaTime; // Float in place: bleed off any downward velocity, allow upward to decay slowly
if (verticalVelocity < 0.0f) verticalVelocity = 0.0f;
else verticalVelocity *= std::max(0.0f, 1.0f - 3.0f * physicsDeltaTime);
} else {
verticalVelocity += gravity * physicsDeltaTime;
// Feather Fall / Slow Fall: cap downward terminal velocity to ~2 m/s
if (featherFallActive_ && verticalVelocity < -2.0f)
verticalVelocity = -2.0f;
} }
targetPos.z += verticalVelocity * physicsDeltaTime;
} // end !flyingActive_ ground physics
} // end !inWater
} else { } else {
// External follow (e.g., taxi): trust server position without grounding. // External follow (e.g., taxi): trust server position without grounding.
swimming = false; swimming = false;
@ -1180,7 +1265,10 @@ void CameraController::update(float deltaTime) {
dz >= -0.25f && dz <= stepUp * 1.5f); dz >= -0.25f && dz <= stepUp * 1.5f);
if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) { if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) {
targetPos.z = *groundH; // HOVER: float at fixed height above ground instead of standing on it
static constexpr float HOVER_HEIGHT = 4.0f; // ~4 yards above ground
const float snapH = hoverActive_ ? (*groundH + HOVER_HEIGHT) : *groundH;
targetPos.z = snapH;
verticalVelocity = 0.0f; verticalVelocity = 0.0f;
grounded = true; grounded = true;
lastGroundZ = *groundH; lastGroundZ = *groundH;
@ -1495,7 +1583,8 @@ void CameraController::update(float deltaTime) {
if (inWater) { if (inWater) {
swimming = true; swimming = true;
float swimSpeed = speed * SWIM_SPEED_FACTOR; float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_))
? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR;
float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z; float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z;
bool diveIntent = nowForward && (forward3D.z < -0.28f); bool diveIntent = nowForward && (forward3D.z < -0.28f);
@ -1707,6 +1796,35 @@ void CameraController::update(float deltaTime) {
} }
} }
// Flight ascend/descend transitions (Space = ascend, X = descend while mounted+flying)
if (movementCallback && !externalFollow_) {
const bool nowAscending = flyingActive_ && spaceDown;
const bool nowDescending = flyingActive_ && xDown && mounted_;
if (flyingActive_) {
if (nowAscending && !wasAscending_) {
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_ASCEND));
} else if (!nowAscending && wasAscending_) {
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_ASCEND));
}
if (nowDescending && !wasDescending_) {
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_START_DESCEND));
} else if (!nowDescending && wasDescending_) {
// No separate STOP_DESCEND opcode; STOP_ASCEND ends all vertical movement
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_ASCEND));
}
} else {
// Left flight mode: clear any lingering vertical movement states
if (wasAscending_) {
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_ASCEND));
} else if (wasDescending_) {
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_STOP_ASCEND));
}
}
wasAscending_ = nowAscending;
wasDescending_ = nowDescending;
}
// Update previous-frame state // Update previous-frame state
wasSwimming = swimming; wasSwimming = swimming;
wasMovingForward = nowForward; wasMovingForward = nowForward;
@ -2096,5 +2214,28 @@ void CameraController::triggerMountJump() {
} }
} }
void CameraController::applyKnockBack(float vcos, float vsin, float hspeed, float vspeed) {
// The server sends (vcos, vsin) as the 2D direction vector in server/wire
// coordinate space. After the server→canonical→render swaps, the direction
// in render space is simply (vcos, vsin) — the two swaps cancel each other.
knockbackHorizVel_ = glm::vec2(vcos, vsin) * hspeed;
knockbackActive_ = true;
// vspeed in the wire packet is negative when the server wants to launch the
// player upward (matches TrinityCore: data << float(-speedZ)). Negate it
// here to obtain the correct upward initial velocity.
verticalVelocity = -vspeed;
grounded = false;
coyoteTimer = 0.0f;
jumpBufferTimer = 0.0f;
// Notify the server that the player left the ground so the FALLING flag is
// set in subsequent movement heartbeats. The normal jump detection
// (nowJump && grounded) does not fire during a server-driven knockback.
if (movementCallback) {
movementCallback(static_cast<uint32_t>(game::Opcode::MSG_MOVE_JUMP));
}
}
} // namespace rendering } // namespace rendering
} // namespace wowee } // namespace wowee

View file

@ -2096,8 +2096,8 @@ void Renderer::updateCharacterAnimation() {
// Rider uses character facing yaw, not mount bone rotation // Rider uses character facing yaw, not mount bone rotation
// (rider faces character direction, seat bone only provides position) // (rider faces character direction, seat bone only provides position)
float yawRad = glm::radians(characterYaw); float yawRad = glm::radians(characterYaw);
float riderPitch = taxiFlight_ ? mountPitch_ * 0.35f : 0.0f; float riderPitch = mountPitch_ * 0.35f;
float riderRoll = taxiFlight_ ? mountRoll_ * 0.35f : 0.0f; float riderRoll = mountRoll_ * 0.35f;
characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad)); characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad));
} else { } else {
// Fallback to old manual positioning if attachment not found // Fallback to old manual positioning if attachment not found
@ -4737,7 +4737,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
auto t0 = std::chrono::steady_clock::now(); auto t0 = std::chrono::steady_clock::now();
VkCommandBuffer cmd = beginSecondary(SEC_WMO); VkCommandBuffer cmd = beginSecondary(SEC_WMO);
setSecondaryViewportScissor(cmd); setSecondaryViewportScissor(cmd);
wmoRenderer->render(cmd, perFrameSet, *camera); wmoRenderer->render(cmd, perFrameSet, *camera, &characterPosition);
vkEndCommandBuffer(cmd); vkEndCommandBuffer(cmd);
return std::chrono::duration<double, std::milli>( return std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - t0).count(); std::chrono::steady_clock::now() - t0).count();
@ -4905,7 +4905,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) {
if (wmoRenderer && camera && !skipWMO) { if (wmoRenderer && camera && !skipWMO) {
wmoRenderer->prepareRender(); wmoRenderer->prepareRender();
auto wmoStart = std::chrono::steady_clock::now(); auto wmoStart = std::chrono::steady_clock::now();
wmoRenderer->render(currentCmd, perFrameSet, *camera); wmoRenderer->render(currentCmd, perFrameSet, *camera, &characterPosition);
lastWMORenderMs = std::chrono::duration<double, std::milli>( lastWMORenderMs = std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - wmoStart).count(); std::chrono::steady_clock::now() - wmoStart).count();
} }

View file

@ -1356,7 +1356,8 @@ void WMORenderer::prepareRender() {
} }
} }
void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera,
const glm::vec3* viewerPos) {
if (!opaquePipeline_ || instances.empty()) { if (!opaquePipeline_ || instances.empty()) {
lastDrawCalls = 0; lastDrawCalls = 0;
return; return;
@ -1380,6 +1381,11 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
} }
glm::vec3 camPos = camera.getPosition(); glm::vec3 camPos = camera.getPosition();
// For portal culling, use the character/player position when available.
// The 3rd-person camera can orbit outside a WMO while the character is inside,
// causing the portal traversal to start from outside and cull interior groups.
// Passing the actual character position as the viewer fixes this.
glm::vec3 portalViewerPos = viewerPos ? *viewerPos : camPos;
bool doPortalCull = portalCulling; bool doPortalCull = portalCulling;
bool doDistanceCull = distanceCulling; bool doDistanceCull = distanceCulling;
@ -1400,7 +1406,7 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const
bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty();
if (usePortalCulling) { if (usePortalCulling) {
std::unordered_set<uint32_t> pvgSet; std::unordered_set<uint32_t> pvgSet;
glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(portalViewerPos, 1.0f);
getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum,
instance.modelMatrix, pvgSet); instance.modelMatrix, pvgSet);
portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end()); portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end());
@ -2049,12 +2055,25 @@ bool WMORenderer::isPortalVisible(const ModelData& model, uint16_t portalIndex,
} }
center /= static_cast<float>(portal.vertexCount); center /= static_cast<float>(portal.vertexCount);
// Transform bounds to world space for frustum test // Transform all 8 corners to world space to build the correct world AABB.
glm::vec4 worldMin = modelMatrix * glm::vec4(pMin, 1.0f); // Direct transform of pMin/pMax is wrong for rotated WMOs — the matrix can
glm::vec4 worldMax = modelMatrix * glm::vec4(pMax, 1.0f); // swap or negate components, inverting min/max and causing frustum test failures.
const glm::vec3 corners[8] = {
{pMin.x, pMin.y, pMin.z}, {pMax.x, pMin.y, pMin.z},
{pMin.x, pMax.y, pMin.z}, {pMax.x, pMax.y, pMin.z},
{pMin.x, pMin.y, pMax.z}, {pMax.x, pMin.y, pMax.z},
{pMin.x, pMax.y, pMax.z}, {pMax.x, pMax.y, pMax.z},
};
glm::vec3 worldMin( std::numeric_limits<float>::max());
glm::vec3 worldMax(-std::numeric_limits<float>::max());
for (const auto& c : corners) {
glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f));
worldMin = glm::min(worldMin, wc);
worldMax = glm::max(worldMax, wc);
}
// Check if portal AABB intersects frustum (more robust than point test) // Check if portal AABB intersects frustum (more robust than point test)
return frustum.intersectsAABB(glm::vec3(worldMin), glm::vec3(worldMax)); return frustum.intersectsAABB(worldMin, worldMax);
} }
void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model,

File diff suppressed because it is too large Load diff

View file

@ -1594,6 +1594,20 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite
IM_COL32(255, 255, 255, 220), countStr); IM_COL32(255, 255, 255, 220), countStr);
} }
// Durability bar on equipment slots (3px strip at bottom of slot icon)
if (kind == SlotKind::EQUIPMENT && item.maxDurability > 0) {
float durPct = static_cast<float>(item.curDurability) /
static_cast<float>(item.maxDurability);
ImU32 durCol;
if (durPct > 0.5f) durCol = IM_COL32(0, 200, 0, 220);
else if (durPct > 0.25f) durCol = IM_COL32(220, 220, 0, 220);
else durCol = IM_COL32(220, 40, 40, 220);
float barW = size * durPct;
drawList->AddRectFilled(ImVec2(pos.x, pos.y + size - 3.0f),
ImVec2(pos.x + barW, pos.y + size),
durCol);
}
ImGui::InvisibleButton("slot", ImVec2(size, size)); ImGui::InvisibleButton("slot", ImVec2(size, size));
// Left mouse: hold to pick up, release to drop/swap // Left mouse: hold to pick up, release to drop/swap
@ -1700,6 +1714,18 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
ImVec4 qColor = getQualityColor(item.quality); ImVec4 qColor = getQualityColor(item.quality);
ImGui::TextColored(qColor, "%s", item.name.c_str()); ImGui::TextColored(qColor, "%s", item.name.c_str());
if (item.itemLevel > 0) {
ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel);
}
// Binding type
switch (item.bindType) {
case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break;
case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break;
case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break;
case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break;
default: break;
}
if (item.itemId == 6948 && gameHandler_) { if (item.itemId == 6948 && gameHandler_) {
uint32_t mapId = 0; uint32_t mapId = 0;
@ -1776,13 +1802,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
}; };
const bool isWeapon = isWeaponInventoryType(item.inventoryType); const bool isWeapon = isWeaponInventoryType(item.inventoryType);
// Compact stats view for weapons: DPS + condensed stat bonuses. // Compact stats view for weapons: damage range + speed + DPS
// Non-weapons keep armor/sell info visible.
ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f);
if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) { if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) {
float speed = static_cast<float>(item.delayMs) / 1000.0f; float speed = static_cast<float>(item.delayMs) / 1000.0f;
float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed; float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed;
ImGui::Text("%.1f DPS", dps); ImGui::Text("%.0f - %.0f Damage", item.damageMin, item.damageMax);
ImGui::SameLine(160.0f);
ImGui::TextDisabled("Speed %.2f", speed);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps);
} }
// Armor appears before stat bonuses — matches WoW tooltip order // Armor appears before stat bonuses — matches WoW tooltip order
@ -1805,6 +1833,101 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
if (!bonusLine.empty()) { if (!bonusLine.empty()) {
ImGui::TextColored(green, "%s", bonusLine.c_str()); ImGui::TextColored(green, "%s", bonusLine.c_str());
} }
// Extra stats (hit, crit, haste, AP, SP, etc.) — one line each
for (const auto& es : item.extraStats) {
const char* statName = nullptr;
switch (es.statType) {
case 0: statName = "Mana"; break;
case 1: statName = "Health"; break;
case 12: statName = "Defense Rating"; break;
case 13: statName = "Dodge Rating"; break;
case 14: statName = "Parry Rating"; break;
case 15: statName = "Block Rating"; break;
case 16: statName = "Hit Rating"; break;
case 17: statName = "Hit Rating"; break;
case 18: statName = "Hit Rating"; break;
case 19: statName = "Crit Rating"; break;
case 20: statName = "Crit Rating"; break;
case 21: statName = "Crit Rating"; break;
case 28: statName = "Haste Rating"; break;
case 29: statName = "Haste Rating"; break;
case 30: statName = "Haste Rating"; break;
case 31: statName = "Hit Rating"; break;
case 32: statName = "Crit Rating"; break;
case 35: statName = "Resilience"; break;
case 36: statName = "Haste Rating"; break;
case 37: statName = "Expertise Rating"; break;
case 38: statName = "Attack Power"; break;
case 39: statName = "Ranged Attack Power"; break;
case 41: statName = "Healing Power"; break;
case 42: statName = "Spell Damage"; break;
case 43: statName = "Mana per 5 sec"; break;
case 44: statName = "Armor Penetration"; break;
case 45: statName = "Spell Power"; break;
case 46: statName = "Health per 5 sec"; break;
case 47: statName = "Spell Penetration"; break;
case 48: statName = "Block Value"; break;
default: statName = nullptr; break;
}
char buf[64];
if (statName) {
std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName);
} else {
std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType);
}
ImGui::TextColored(green, "%s", buf);
}
if (item.requiredLevel > 1) {
ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel);
}
if (item.maxDurability > 0) {
float durPct = static_cast<float>(item.curDurability) / static_cast<float>(item.maxDurability);
ImVec4 durColor;
if (durPct > 0.5f) durColor = ImVec4(0.1f, 1.0f, 0.1f, 1.0f); // green
else if (durPct > 0.25f) durColor = ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // yellow
else durColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); // red
ImGui::TextColored(durColor, "Durability %u / %u",
item.curDurability, item.maxDurability);
}
// Item spell effects (Use/Equip/Chance on Hit)
if (gameHandler_) {
auto* info = gameHandler_->getItemInfo(item.itemId);
if (info) {
for (const auto& sp : info->spells) {
if (sp.spellId == 0) continue;
const char* trigger = nullptr;
switch (sp.spellTrigger) {
case 0: trigger = "Use"; break;
case 1: trigger = "Equip"; break;
case 2: trigger = "Chance on Hit"; break;
case 6: trigger = "Soulstone"; break;
default: break;
}
if (!trigger) continue;
const std::string& spName = gameHandler_->getSpellName(sp.spellId);
if (!spName.empty()) {
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f),
"%s: %s", trigger, spName.c_str());
} else {
ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f),
"%s: Spell #%u", trigger, sp.spellId);
}
}
}
}
// "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");
}
// Flavor / lore text (italic yellow in WoW, just yellow here)
if (!item.description.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", item.description.c_str());
}
if (item.sellPrice > 0) { if (item.sellPrice > 0) {
uint32_t g = item.sellPrice / 10000; uint32_t g = item.sellPrice / 10000;
uint32_t s = (item.sellPrice / 100) % 100; uint32_t s = (item.sellPrice / 100) % 100;
@ -1824,23 +1947,79 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I
} }
ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str());
if (isWeaponInventoryType(eq->item.inventoryType) && // Helper: render a numeric stat diff line
eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { auto showDiff = [](const char* label, float newVal, float eqVal) {
float speed = static_cast<float>(eq->item.delayMs) / 1000.0f; if (newVal == 0.0f && eqVal == 0.0f) return;
float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; float diff = newVal - eqVal;
ImGui::Text("%.1f DPS", dps); char buf[128];
if (diff > 0.0f) {
std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, newVal, diff);
ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "%s", buf);
} else if (diff < 0.0f) {
std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, newVal, -diff);
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", buf);
} else {
std::snprintf(buf, sizeof(buf), "%s: %.0f", label, newVal);
ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf);
}
};
// DPS comparison for weapons
if (isWeaponInventoryType(item.inventoryType) && isWeaponInventoryType(eq->item.inventoryType)) {
float newDps = 0.0f, eqDps = 0.0f;
if (item.damageMax > 0.0f && item.delayMs > 0)
newDps = ((item.damageMin + item.damageMax) * 0.5f) / (item.delayMs / 1000.0f);
if (eq->item.damageMax > 0.0f && eq->item.delayMs > 0)
eqDps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / (eq->item.delayMs / 1000.0f);
showDiff("DPS", newDps, eqDps);
} }
if (eq->item.armor > 0) {
ImGui::Text("%d Armor", eq->item.armor); // Armor
showDiff("Armor", static_cast<float>(item.armor), static_cast<float>(eq->item.armor));
// Primary stats
showDiff("Str", static_cast<float>(item.strength), static_cast<float>(eq->item.strength));
showDiff("Agi", static_cast<float>(item.agility), static_cast<float>(eq->item.agility));
showDiff("Sta", static_cast<float>(item.stamina), static_cast<float>(eq->item.stamina));
showDiff("Int", static_cast<float>(item.intellect), static_cast<float>(eq->item.intellect));
showDiff("Spi", static_cast<float>(item.spirit), static_cast<float>(eq->item.spirit));
// Extra stats diff — union of stat types from both items
auto findExtraStat = [](const game::ItemDef& it, uint32_t type) -> int32_t {
for (const auto& es : it.extraStats)
if (es.statType == type) return es.statValue;
return 0;
};
// Collect all extra stat types
std::vector<uint32_t> allTypes;
for (const auto& es : item.extraStats) allTypes.push_back(es.statType);
for (const auto& es : eq->item.extraStats) {
bool found = false;
for (uint32_t t : allTypes) if (t == es.statType) { found = true; break; }
if (!found) allTypes.push_back(es.statType);
} }
std::string eqBonusLine; for (uint32_t t : allTypes) {
appendBonus(eqBonusLine, eq->item.strength, "Str"); int32_t nv = findExtraStat(item, t);
appendBonus(eqBonusLine, eq->item.agility, "Agi"); int32_t ev = findExtraStat(eq->item, t);
appendBonus(eqBonusLine, eq->item.stamina, "Sta"); // Find a label for this stat type
appendBonus(eqBonusLine, eq->item.intellect, "Int"); const char* lbl = nullptr;
appendBonus(eqBonusLine, eq->item.spirit, "Spi"); switch (t) {
if (!eqBonusLine.empty()) { case 31: lbl = "Hit"; break;
ImGui::TextColored(green, "%s", eqBonusLine.c_str()); case 32: lbl = "Crit"; break;
case 35: lbl = "Resilience"; break;
case 36: lbl = "Haste"; break;
case 37: lbl = "Expertise"; break;
case 38: lbl = "Attack Power"; break;
case 39: lbl = "Ranged AP"; break;
case 43: lbl = "MP5"; break;
case 44: lbl = "Armor Pen"; break;
case 45: lbl = "Spell Power"; break;
case 46: lbl = "HP5"; break;
case 48: lbl = "Block Value"; break;
default: lbl = nullptr; break;
}
if (!lbl) continue;
showDiff(lbl, static_cast<float>(nv), static_cast<float>(ev));
} }
} }
} }

View file

@ -45,18 +45,66 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
} }
uint32_t fieldCount = dbc->getFieldCount(); uint32_t fieldCount = dbc->getFieldCount();
if (fieldCount < 154) { // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~220+ (SchoolMask at 215), WotLK has 234.
LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, expected 234+"); // Require at least 148 fields so all expansions can load spell names/icons via the DBC layout.
if (fieldCount < 148) {
LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, too few to load");
return; return;
} }
const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr;
// Load SpellCastTimes.dbc: field 0=ID, field 1=Base(ms), field 2=PerLevel, field 3=Minimum
std::unordered_map<uint32_t, uint32_t> castTimeMap; // index → base ms
auto castTimeDbc = assetManager->loadDBC("SpellCastTimes.dbc");
if (castTimeDbc && castTimeDbc->isLoaded()) {
for (uint32_t i = 0; i < castTimeDbc->getRecordCount(); ++i) {
uint32_t id = castTimeDbc->getUInt32(i, 0);
int32_t base = static_cast<int32_t>(castTimeDbc->getUInt32(i, 1));
if (id > 0 && base > 0)
castTimeMap[id] = static_cast<uint32_t>(base);
}
}
// Load SpellRange.dbc. Field layout differs by expansion:
// Classic 1.12: 0=ID, 1=MinRange, 2=MaxRange, 3=Flags, 4+=strings
// TBC / WotLK: 0=ID, 1=MinRangeFriendly, 2=MinRangeHostile,
// 3=MaxRangeFriendly, 4=MaxRangeHostile, 5=Flags, 6+=strings
// The correct field is declared in each expansion's dbc_layouts.json.
uint32_t spellRangeMaxField = 4; // WotLK / TBC default: MaxRangeHostile
const auto* spellRangeL = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("SpellRange")
: nullptr;
if (spellRangeL) {
try { spellRangeMaxField = (*spellRangeL)["MaxRange"]; } catch (...) {}
}
std::unordered_map<uint32_t, float> rangeMap; // index → max yards
auto rangeDbc = assetManager->loadDBC("SpellRange.dbc");
if (rangeDbc && rangeDbc->isLoaded()) {
uint32_t rangeFieldCount = rangeDbc->getFieldCount();
if (rangeFieldCount > spellRangeMaxField) {
for (uint32_t i = 0; i < rangeDbc->getRecordCount(); ++i) {
uint32_t id = rangeDbc->getUInt32(i, 0);
float maxRange = rangeDbc->getFloat(i, spellRangeMaxField);
if (id > 0 && maxRange > 0.0f)
rangeMap[id] = maxRange;
}
}
}
// schoolField / isSchoolEnum are declared before the lambda so the WotLK fallback path
// can override them before the second tryLoad call.
uint32_t schoolField_ = UINT32_MAX;
bool isSchoolEnum_ = false;
auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField, auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField,
uint32_t nameField, uint32_t rankField, uint32_t tooltipField, uint32_t nameField, uint32_t rankField, uint32_t tooltipField,
uint32_t powerTypeField, uint32_t manaCostField,
uint32_t castTimeIndexField, uint32_t rangeIndexField,
const char* label) { const char* label) {
spellData.clear(); spellData.clear();
uint32_t count = dbc->getRecordCount(); uint32_t count = dbc->getRecordCount();
const uint32_t fc = dbc->getFieldCount();
for (uint32_t i = 0; i < count; ++i) { for (uint32_t i = 0; i < count; ++i) {
uint32_t spellId = dbc->getUInt32(i, idField); uint32_t spellId = dbc->getUInt32(i, idField);
if (spellId == 0) continue; if (spellId == 0) continue;
@ -66,8 +114,31 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
info.attributes = dbc->getUInt32(i, attrField); info.attributes = dbc->getUInt32(i, attrField);
info.iconId = dbc->getUInt32(i, iconField); info.iconId = dbc->getUInt32(i, iconField);
info.name = dbc->getString(i, nameField); info.name = dbc->getString(i, nameField);
info.rank = dbc->getString(i, rankField); if (rankField < fc) info.rank = dbc->getString(i, rankField);
info.description = dbc->getString(i, tooltipField); if (tooltipField < fc) info.description = dbc->getString(i, tooltipField);
// Optional fields: only read if field index is valid for this DBC version
if (powerTypeField < fc) info.powerType = dbc->getUInt32(i, powerTypeField);
if (manaCostField < fc) info.manaCost = dbc->getUInt32(i, manaCostField);
if (castTimeIndexField < fc) {
uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField);
if (ctIdx > 0) {
auto ctIt = castTimeMap.find(ctIdx);
if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second;
}
}
if (rangeIndexField < fc) {
uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField);
if (rangeIdx > 0) {
auto rangeIt = rangeMap.find(rangeIdx);
if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast<uint32_t>(rangeIt->second);
}
}
if (schoolField_ < fc) {
uint32_t raw = dbc->getUInt32(i, schoolField_);
// Classic/Turtle use a 0-6 school enum; TBC/WotLK use a bitmask.
// enum→mask: schoolEnum N maps to bit (1u << N), e.g. 0→1 (physical), 4→16 (frost).
info.schoolMask = isSchoolEnum_ ? (raw <= 6 ? (1u << raw) : 0u) : raw;
}
if (!info.name.empty()) { if (!info.name.empty()) {
spellData[spellId] = std::move(info); spellData[spellId] = std::move(info);
@ -77,21 +148,51 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) {
}; };
if (spellL) { if (spellL) {
uint32_t tooltipField = 139; // Default to UINT32_MAX for optional fields; tryLoad will skip them if >= fieldCount.
// Try to get Tooltip field from layout, fall back to 139 // Avoids reading wrong data from expansion DBCs that lack these fields (e.g. Classic/TBC).
try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} uint32_t tooltipField = UINT32_MAX;
uint32_t powerTypeField = UINT32_MAX;
uint32_t manaCostField = UINT32_MAX;
uint32_t castTimeIdxField = UINT32_MAX;
uint32_t rangeIdxField = UINT32_MAX;
try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {}
try { powerTypeField = (*spellL)["PowerType"]; } catch (...) {}
try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {}
try { castTimeIdxField = (*spellL)["CastingTimeIndex"]; } catch (...) {}
try { rangeIdxField = (*spellL)["RangeIndex"]; } catch (...) {}
// Try SchoolMask (TBC/WotLK bitmask) then SchoolEnum (Classic/Turtle 0-6 value)
schoolField_ = UINT32_MAX;
isSchoolEnum_ = false;
try { schoolField_ = (*spellL)["SchoolMask"]; } catch (...) {}
if (schoolField_ == UINT32_MAX) {
try { schoolField_ = (*spellL)["SchoolEnum"]; isSchoolEnum_ = true; } catch (...) {}
}
tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"],
(*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout"); (*spellL)["Name"], (*spellL)["Rank"], tooltipField,
powerTypeField, manaCostField, castTimeIdxField, rangeIdxField,
"expansion layout");
} }
if (spellData.empty() && fieldCount >= 200) { if (spellData.empty() && fieldCount >= 200) {
LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)");
tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback"); // WotLK Spell.dbc field indices (verified against 3.3.5a schema); SchoolMask at field 225
schoolField_ = 225;
isSchoolEnum_ = false;
tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback");
} }
dbcLoaded = !spellData.empty(); dbcLoaded = !spellData.empty();
} }
bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler,
pipeline::AssetManager* assetManager) {
if (!dbcLoadAttempted) loadSpellDBC(assetManager);
const SpellInfo* info = getSpellInfo(spellId);
if (!info) return false;
renderSpellTooltip(info, gameHandler);
return true;
}
std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager) { std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager) {
if (!dbcLoadAttempted) { if (!dbcLoadAttempted) {
loadSpellDBC(assetManager); loadSpellDBC(assetManager);
@ -363,6 +464,81 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive"); ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive");
} }
// Spell school — only show for non-physical schools (physical is the default/implicit)
if (info->schoolMask != 0 && info->schoolMask != 1 /*physical*/) {
struct SchoolEntry { uint32_t mask; const char* name; ImVec4 color; };
static constexpr SchoolEntry kSchools[] = {
{ 2, "Holy", { 1.0f, 1.0f, 0.6f, 1.0f } },
{ 4, "Fire", { 1.0f, 0.5f, 0.1f, 1.0f } },
{ 8, "Nature", { 0.4f, 0.9f, 0.3f, 1.0f } },
{ 16, "Frost", { 0.5f, 0.8f, 1.0f, 1.0f } },
{ 32, "Shadow", { 0.7f, 0.4f, 1.0f, 1.0f } },
{ 64, "Arcane", { 0.9f, 0.5f, 1.0f, 1.0f } },
};
bool first = true;
for (const auto& s : kSchools) {
if (info->schoolMask & s.mask) {
if (!first) ImGui::SameLine(0, 0);
if (first) {
ImGui::TextColored(s.color, "%s", s.name);
first = false;
} else {
ImGui::SameLine(0, 2);
ImGui::TextColored(s.color, "/%s", s.name);
}
}
}
}
// Resource cost + cast time on same row (WoW style)
if (!info->isPassive()) {
// Left: resource cost
char costBuf[64] = "";
if (info->manaCost > 0) {
const char* powerName = "Mana";
switch (info->powerType) {
case 1: powerName = "Rage"; break;
case 3: powerName = "Energy"; break;
case 4: powerName = "Focus"; break;
default: break;
}
std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName);
}
// Right: cast time
char castBuf[32] = "";
if (info->castTimeMs == 0) {
std::snprintf(castBuf, sizeof(castBuf), "Instant cast");
} else {
float secs = info->castTimeMs / 1000.0f;
std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs);
}
if (costBuf[0] || castBuf[0]) {
float wrapW = 320.0f;
if (costBuf[0] && castBuf[0]) {
float castW = ImGui::CalcTextSize(castBuf).x;
ImGui::Text("%s", costBuf);
ImGui::SameLine(wrapW - castW);
ImGui::Text("%s", castBuf);
} else if (castBuf[0]) {
ImGui::Text("%s", castBuf);
} else {
ImGui::Text("%s", costBuf);
}
}
// Range
if (info->rangeIndex > 0) {
char rangeBuf[32];
if (info->rangeIndex <= 5)
std::snprintf(rangeBuf, sizeof(rangeBuf), "Melee range");
else
std::snprintf(rangeBuf, sizeof(rangeBuf), "%u yd range", info->rangeIndex);
ImGui::Text("%s", rangeBuf);
}
}
// Cooldown if active // Cooldown if active
float cd = gameHandler.getSpellCooldown(info->spellId); float cd = gameHandler.getSpellCooldown(info->spellId);
if (cd > 0.0f) { if (cd > 0.0f) {