Compare commits

...

67 commits

Author SHA1 Message Date
Kelsi
785df23f1b net: dispatch flying movement opcodes (pitch up/down, ascend/descend) for other players
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
MSG_MOVE_START_PITCH_UP, MSG_MOVE_START_PITCH_DOWN, MSG_MOVE_STOP_PITCH,
MSG_MOVE_START_ASCEND, MSG_MOVE_STOP_ASCEND were defined in the opcode
table but never routed. The server relays these when another player
pitches or ascends/descends while flying. Without dispatch, position
updates embedded in these packets were silently dropped, causing flying
players to appear to not move vertically. Also adds these to the
compressed-moves opcode recognition array.
2026-03-10 11:29:13 -07:00
Kelsi
274419584e net: add MSG_MOVE_SET_WALK/RUN_MODE to compressed-moves batch dispatch
handleCompressedMoves uses a hardcoded opcode array to recognise which
sub-packets should be routed to handleOtherPlayerMovement. The two newly
dispatched opcodes were not in this list, so walk/run mode transitions
embedded in SMSG_COMPRESSED_MOVES / SMSG_MULTIPLE_MOVES batches were
silently dropped.
2026-03-10 11:25:58 -07:00
Kelsi
d96a87aafc net: dispatch MSG_MOVE_SET_WALK_MODE and MSG_MOVE_SET_RUN_MODE through handleOtherPlayerMovement
These two opcodes were defined in the opcode table but never routed to
handleOtherPlayerMovement. The server sends them when another player
explicitly toggles walk/run mode. Without dispatch, the WALKING flag
from these packets was never processed, so other players appeared to
always run even after switching to walk mode (until the next heartbeat).
2026-03-10 11:24:15 -07:00
Kelsi
ec665bae25 net: store moveFlags in UpdateBlock for Classic and TBC parsers
Extends the cold-join fix (block.moveFlags) to the Classic and TBC
parseMovementBlock implementations so that SMSG_UPDATE_OBJECT CREATE
packets on Classic/TBC servers also initialise entity swim/walk state
from the spawn-time movement flags via unitMoveFlagsCallback_.
2026-03-10 11:21:13 -07:00
Kelsi
48d21f97bd net: correct pitch condition to use FLYING=0x01000000 not SPLINE_ELEVATION=0x02000000
The previous fix added SWIMMING (0x00200000) correctly but kept 0x02000000
which is SPLINE_ELEVATION (smooth vertical spline offset), not the FLYING
flag. WotLK 3.3.5a FLYING = 0x01000000; pitch should be read when SWIMMING
or FLYING are active. This corrects the condition and updates the comment.
2026-03-10 11:16:40 -07:00
Kelsi
863ea742f6 net/game: initialise entity swim/walk state from spawn packet and SPLINE_MOVE opcodes
UpdateBlock now stores moveFlags from the LIVING movement block so the
cold-join problem is fixed: entities already swimming or walking when the
client joins get their animation state correctly initialised from the
SMSG_UPDATE_OBJECT CREATE_OBJECT packet rather than waiting for the next
MSG_MOVE_* heartbeat.

Additionally, SMSG_SPLINE_MOVE_START_SWIM, SMSG_SPLINE_MOVE_STOP_SWIM,
SMSG_SPLINE_MOVE_SET_WALK_MODE, SMSG_SPLINE_MOVE_SET_RUN_MODE, and
SMSG_SPLINE_MOVE_SET_FLYING now fire unitMoveFlagsCallback_ with
synthesised flags so explicit server-driven mode transitions update
animation state immediately without waiting for a heartbeat.
2026-03-10 11:14:58 -07:00
Kelsi
3439df0333 net: fix UPDATEFLAG_LIVING pitch misalignment for swimming entities
parseMovementBlock was checking moveFlags & 0x02000000 for the pitch
field, but SWIMMING is 0x00200000 in WotLK 3.3.5a. Swimming NPCs and
players in SMSG_UPDATE_OBJECT packets never triggered the pitch read,
so the fallTime/jumpData/splineElevation fields were read from the wrong
offsets, producing incorrect positions and orientations.

Fix: check both SWIMMING (0x00200000) and FLYING (0x02000000), matching
the WotLK format — same condition used in the write path.
2026-03-10 11:03:33 -07:00
Kelsi
d7ebc5c8c7 game/rendering: drive Walk(4) and swim state from movement flags
Add UnitMoveFlagsCallback fired on every MSG_MOVE_* with the raw
movement flags field. Application.cpp uses it to update swimming
and walking state from any packet, not just explicit START_SWIM/
STOP_SWIM opcodes — fixing cold-join cases where a player is already
swimming when we enter the world.

Per-frame animation sync now selects Walk(4) when the WALKING flag is
set, Run(5) otherwise, and Swim(42)/SwimIdle(41) when swimming.
UnitAnimHintCallback is simplified to jump (38=JumpMid) only.
2026-03-10 10:55:23 -07:00
Kelsi
333ada8eb6 rendering/game: track per-entity swim state for correct water animations
- Add creatureSwimmingState_ map to track which units are swimming
- unitAnimHintCallback with animId=42 (Swim): marks entity as swimming
- unitAnimHintCallback with animId=0 (MSG_MOVE_STOP_SWIM): clears swim state
- Per-frame sync: uses Swim(42)/SwimIdle(41) when swimming, Run(5)/Stand(0) otherwise
  — creatures/players now show SwimIdle when standing still in water
- Clear creatureSwimmingState_ on creature/player despawn and world reset
2026-03-10 10:36:45 -07:00
Kelsi
14c2bc97b1 rendering/game: fix other-player movement animations and add jump/swim hints
- MSG_MOVE_STOP/STOP_STRAFE/STOP_TURN/STOP_SWIM/FALL_LAND: snap entity to
  stop position (duration=0) and pass durationMs=0 to renderer so the
  Run-animation flash is suppressed; per-frame sync plays Stand on next frame
- MSG_MOVE_JUMP: fire new UnitAnimHintCallback with anim 38 (JumpMid) so
  other players and NPCs visually leave the ground during jumps
- MSG_MOVE_START_SWIM: fire UnitAnimHintCallback with anim 42 (Swim)
- Wire up UnitAnimHintCallback in application.cpp; skips Death (anim 1)
2026-03-10 10:30:50 -07:00
Kelsi
137b25f318 rendering: fix NPC movement animation IDs and remove redundant player anim
The renderer's CharAnimState machine already drives player character
animations (Run=5, Walk=4, Jump, Swim, etc.) — remove the conflicting
camera controller code added in the previous commit.

Fix creature movement animations to use the correct WoW M2 IDs:
4=Walk, 5=Run. Both the per-frame sync loop and the SMSG_MONSTER_MOVE
spline callback now use Run (5) for NPC movement.
2026-03-10 10:19:13 -07:00
Kelsi
279c30367a rendering: use Walk (anim 5) vs Run (anim 4) based on movement pace
CameraController now plays Walk (5) for backpedal/slow pace and Run (4)
for forward running (runPace), matching WoW's animation convention.
Also handles transitions between Walk and Run while already moving.
2026-03-10 10:10:46 -07:00
Kelsi
baaa063342 rendering: play Run animation during SMSG_MONSTER_MOVE spline paths
creatureMoveCallback now plays anim 4 (Run) when a spline move begins
(durationMs > 0), mirroring the per-frame sync logic so NPC/player
characters animate correctly during server-driven path moves as well
as position-sync moves.
2026-03-10 10:09:02 -07:00
Kelsi
4e137c4061 rendering: drive Run/Stand animations from actual movement state
CameraController now transitions the player character to Run (anim 4)
on movement start and back to Stand (anim 0) on stop, guarded by a
prevPlayerMoving_ flag so animation time is not reset every frame.
Death animation (anim 1) is never overridden.

Application creature sync similarly switches creature models to Run (4)
when they move between server positions and Stand (0) when they stop,
with per-guid creatureWasMoving_ tracking to avoid per-frame resets.
2026-03-10 10:06:56 -07:00
Kelsi
c8d9d6b792 rendering/game: make player model semi-transparent in ghost form
Add GhostStateCallback to GameHandler, fired when PLAYER_FLAGS_GHOST
transitions on or off in UPDATE_OBJECT / login detection. Add
setInstanceOpacity() to CharacterRenderer to directly set opacity
without disturbing fade-in state. Application wires the callback to
set opacity 0.5 on ghost entry and 1.0 on resurrect.
2026-03-10 09:57:24 -07:00
Kelsi
366321042f rendering/game: sync camera sit state from server-confirmed stand state
Add CameraController::setSitting() and call it from the StandStateCallback
so the camera blocks movement when the server confirms the player is
sitting or kneeling (stand states 1-6, 8). This prevents the player
from sliding across the ground after sitting.

Death (state 7) deliberately leaves sitting=false so the player can
still respawn/move after death without input being blocked.
2026-03-10 09:51:15 -07:00
Kelsi
9f3c236c48 game/rendering: drive player stand-state animation from SMSG_STANDSTATE_UPDATE
Add StandStateCallback to GameHandler, fired when the server confirms
a stand state change (SMSG_STANDSTATE_UPDATE). Connect in Application
to map the WoW stand state (0-8) to M2 animation IDs on the player
character model:
  - 0 = Stand → anim 0 (Stand)
  - 1-6 = Sit variants → anim 27 (SitGround)
  - 7 = Dead → anim 1 (Death)
  - 8 = Kneel → anim 72 (Kneel)

Sit and Kneel animations are looped so the held-pose frame stays
visible; Death stays on the final frame.
2026-03-10 09:46:46 -07:00
Kelsi
59c50e3beb game/rendering: play SpellCast animation during SMSG_SPELL_START
Add SpellCastAnimCallback to GameHandler, triggered on SMSG_SPELL_START
(start=true) and cleared on SMSG_SPELL_GO / SMSG_SPELL_FAILURE
(start=false) for both the player and other units.

Connect the callback in Application to play animation 3 (SpellCast) on
the player character, NPCs, and other players when they begin a cast.
The cast animation is one-shot (loop=false) so it auto-returns to Stand
when complete via the existing return-to-idle logic.

Also fire stop-cast on spell failure to cancel any stuck cast pose.
2026-03-10 09:42:17 -07:00
Kelsi
c20d7c2638 rendering: return to Stand after one-shot emote animations complete
When a non-looping animation (e.g. wave, cheer, laugh emote) reaches
its end, transition back to Stand (animation 0) rather than freezing
on the last frame. Death (animation 1) is excluded — it stays on the
final frame as expected.

Fixes NPCs and players getting stuck in emote poses after SMSG_EMOTE.
2026-03-10 09:36:58 -07:00
Kelsi
60b93cdfd9 rendering/game: remove leftover debug dump I/O from hot paths
Remove active file-I/O debug block in character_renderer.cpp that
wrote composite textures as raw binary files to /tmp on every texture
composite generation. Remove the now-unused <fstream> include.

Remove the 10-shot hex dump of decompressed SMSG_MONSTER_MOVE payloads
in game_handler.cpp (dumpCount static); format has been confirmed.
2026-03-10 09:30:59 -07:00
Kelsi
55895340e9 game: connect emote animation callback to creature/player renderers
SMSG_EMOTE packets for NPCs and other players were received but the
emoteAnimCallback_ was never wired to the rendering layer.  Register
the callback in application.cpp so that when the server sends an emote
animation ID, the corresponding CharacterRenderer instance plays it as
a one-shot animation (loop=false), falling back to idle on completion.

Lookups check creatureInstances_ first, then playerInstances_ so both
NPCs and other online players respond to server emote packets.
2026-03-10 09:25:58 -07:00
Kelsi
b2dccca58c rendering: re-enable WMO camera collision with asymmetric smoothing
Previously disabled because the per-frame raycast caused erratic zoom
snapping at doorway transitions.  Re-enable using an asymmetrically-
smoothed collision limit: pull-in reacts quickly (τ≈60 ms) to prevent
the camera from ever visibly clipping through walls, while recovery is
slow (τ≈400 ms) so walking through a doorway zooms back out gradually
instead of snapping.

Uses wmoRenderer->raycastBoundingBoxes() which already has strict wall
filters (|normal.z|<0.20, surface-alignment check, ±0.9 height band)
to ignore floors, ramps, and arch geometry.
2026-03-10 09:13:31 -07:00
Kelsi
c622e547c9 game: clear in-flight NPC/GO query sets on disconnect
pendingCreatureQueries and pendingGameObjectQueries_ were never cleared on
disconnect. If a query was sent but the response lost (e.g. server
disconnect mid-flight), the entry would remain in the pending set after
reconnect, causing queryCreatureInfo/queryGameObjectInfo to silently skip
re-issuing the query — leaving NPC and GO names unpopulated for those
entries.

Clear both sets on disconnect so reconnect sees them as unqueried and
re-sends the queries as needed. creatureInfoCache/gameObjectInfoCache_ are
intentionally preserved across sessions to avoid re-querying entries whose
responses did arrive.
2026-03-10 09:01:34 -07:00
Kelsi
6763cfcda0 game: fix log level for GameObject CREATE messages (WARNING → DEBUG)
Every GameObject CREATE block was logged at WARNING level, spamming the
warning log with doors, chests, and other world objects. Demote to DEBUG
since this is routine spawn traffic; keep transport detection at INFO since
those are noteworthy.
2026-03-10 09:00:17 -07:00
Kelsi
4f51103cdb game: clear permanent-failure and dead-creature caches on same-map reconnect
After reconnect, `creaturePermanentFailureGuids_` and `deadCreatureGuids_`
could retain stale entries for GUIDs not tracked in `creatureInstances_`
(creatures that failed to load or died before being spawned).  These stale
entries would silently block re-spawning or cause wrong death state on the
fresh CREATE_OBJECTs the server sends after reconnect.

Clear both caches in the reconnect-to-same-map path so server state is
authoritative after every reconnect.
2026-03-10 08:55:23 -07:00
Kelsi
a06ac018ea game: use targeted entity cleanup on reconnect to same map, preserving terrain
The previous reconnect fix caused loadOnlineWorldTerrain to run, which
cleared and reloaded all terrain tiles — unnecessarily heavy for a
reconnect where the map hasn't changed.

New path: when isInitialEntry=true and mapId==loadedMapId_, despawn all
tracked creature/player/GO instances from the renderer (proper cleanup),
clear all pending spawn queues, update player position, and return — the
terrain stays loaded and the server's fresh CREATE_OBJECTs repopulate
entities normally.
2026-03-10 08:50:25 -07:00
Kelsi
d22f4b30ac game: process partial UPDATE_OBJECT packets when a block parse fails
Previously, if any single block in an SMSG_UPDATE_OBJECT packet failed
to parse (e.g. unusual spline flags), the entire packet was dropped and
all entities in it were lost. On busy zones with many CREATE_OBJECTs in
one packet, one bad NPC movement block would silently suppress all NPCs
that followed it in the same packet.

- parseUpdateObject: break instead of return false on block failure,
  so already-parsed blocks are returned to the caller
- handleUpdateObject: fall through to process partial data when parsing
  returns false but some blocks were successfully parsed
2026-03-10 08:38:39 -07:00
Kelsi
54246345bb game: fix NPCs not spawning on reconnect to same map
On disconnect/reconnect to the same map, entityManager was not cleared
and creatureInstances_ still held old entries from the previous session.
When the server re-sent CREATE_OBJECT for the same GUIDs, the spawn
callback's early-return guard (creatureInstances_.count(guid)) silently
dropped every NPC re-spawn, leaving the world empty.

Fixes:
- disconnect() now calls entityManager.clear() to purge stale entities
- WorldEntryCallback gains a bool isInitialEntry parameter (true on first
  login or reconnect, false on in-world teleport/flight landing)
- Same-map optimization path skipped when isInitialEntry=true, so
  loadOnlineWorldTerrain runs its full cleanup and properly despawns old
  creature/player instances before the server refreshes them
2026-03-10 08:35:36 -07:00
Kelsi
baab997da8 ui: fix XP bar overlapping second action bar by positioning above both bars 2026-03-10 08:28:48 -07:00
Kelsi
0a157d3255 game: add area name cache from WorldMapArea.dbc for /who zone display and exploration messages
- Load WorldMapArea.dbc lazily on first use to build areaId→name lookup
- /who results now show [Zone Name] alongside level: 'Name - Level 70 [Stormwind City]'
- SMSG_EXPLORATION_EXPERIENCE now shows 'Discovered Elwynn Forest! Gained X experience.'
  instead of generic 'Discovered new area!' message when the zone name is available
- Cache is populated once per session and shared across both callsites
2026-03-10 08:06:21 -07:00
Kelsi
846ba58d2e ui,game: show creature names in quest kill count tracker and progress messages
Quest kill count tracker in the HUD now resolves creature names from the
cached creature query results and displays them as "Name: x/y" instead
of bare "x/y". The system chat progress message on kill also includes
the creature name when available, matching retail client behavior.
2026-03-10 07:45:53 -07:00
Kelsi
03c4d59592 game: fix talent state not resetting across login sessions
Replace static-local firstSpecReceived with talentsInitialized_ member
variable, reset in handleLoginVerifyWorld alongside other per-session
state. Also clear learnedTalents_, unspentTalentPoints_, and
activeTalentSpec_ at world entry so reconnects and character switches
start from a clean talent state instead of carrying over stale data.
2026-03-10 07:41:27 -07:00
Kelsi
2a9d26e1ea game,ui: add rest state tracking and rested XP bar overlay
- Track PLAYER_REST_STATE_EXPERIENCE update field for all expansions
  (WotLK=636, Classic=718, TBC=928, Turtle=718)
- Set isResting_ flag from SMSG_SET_REST_START packet
- XP bar shows rested bonus as a lighter purple overlay extending
  beyond the current fill to (currentXp + restedXp) position
- Tooltip text changes to "%u / %u XP  (+%u rested)" when bonus exists
- "zzz" indicator shown at bar right edge while resting
2026-03-10 07:35:30 -07:00
Kelsi
0ea8e55ad4 ui,game,pipeline: player nameplates always-on, level-up ring effect, vanilla tile fallback, warden null guard
- Nameplates: player names always rendered regardless of V-key toggle;
  separate cull distance 40u (players/target) vs 20u (NPCs); cyan name
  color for other players; fade alpha scales with cull distance
- Level-up: add expanding golden ring burst (3 staggered waves, 420u
  max radius) + full-screen flash to renderDingEffect(); M2 LevelUp.m2
  is still attempted as a bonus on top
- Vanilla tile loading: add AssetManager::setBaseFallbackPath() so that
  when the primary manifest is an expansion-specific DBC-only subset
  (e.g. Data/expansions/vanilla/), world terrain files fall back to
  the base Data/ extraction; wired in Application::initialize()
- Warden: map a null guard page at address 0x0 in the Unicorn emulator
  so NULL-pointer reads in the module don't crash with UC_ERR_MAP;
  execution continues past the NULL read for better diagnostics
2026-03-10 07:25:04 -07:00
Kelsi
3cdaf78369 game,warden,assets: fix unknown player names, warden heap overlap, and Vanilla Item.dbc
- game: clear pendingNameQueries on player out-of-range and DESTROY_OBJECT so
  re-entering players get a fresh name query instead of being silently skipped
- game: add 5s periodic name resync scan that re-queries players with empty names
  and no pending query, recovering from dropped CMSG_NAME_QUERY responses
- warden: fix UC_ERR_MAP by moving HEAP_BASE from 0x200000 to 0x20000000; the old
  heap [0x200000, 0x1200000) overlapped the module at 0x400000, causing Unicorn to
  reject the heap mapping and abort emulator initialisation
- warden: add early overlap check between module and heap regions to catch future
  layout bugs at init time
- assets: add loadDBCOptional() which logs at DEBUG level when a DBC is absent,
  for files that are not distributed on all expansions
- assets: use loadDBCOptional for Item.dbc (absent on Vanilla 1.12 clients) and
  fall back to server-sent itemInfoCache displayInfoId for NPC weapon resolution
2026-03-10 07:00:43 -07:00
Kelsi
dc2aab5e90 perf: limit NPC composite texture processing to 2ms per frame
processAsyncNpcCompositeResults() had no per-frame budget cap, so when
many NPCs finished async skin compositing simultaneously (e.g. right
after world load), all results were finalized in a single frame causing
up to 284ms frame stalls. Apply the same 2ms budget pattern used by
processAsyncCreatureResults. Load screen still processes all pending
composites without the cap (unlimited=true).
2026-03-10 06:47:33 -07:00
Kelsi
ac0fe1bd61 game: clear target auras when switching targets
setTarget() was not clearing targetAuras, leaving stale buff/debuff
icons from the previous target visible on the buff bar until the server
sent SMSG_AURA_UPDATE_ALL for the new target. Reset all slots to empty
on target change so the display is immediately correct.
2026-03-10 06:43:16 -07:00
Kelsi
1e53369869 game: fix player phantom model on SMSG_DESTROY_OBJECT
handleDestroyObject invoked creatureDespawnCallback_ and
gameObjectDespawnCallback_ but not playerDespawnCallback_ for PLAYER
entities. This caused the CharacterRenderer instance for nearby players
to remain alive after they received a DESTROY_OBJECT packet (e.g. when
they teleported or went out of range via server-forced despawn), leaving
phantom models in the world.

Mirror the same despawn logic used for out-of-range removal: call
playerDespawnCallback_ and clean up the per-player bookkeeping maps so
the renderer cleans up the character instance correctly.
2026-03-10 06:40:07 -07:00
Kelsi
ea9c7e68e7 rendering,ui: sync selection circle to renderer instance position
The selection circle was positioned using the entity's game-logic
interpolator (entity->getX/Y/Z), while the actual M2 model is
positioned by CharacterRenderer's independent interpolator (moveInstanceTo).
These two systems can drift apart during movement, causing the circle
to appear under the wrong position relative to the visible model.

Fix: add CharacterRenderer::getInstancePosition / Application::getRenderPositionForGuid
and use the renderer's inst.position for XY (with footZ override for Z)
so the circle always tracks the rendered model exactly. Falls back to
the entity game-logic position when no CharacterRenderer instance exists.
2026-03-10 06:33:44 -07:00
Kelsi
463e3f5ed1 game: fix party frame duplication and player name on entity re-creation
SMSG_GROUP_LIST is a full replacement packet, not a delta. handleGroupList()
was not resetting partyData before parsing, so repeated GROUP_LIST packets
pushed duplicate members onto the existing vector — a 2-player party would
show the same name 5 times if the packet was sent 5 times.

Fix: reset partyData = GroupListData{} before calling GroupListParser::parse().

Also fix player names staying "Unknown" when an entity moves out of range and
comes back: queryPlayerName() now applies the cached name to the new entity
object immediately instead of skipping when the name is already in cache.
This was causing other players' names to appear as unknown after zoning or
crossing render distance boundaries.
2026-03-10 06:21:05 -07:00
Kelsi
a2dd8ee5b5 game,ui: implement MSG_RAID_TARGET_UPDATE and display raid marks
Parse the full and single-update variants of MSG_RAID_TARGET_UPDATE to
track which guid carries each of the 8 raid icons (Star/Circle/Diamond/
Triangle/Moon/Square/Cross/Skull). Marks are cleared on world transfer.

The target frame now shows the Unicode symbol for the target's raid mark
in its faction color to the left of the name. Nameplates show the same
symbol to the left of the unit name for all nearby marked units.
2026-03-10 06:10:29 -07:00
Kelsi
90b8cccac5 ui,game: add second action bar (Shift+1-12 keybinds, slots 12-23)
Expand action bar from 12 to 24 slots (2 bars × 12). Bar 2 is rendered
above bar 1 and loaded from SMSG_ACTION_BUTTONS slots 12-23. Pressing
Shift+number activates the corresponding bar-2 slot. Drag-and-drop,
cooldown overlays, and tooltips work identically on both bars. Bar 2
fades slightly when all its slots are empty to minimize visual noise.
2026-03-10 06:04:43 -07:00
Kelsi
753790ae47 ui: show persistent zone name above minimap
Draws the current zone name centered above the minimap circle using a
gold-colored 12pt label with drop shadow. This gives players a constant
location reference without needing to trigger the full-screen zone flash.
Uses getForegroundDrawList so it renders above the minimap texture.
2026-03-10 05:52:55 -07:00
Kelsi
6f7363fbcb ui: click-to-target via nameplate hit testing
Left-clicking anywhere within a nameplate's bounding box (name text +
health bar) calls setTarget() for that unit. Uses manual mouse position
hit testing since nameplates are drawn on the background DrawList rather
than as ImGui widgets. Click is ignored when ImGui has captured the mouse
(e.g. when a window is open).
2026-03-10 05:50:26 -07:00
Kelsi
3fce3adb39 game: keep contacts_ in sync with SMSG_FRIEND_STATUS updates
When a friend goes online, offline, or is added/removed, update the
contacts_ vector in addition to friendsCache. This ensures the Friends
tab in the Social window always reflects the current state without
needing a full SMSG_CONTACT_LIST/SMSG_FRIEND_LIST refresh.
2026-03-10 05:48:37 -07:00
Kelsi
7cd8e86d3b game,ui: add ContactEntry struct and Friends tab in social frame
Store structured friend data (online status, level, area, class) that
was previously discarded in handleFriendList/handleContactList. New
ContactEntry struct lives in game_handler.hpp; getContacts() exposes it.

UI: the O-key Social window (formerly guild-only) now has a Friends tab.
- Shows online/offline status dot, name, level, and AFK/DND label
- Pressing O when not in a guild opens Social directly on the Friends tab
- The window title changed from "Guild" to "Social" for accuracy
- Non-guild players no longer get a "not in a guild" rejection on O press
2026-03-10 05:46:03 -07:00
Kelsi
f98cc32947 docs: update Discord invite link 2026-03-10 05:40:53 -07:00
Kelsi
bbf0c0b22c ui: show nameplates for all nearby units, not just target
The V toggle previously only rendered a nameplate for the currently
targeted unit. Now all nearby units get a nameplate when nameplates are
enabled, matching WoW's native behaviour:

- Target: nameplate shown up to 40 units, gold border highlight
- All other units: shown up to 20 units, dark border (no highlight)
- Fade-out range, hostility colour, level label, and health bar logic
  are all unchanged — only the per-entity distance culling changes
2026-03-10 05:38:52 -07:00
Kelsi
65c4bd1a17 ui: WoW-style clock-sweep cooldown overlay on action bar
Replace the plain yellow text cooldown overlay with a proper clock-sweep:
- Dark fan spanning the elapsed fraction of the cooldown, sweeping
  clockwise from 12 o'clock (matches WoW's native cooldown look)
- White remaining-time text with drop-shadow centered on the icon
- Minutes shown as "Xm" for cooldowns >= 60s, seconds otherwise
- Fan radius set to 1.5× the icon half-width to cover corners on the
  square icon; works for both icon and empty (label-only) slots
2026-03-10 05:36:59 -07:00
Kelsi
983c64720d ui: show party member dots on minimap
Draw a dot for each online party member that has reported a position via
SMSG_PARTY_MEMBER_STATS. Leader gets a gold dot, others get blue. A
white outline ring is drawn around each dot, and hovering over it shows
the member's name as a tooltip. Out-of-range members are silently
skipped by the existing projectToMinimap clamp logic.

Axis mapping follows the same convention as minimap pings: server posX
(east/west) → canonical Y, server posY (north/south) → canonical X.
2026-03-10 05:33:21 -07:00
Kelsi
962c640ff5 ui: add raid frames, quest log scroll-to-quest, and quest tracking polish
Raid frames:
- When groupType=1 (raid), render compact grid-style raid frames instead
  of the vertical party list that would overflow for 25/40-man groups
- Members organized by subgroup (G1-G8), up to 5 rows per subgroup column
- Each cell shows: name, health bar (green/yellow/red), power bar (class color)
- Clicking a cell targets the member; border highlight for current target
- Frames anchored above action bar area, centered horizontally

Quest log scroll-to-quest:
- openAndSelectQuest(questId) selects the quest AND scrolls the list pane
  to show it (SetScrollHereY on the first render frame after open)
- One-shot scroll: scrollToSelected_ cleared after first use so normal
  scroll behavior is unaffected afterward

Quest tracker:
- Clicking a tracked quest now calls openAndSelectQuest() — opens the log
  AND jumps to that specific quest rather than just opening to top
2026-03-10 05:26:16 -07:00
Kelsi
fb80b125bd Fix post-hearthstone asset gaps and add quest tracker interactivity
Hearthstone post-teleport fix:
- Expand same-map hearthstone precache from 5x5 to 9x9 tiles so workers
  have more tiles parsed before the player arrives at the bind point
- After same-map teleport arrival, enqueue the full load-radius tile grid
  (17x17 = 289 tiles) at the new position so background workers immediately
  start loading all WMOs/M2s visible from the new location

Quest tracker improvements:
- Clicking a quest in the tracker now opens the Quest Log (L)
- Remove NoInputs flag so the tracker window receives mouse events
- Show only tracked quests in tracker; fall back to all quests if none tracked
- Add Track/Untrack button in Quest Log details panel
- Abandoning a quest automatically untracks it
- Track state stored in GameHandler::trackedQuestIds_ (per-session)
2026-03-10 05:18:45 -07:00
Kelsi
682f47f66b game: downgrade high-frequency per-interaction LOG_INFO/WARNING to LOG_DEBUG
Demote parse-level diagnostic logs that fire on every game interaction:
- TBC/Classic gossip, quest details, quest rewards: LOG_INFO → LOG_DEBUG
- WotLK gossip, quest details/reward/request-items: LOG_INFO → LOG_DEBUG
- Attack start/stop, XP gain, loot, name query, vendor, party: LOG_INFO → LOG_DEBUG
- TBC SMSG_UPDATE_OBJECT has_transport fallback: LOG_WARNING → LOG_DEBUG
- TBC parseAuraUpdate not-in-TBC diagnostic: LOG_WARNING → LOG_DEBUG
- Turtle SMSG_MONSTER_MOVE WotLK fallback: LOG_WARNING → LOG_DEBUG

These all fire multiple times per second during normal gameplay.
2026-03-10 05:09:43 -07:00
Kelsi
f0233c092b game: downgrade false-positive LOG_WARNING calls for normal game events
SMSG_SHOW_BANK and SMSG_BUY_BANK_SLOT_RESULT are normal interactions
that were incorrectly logged at WARNING level. LOGIN_VERIFY_WORLD coord
dump is diagnostic detail, not a warning condition. Downgraded:
- SMSG_SHOW_BANK: LOG_WARNING → LOG_INFO
- SMSG_BUY_BANK_SLOT_RESULT: LOG_WARNING → LOG_INFO
- SMSG_TRANSFER_PENDING: LOG_WARNING → LOG_INFO (from previous session)
- SMSG_NEW_WORLD: LOG_WARNING → LOG_INFO (from previous session)
- LOGIN_VERIFY_WORLD coord dump: LOG_WARNING → LOG_DEBUG
2026-03-10 04:56:42 -07:00
Kelsi
4dab5daf79 game: remove duplicate initial-spells LOG_INFO and downgrade debug spell list
- world_packets.cpp::InitialSpellsParser::parse already logs spell count
  at LOG_INFO; remove the duplicate count from handleInitialSpells()
- Downgrade verbose format-detection LOG_INFO to LOG_DEBUG (packet size,
  format name, first-10 spell IDs) — these are diagnostic details that
  clutter INFO output without adding operational value
2026-03-10 04:52:22 -07:00
Kelsi
4972472b2a security+game: downgrade auth credential and high-frequency LOG_INFO to LOG_DEBUG
- AUTH HASH logs (sessionKey, hash input, digest): session key material
  must never appear in production logs at INFO level — downgrade to DEBUG
- SMSG_AUTH_CHALLENGE field details (seeds, unknown1): downgrade to DEBUG;
  keep one INFO line with format name for connection diagnostics
- SMSG_MOTD per-line content: downgrade to DEBUG; keep INFO line count
- Transport position update per-entity: fires on every update for each
  entity riding a transport — downgrade to DEBUG
2026-03-10 04:51:01 -07:00
Kelsi
dd8c2cbb20 game: downgrade per-item-query LOG_INFO to LOG_DEBUG in game_handler
queryItemInfo and handleItemQueryResponse fire for every item in
inventory, loot windows, vendor lists, and mail — potentially dozens
of times at login or when any container is opened.  Downgrade to
LOG_DEBUG to reduce noise.  Also downgrade useItemById search traces
to LOG_DEBUG; the final warning (item not found) stays at LOG_WARNING.
2026-03-10 04:48:33 -07:00
Kelsi
f22845b238 game: downgrade trainer/initial-spells diagnostic LOG_INFO to LOG_DEBUG
Debug-labeled LOG_INFO calls in handleTrainerList and handleInitialSpells
fire every time the trainer window opens or the player logs in, producing
noisy output that obscures meaningful events.

- handleTrainerList: known spells list dump, hardcoded prerequisite checks
  (527/25312), and per-spell detail lines → LOG_DEBUG
  Keep one LOG_INFO for the spell count summary (meaningful lifecycle event)
- handleInitialSpells: hardcoded spell presence checks (527/988/1180) →
  LOG_DEBUG; replace with a single LOG_INFO for spell count summary
2026-03-10 04:46:42 -07:00
Kelsi
31ae689b2c game: fix TBC SMSG_QUESTGIVER_QUEST_DETAILS parsing by promoting Classic override to TbcPacketParsers
TBC 2.4.3 and Classic 1.12 share the same SMSG_QUESTGIVER_QUEST_DETAILS
format.  WotLK 3.3.5a adds three extra fields (informUnit u64, flags u32,
isFinished u8) that the base QuestDetailsParser::parse handles.  TBC had no
override, so it fell through to the WotLK heuristic which read flags+isFinished
as if they were TBC fields, misaligning choiceCount, rewardMoney, and rewardXp.

Fix: move parseQuestDetails from ClassicPacketParsers to TbcPacketParsers.
Classic inherits it unchanged (formats are identical).  Both expansions now
correctly parse: no informUnit, activateAccept(u8), suggestedPlayers(u32),
emote section, variable choice/reward item counts, rewardMoney, and rewardXp.
2026-03-10 04:37:03 -07:00
Kelsi
475e0c213c rendering: downgrade per-NPC-spawn LOG_INFO spam to LOG_DEBUG in application.cpp
Model batch submesh IDs and NPC geoset lists fire on every NPC spawn and
produce excessive log noise in normal gameplay. Downgrade to LOG_DEBUG.
Also downgrade per-equipment-slot DBC lookups from LOG_INFO to LOG_DEBUG.
2026-03-10 04:30:01 -07:00
Kelsi
f533373050 game: downgrade combat/charEnum per-entry LOG_INFO to LOG_DEBUG in WotLK parsers
Consistent with the same cleanup for Classic/TBC parsers. Melee hit, spell
damage, and spell heal logs fire on every combat event and belong at DEBUG
level. Per-character detail lines in the WotLK CharEnum parser are also
consolidated to a single DEBUG line per character.
2026-03-10 04:25:45 -07:00
Kelsi
d7ef40c9d7 game: downgrade combat/charEnum per-entry LOG_INFO to LOG_DEBUG in Classic/TBC parsers
Combat event logs (melee hit, spell damage, spell heal) fire on every combat
event and should be DEBUG-level. The per-character detail lines in parseCharEnum
are also moved to DEBUG — the summary line stays at INFO.
2026-03-10 04:23:54 -07:00
Kelsi
3bee0882cc game: fix Classic parseQuestDetails missing rewardXp field
Vanilla 1.12 SMSG_QUESTGIVER_QUEST_DETAILS includes rewardXp (uint32)
after rewardMoney, same as WotLK. Without this read the XP reward was
always 0 in the quest accept dialog for Classic.
2026-03-10 04:20:13 -07:00
Kelsi
5c830216be Remove debug LOG_INFO spam from applyEquipment; use DBC layout for texture fields
The verbose diagnostic logs added in 16cdde8 for Classic equipment debugging
are no longer needed now that the CSV string-detection bug is fixed. Remove
them to eliminate log spam on every character screen open.

Also replace the hardcoded `14 + region` texture field lookup with the same
DBC-layout-aware array pattern used in game_screen.cpp::updateCharacterTextures,
so texture field indices are correctly resolved per expansion.
2026-03-10 04:16:27 -07:00
Kelsi
b31a2a66b6 tools: fix DBC string-column detection false positives in both dbc_to_csv and asset_extract
The string-column auto-detector in both tools had two gaps that caused small
integer fields (RaceID=1, SexID=0/1, BaseSection, ColorIndex) to be falsely
classified as string columns, corrupting the generated CSVs:

1. No boundary check: a value of N was accepted as a valid string offset even
   when N landed inside a longer string (e.g. offset 3 inside "Character\...").
   Fix: precompute valid string-start boundaries (offset 0 plus every position
   immediately after a null byte); reject offsets that are not boundaries.

2. No diversity check: a column whose only non-zero value is 1 would pass the
   boundary test because offset 1 is always a valid boundary (it follows the
   mandatory null at offset 0). Fix: require at least 2 distinct non-empty
   string values before marking a column as a string column. Columns like
   SexID (all values are 0 or 1, resolving to "" and the same path fragment)
   are integer fields, not string fields.

Both dbc_to_csv and asset_extract now produce correct column metadata,
e.g. CharSections.dbc yields "strings=6,7,8" instead of "strings=0,1,...,9".
2026-03-10 03:49:06 -07:00
Kelsi
5b06a62d91 game: fix Classic/TBC movement ACKs silently dropped by isClassicLikeExpansion guard
Five movement control response handlers (speed change, move-root, move-flag
change, knock-back, teleport) had guards of the form !isClassicLikeExpansion()
or isClassicLikeExpansion() that prevented ACKs from ever being sent on
Classic/Turtle.  Each handler already contained correct legacyGuidAck logic
(full uint64 for Classic/TBC, packed GUID for WotLK) that was unreachable
due to the outer guard.

Classic servers (CMaNGOS/VMaNGOS/ChromieCraft) expect all of these ACKs.
Without them the server stalls the player's speed update, keeps root state
desynced, or generates movement hacks.  Fix by removing the erroneous
expansion guard and relying on the existing legacyGuidAck path.

Affected: handleForceSpeedChange, handleForceMoveRootState,
          handleForceMoveFlagChange, handleMoveKnockBack, handleTeleport.
2026-03-10 03:30:24 -07:00
Kelsi
4a213d8da8 tools/game: fix dbc_to_csv false-positive string detection + clear DBC cache on expansion switch
dbc_to_csv: The string-column auto-detector would mark integer fields (e.g.
RaceID=1, SexID=0, BaseSection=0-4) as string columns whenever their small
values were valid string-block offsets that happened to land inside longer
strings.  Fix by requiring that an offset point to a string *boundary* (offset
0 or immediately after a null byte) rather than any valid position — this
eliminates false positives from integer fields whose values accidentally alias
path substrings.  Affected CSVs (CharSections, ItemDisplayInfo for Classic/TBC)
can now be regenerated correctly.

game_handler: clearDBCCache() is already called by application.cpp before
resetDbcCaches(), but also add it inside resetDbcCaches() as a defensive
measure so that future callers of resetDbcCaches() alone also flush stale
expansion-specific DBC data (CharSections, ItemDisplayInfo, etc.).
2026-03-10 03:27:30 -07:00
29 changed files with 1966 additions and 608 deletions

View file

@ -22,6 +22,7 @@
"PLAYER_BYTES_2": 192,
"PLAYER_XP": 716,
"PLAYER_NEXT_LEVEL_XP": 717,
"PLAYER_REST_STATE_EXPERIENCE": 718,
"PLAYER_FIELD_COINAGE": 1176,
"PLAYER_QUEST_LOG_START": 198,
"PLAYER_FIELD_INV_SLOT_HEAD": 486,

View file

@ -22,6 +22,7 @@
"PLAYER_BYTES_2": 238,
"PLAYER_XP": 926,
"PLAYER_NEXT_LEVEL_XP": 927,
"PLAYER_REST_STATE_EXPERIENCE": 928,
"PLAYER_FIELD_COINAGE": 1441,
"PLAYER_QUEST_LOG_START": 244,
"PLAYER_FIELD_INV_SLOT_HEAD": 650,

View file

@ -22,6 +22,7 @@
"PLAYER_BYTES_2": 192,
"PLAYER_XP": 716,
"PLAYER_NEXT_LEVEL_XP": 717,
"PLAYER_REST_STATE_EXPERIENCE": 718,
"PLAYER_FIELD_COINAGE": 1176,
"PLAYER_QUEST_LOG_START": 198,
"PLAYER_FIELD_INV_SLOT_HEAD": 486,
@ -35,4 +36,4 @@
"ITEM_FIELD_STACK_COUNT": 14,
"CONTAINER_FIELD_NUM_SLOTS": 48,
"CONTAINER_FIELD_SLOT_1": 50
}
}

View file

@ -22,6 +22,7 @@
"PLAYER_BYTES_2": 154,
"PLAYER_XP": 634,
"PLAYER_NEXT_LEVEL_XP": 635,
"PLAYER_REST_STATE_EXPERIENCE": 636,
"PLAYER_FIELD_COINAGE": 1170,
"PLAYER_QUEST_LOG_START": 158,
"PLAYER_FIELD_INV_SLOT_HEAD": 324,

View file

@ -7,7 +7,7 @@
A native C++ World of Warcraft client with a custom Vulkan renderer.
[![Sponsor](https://img.shields.io/github/sponsors/Kelsidavis?label=Sponsor&logo=GitHub)](https://github.com/sponsors/Kelsidavis)
[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/SDqjA79B)
[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/PSdMPS8uje)
[![Watch the video](https://img.youtube.com/vi/B-jtpPmiXGM/maxresdefault.jpg)](https://youtu.be/B-jtpPmiXGM)

View file

@ -78,6 +78,7 @@ public:
// Render bounds lookup (for click targeting / selection)
bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const;
bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const;
bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const;
// Character skin composite state (saved at spawn for re-compositing on equipment change)
const std::string& getBodySkinPath() const { return bodySkinPath_; }
@ -186,6 +187,9 @@ private:
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, 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> 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_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<uint32_t, bool> modelIdIsWolfLike_; // modelId → cached wolf/worg check
@ -360,7 +364,7 @@ private:
std::future<PreparedNpcComposite> future;
};
std::vector<AsyncNpcCompositeLoad> asyncNpcCompositeLoads_;
void processAsyncNpcCompositeResults();
void processAsyncNpcCompositeResults(bool unlimited = false);
// Cache base player model geometry by (raceId, genderId)
std::unordered_map<uint32_t, uint32_t> playerModelCache_; // key=(race<<8)|gender → modelId
struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; };

View file

@ -55,6 +55,24 @@ enum class QuestGiverStatus : uint8_t {
REWARD = 10 // ? (yellow)
};
/**
* A single contact list entry (friend, ignore, or mute).
*/
struct ContactEntry {
uint64_t guid = 0;
std::string name;
std::string note;
uint32_t flags = 0; // 0x1=friend, 0x2=ignore, 0x4=mute
uint8_t status = 0; // 0=offline, 1=online, 2=AFK, 3=DND
uint32_t areaId = 0;
uint32_t level = 0;
uint32_t classId = 0;
bool isFriend() const { return (flags & 0x1) != 0; }
bool isIgnored() const { return (flags & 0x2) != 0; }
bool isOnline() const { return status != 0; }
};
/**
* World connection state
*/
@ -570,8 +588,10 @@ public:
const std::unordered_map<uint32_t, TalentTabEntry>& getAllTalentTabs() const { return talentTabCache_; }
void loadTalentDbc();
// Action bar
static constexpr int ACTION_BAR_SLOTS = 12;
// Action bar — 2 bars × 12 slots = 24 total
static constexpr int SLOTS_PER_BAR = 12;
static constexpr int ACTION_BARS = 2;
static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24
std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() { return actionBar; }
const std::array<ActionBarSlot, ACTION_BAR_SLOTS>& getActionBar() const { return actionBar; }
void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id);
@ -599,10 +619,33 @@ public:
using NpcRespawnCallback = std::function<void(uint64_t guid)>;
void setNpcRespawnCallback(NpcRespawnCallback cb) { npcRespawnCallback_ = std::move(cb); }
// Stand state animation callback — fired when SMSG_STANDSTATE_UPDATE confirms a new state
// standState: 0=stand, 1-6=sit variants, 7=dead, 8=kneel
using StandStateCallback = std::function<void(uint8_t standState)>;
void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); }
// Ghost state callback — fired when player enters or leaves ghost (spirit) form
using GhostStateCallback = std::function<void(bool isGhost)>;
void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); }
// Melee swing callback (for driving animation/SFX)
using MeleeSwingCallback = std::function<void()>;
void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); }
// Spell cast animation callbacks — true=start cast/channel, false=finish/cancel
// guid: caster (may be player or another unit), isChannel: channel vs regular cast
using SpellCastAnimCallback = std::function<void(uint64_t guid, bool start, bool isChannel)>;
void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); }
// Unit animation hint: signal jump (animId=38) for other players/NPCs
using UnitAnimHintCallback = std::function<void(uint64_t guid, uint32_t animId)>;
void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); }
// Unit move-flags callback: fired on every MSG_MOVE_* for other players with the raw flags field.
// Drives Walk(4) vs Run(5) selection and swim state initialization from heartbeat packets.
using UnitMoveFlagsCallback = std::function<void(uint64_t guid, uint32_t moveFlags)>;
void setUnitMoveFlagsCallback(UnitMoveFlagsCallback cb) { unitMoveFlagsCallback_ = std::move(cb); }
// NPC swing callback (plays attack animation on NPC)
using NpcSwingCallback = std::function<void(uint64_t guid)>;
void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); }
@ -620,6 +663,8 @@ public:
// XP tracking
uint32_t getPlayerXp() const { return playerXp_; }
uint32_t getPlayerNextLevelXp() const { return playerNextLevelXp_; }
uint32_t getPlayerRestedXp() const { return playerRestedXp_; }
bool isPlayerResting() const { return isResting_; }
uint32_t getPlayerLevel() const { return serverPlayerLevel_; }
const std::vector<uint32_t>& getPlayerExploredZoneMasks() const { return playerExploredZones_; }
bool hasPlayerExploredZoneMasks() const { return hasPlayerExploredZones_; }
@ -644,8 +689,8 @@ public:
uint32_t getSkillCategory(uint32_t skillId) const;
// World entry callback (online mode - triggered when entering world)
// Parameters: mapId, x, y, z (canonical WoW coordinates)
using WorldEntryCallback = std::function<void(uint32_t mapId, float x, float y, float z)>;
// Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect
using WorldEntryCallback = std::function<void(uint32_t mapId, float x, float y, float z, bool isInitialEntry)>;
void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); }
// Unstuck callback (resets player Z to floor height)
@ -800,6 +845,7 @@ public:
void leaveGroup();
bool isInGroup() const { return !partyData.isEmpty(); }
const GroupListData& getPartyData() const { return partyData; }
const std::vector<ContactEntry>& getContacts() const { return contacts_; }
bool hasPendingGroupInvite() const { return pendingGroupInvite; }
const std::string& getPendingInviterName() const { return pendingInviterName; }
@ -867,6 +913,21 @@ public:
return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0;
}
// Raid target markers (MSG_RAID_TARGET_UPDATE)
// Icon indices 0-7: Star, Circle, Diamond, Triangle, Moon, Square, Cross, Skull
static constexpr uint32_t kRaidMarkCount = 8;
// Returns the GUID marked with the given icon (0 = no mark)
uint64_t getRaidMarkGuid(uint32_t icon) const {
return (icon < kRaidMarkCount) ? raidTargetGuids_[icon] : 0;
}
// Returns the raid mark icon for a given guid (0xFF = no mark)
uint8_t getEntityRaidMark(uint64_t guid) const {
if (guid == 0) return 0xFF;
for (uint32_t i = 0; i < kRaidMarkCount; ++i)
if (raidTargetGuids_[i] == guid) return static_cast<uint8_t>(i);
return 0xFF;
}
// ---- LFG / Dungeon Finder ----
enum class LfgState : uint8_t {
None = 0,
@ -966,6 +1027,12 @@ public:
const std::vector<QuestLogEntry>& getQuestLog() const { return questLog_; }
void abandonQuest(uint32_t questId);
bool requestQuestQuery(uint32_t questId, bool force = false);
bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; }
void setQuestTracked(uint32_t questId, bool tracked) {
if (tracked) trackedQuestIds_.insert(questId);
else trackedQuestIds_.erase(questId);
}
const std::unordered_set<uint32_t>& getTrackedQuestIds() const { return trackedQuestIds_; }
bool isQuestQueryPending(uint32_t questId) const {
return pendingQuestQueryIds_.count(questId) > 0;
}
@ -1656,11 +1723,12 @@ private:
std::unordered_map<uint32_t, GameObjectQueryResponseData> gameObjectInfoCache_;
std::unordered_set<uint32_t> pendingGameObjectQueries_;
// ---- Friend list cache ----
// ---- Friend/contact list cache ----
std::unordered_map<std::string, uint64_t> friendsCache; // name -> guid
std::unordered_set<uint64_t> friendGuids_; // all known friend GUIDs (for name backfill)
uint32_t lastContactListMask_ = 0;
uint32_t lastContactListCount_ = 0;
std::vector<ContactEntry> contacts_; // structured contact list (friends + ignores)
// ---- World state and faction initialization snapshots ----
uint32_t worldStateMapId_ = 0;
@ -1796,6 +1864,7 @@ private:
std::unordered_map<uint32_t, TalentEntry> talentCache_; // talentId -> entry
std::unordered_map<uint32_t, TalentTabEntry> talentTabCache_; // tabId -> entry
bool talentDbcLoaded_ = false;
bool talentsInitialized_ = false; // Reset on world entry; guards first-spec selection
// ---- Area trigger detection ----
struct AreaTriggerEntry {
@ -1813,7 +1882,7 @@ private:
bool areaTriggerSuppressFirst_ = false; // suppress first check after map transfer
float castTimeTotal = 0.0f;
std::array<ActionBarSlot, 12> actionBar{};
std::array<ActionBarSlot, ACTION_BAR_SLOTS> actionBar{};
std::vector<AuraSlot> playerAuras;
std::vector<AuraSlot> targetAuras;
uint64_t petGuid_ = 0;
@ -1836,6 +1905,9 @@ private:
uint32_t instanceDifficulty_ = 0;
bool instanceIsHeroic_ = false;
// Raid target markers (icon 0-7 -> guid; 0 = empty slot)
std::array<uint64_t, kRaidMarkCount> raidTargetGuids_ = {};
// Mirror timers (0=fatigue, 1=breath, 2=feigndeath)
MirrorTimer mirrorTimers_[3];
@ -1981,6 +2053,7 @@ private:
// Quest log
std::vector<QuestLogEntry> questLog_;
std::unordered_set<uint32_t> pendingQuestQueryIds_;
std::unordered_set<uint32_t> trackedQuestIds_;
bool pendingLoginQuestResync_ = false;
float pendingLoginQuestResyncTimeout_ = 0.0f;
@ -2097,6 +2170,12 @@ private:
std::unordered_map<uint32_t, std::string> achievementNameCache_;
bool achievementNameCacheLoaded_ = false;
void loadAchievementNameCache();
// Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name)
std::unordered_map<uint32_t, std::string> areaNameCache_;
bool areaNameCacheLoaded_ = false;
void loadAreaNameCache();
std::string getAreaName(uint32_t areaId) const;
std::vector<TrainerTab> trainerTabs_;
void handleTrainerList(network::Packet& packet);
void loadSpellNameCache();
@ -2152,6 +2231,8 @@ private:
// ---- XP tracking ----
uint32_t playerXp_ = 0;
uint32_t playerNextLevelXp_ = 0;
uint32_t playerRestedXp_ = 0;
bool isResting_ = false;
uint32_t serverPlayerLevel_ = 1;
static uint32_t xpForLevel(uint32_t level);
@ -2187,7 +2268,12 @@ private:
NpcDeathCallback npcDeathCallback_;
NpcAggroCallback npcAggroCallback_;
NpcRespawnCallback npcRespawnCallback_;
StandStateCallback standStateCallback_;
GhostStateCallback ghostStateCallback_;
MeleeSwingCallback meleeSwingCallback_;
SpellCastAnimCallback spellCastAnimCallback_;
UnitAnimHintCallback unitAnimHintCallback_;
UnitMoveFlagsCallback unitMoveFlagsCallback_;
NpcSwingCallback npcSwingCallback_;
NpcGreetingCallback npcGreetingCallback_;
NpcFarewellCallback npcFarewellCallback_;

View file

@ -353,6 +353,9 @@ public:
// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST: guid(8) + questId(4) — no trailing
// isDialogContinued byte that WotLK added
network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override;
// TBC/Classic SMSG_QUESTGIVER_QUEST_DETAILS lacks informUnit(u64), flags(u32),
// isFinished(u8) that WotLK added; uses variable item counts + emote section.
bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override;
};
/**
@ -402,7 +405,7 @@ public:
uint8_t readQuestGiverStatus(network::Packet& packet) override;
network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override;
network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override;
bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override;
// parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3)
uint8_t questLogStride() const override { return 3; }
bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override {
return MonsterMoveParser::parseVanilla(packet, data);

View file

@ -41,6 +41,7 @@ enum class UF : uint16_t {
PLAYER_BYTES_2,
PLAYER_XP,
PLAYER_NEXT_LEVEL_XP,
PLAYER_REST_STATE_EXPERIENCE,
PLAYER_FIELD_COINAGE,
PLAYER_QUEST_LOG_START,
PLAYER_FIELD_INV_SLOT_HEAD,

View file

@ -481,6 +481,10 @@ struct UpdateBlock {
// Update flags from movement block (for detecting transports, etc.)
uint16_t updateFlags = 0;
// Raw movement flags from LIVING block (SWIMMING=0x200000, WALKING=0x100, CAN_FLY=0x800000, FLYING=0x1000000)
// Used to initialise swim/walk/fly state on entity spawn (cold-join).
uint32_t moveFlags = 0;
// Transport data from LIVING movement block (MOVEMENTFLAG_ONTRANSPORT)
bool onTransport = false;
uint64_t transportGuid = 0;

View file

@ -59,6 +59,15 @@ public:
*/
void setExpansionDataPath(const std::string& path);
/**
* Set a base data path to fall back to when the primary manifest
* does not contain a requested file. Call this when the primary
* dataPath is an expansion-specific subset (e.g. Data/expansions/vanilla/)
* that only holds DBC overrides, not the full world asset set.
* @param basePath Path to the base extraction (Data/) that has a manifest.json
*/
void setBaseFallbackPath(const std::string& basePath);
/**
* Load a DBC file
* @param name DBC file name (e.g., "Map.dbc")
@ -66,6 +75,14 @@ public:
*/
std::shared_ptr<DBCFile> loadDBC(const std::string& name);
/**
* Load a DBC file that is optional (not all expansions ship it).
* Returns nullptr quietly (debug-level log only) when the file is absent.
* @param name DBC file name (e.g., "Item.dbc")
* @return Loaded DBC file, or nullptr if not available
*/
std::shared_ptr<DBCFile> loadDBCOptional(const std::string& name);
/**
* Get a cached DBC file
* @param name DBC file name
@ -136,6 +153,11 @@ private:
AssetManifest manifest_;
LooseFileReader looseReader_;
// Optional base-path fallback: used when manifest_ doesn't contain a file.
// Populated by setBaseFallbackPath(); ignored if baseFallbackDataPath_ is empty.
std::string baseFallbackDataPath_;
AssetManifest baseFallbackManifest_;
/**
* Resolve filesystem path: check override dir first, then base manifest.
* Returns empty string if not found.

View file

@ -82,6 +82,7 @@ public:
bool isSwimming() const { return swimming; }
bool isInsideWMO() const { return cachedInsideWMO; }
void setGrounded(bool g) { grounded = g; }
void setSitting(bool s) { sitting = s; }
bool isOnTaxi() const { return externalFollow_; }
const glm::vec3* getFollowTarget() const { return followTarget; }
glm::vec3* getFollowTargetMutable() { return followTarget; }
@ -156,6 +157,7 @@ private:
static constexpr float MAX_PITCH = 35.0f; // Limited upward look
glm::vec3* followTarget = nullptr;
glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement
float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised)
// Gravity / grounding
float verticalVelocity = 0.0f;

View file

@ -78,6 +78,7 @@ public:
void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation);
void moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds);
void startFadeIn(uint32_t instanceId, float durationSeconds);
void setInstanceOpacity(uint32_t instanceId, float opacity);
const pipeline::M2Model* getModelData(uint32_t modelId) const;
void setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets);
void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture);
@ -91,6 +92,7 @@ public:
bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const;
bool getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const;
bool getInstanceFootZ(uint32_t instanceId, float& outFootZ) const;
bool getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const;
/** Debug: Log all available animations for an instance */
void dumpAnimations(uint32_t instanceId) const;

View file

@ -64,6 +64,7 @@ private:
bool showChatWindow = true;
bool showNameplates_ = true; // V key toggles nameplates
bool showPlayerInfo = false;
bool showSocialFrame_ = false; // O key toggles social/friends list
bool showGuildRoster_ = false;
std::string selectedGuildMember_;
bool showGuildNoteEdit_ = false;
@ -219,6 +220,7 @@ private:
void renderSharedQuestPopup(game::GameHandler& gameHandler);
void renderItemTextWindow(game::GameHandler& gameHandler);
void renderBuffBar(game::GameHandler& gameHandler);
void renderSocialFrame(game::GameHandler& gameHandler);
void renderLootWindow(game::GameHandler& gameHandler);
void renderGossipWindow(game::GameHandler& gameHandler);
void renderQuestDetailsWindow(game::GameHandler& gameHandler);

View file

@ -13,11 +13,19 @@ public:
bool isOpen() const { return open; }
void toggle() { open = !open; }
void setOpen(bool o) { open = o; }
// Open the log and scroll to the given quest (by questId)
void openAndSelectQuest(uint32_t questId) {
open = true;
pendingSelectQuestId_ = questId;
scrollToSelected_ = true;
}
private:
bool open = false;
bool lKeyWasDown = false;
int selectedIndex = -1;
uint32_t pendingSelectQuestId_ = 0; // non-zero: select this quest on next render
bool scrollToSelected_ = false; // true: call SetScrollHereY once after selection
uint32_t lastDetailRequestQuestId_ = 0;
double lastDetailRequestAt_ = 0.0;
std::unordered_set<uint32_t> questDetailQueryNoResponse_;

View file

@ -292,6 +292,11 @@ bool Application::initialize() {
if (std::filesystem::exists(expansionManifest)) {
assetPath = profile->dataPath;
LOG_INFO("Using expansion-specific asset path: ", assetPath);
// Register base Data/ as fallback so world terrain files are found
// even when the expansion path only contains DBC overrides.
if (assetPath != dataPath) {
assetManager->setBaseFallbackPath(dataPath);
}
}
}
}
@ -744,6 +749,9 @@ void Application::logoutToLogin() {
creatureRenderPosCache_.clear();
creatureWeaponsAttached_.clear();
creatureWeaponAttachAttempts_.clear();
creatureWasMoving_.clear();
creatureSwimmingState_.clear();
creatureWalkingState_.clear();
deadCreatureGuids_.clear();
nonRenderableCreatureDisplayIds_.clear();
creaturePermanentFailureGuids_.clear();
@ -1461,14 +1469,36 @@ void Application::update(float deltaTime) {
auto unitPtr = std::static_pointer_cast<game::Unit>(entity);
const bool deadOrCorpse = unitPtr->getHealth() == 0;
const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f);
const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f);
if (deadOrCorpse || largeCorrection) {
charRenderer->setInstancePosition(instanceId, renderPos);
} else if (planarDist > 0.03f || dz > 0.08f) {
// Use movement interpolation so step/run animation can play.
} else if (isMovingNow) {
float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f);
charRenderer->moveInstanceTo(instanceId, renderPos, duration);
}
posIt->second = renderPos;
// Drive movement animation: Walk/Run/Swim (4/5/42) when moving,
// Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set.
// WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim.
// Only switch on transitions to avoid resetting animation time.
// Don't override Death (1) animation.
const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0;
const bool isWalkingNow = creatureWalkingState_.count(guid) > 0;
bool prevMoving = creatureWasMoving_[guid];
if (isMovingNow != prevMoving) {
creatureWasMoving_[guid] = isMovingNow;
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) {
uint32_t targetAnim;
if (isMovingNow)
targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run
else
targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand
charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true);
}
}
}
float renderYaw = entity->getOrientation() + glm::radians(90.0f);
charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw));
@ -1687,8 +1717,72 @@ void Application::setupUICallbacks() {
});
// World entry callback (online mode) - load terrain when entering world
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z) {
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")");
gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) {
LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"
" initial=", isInitialEntry);
// Reconnect to the same map: terrain stays loaded but all online entities are stale.
// Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world.
if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) {
LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)");
// Pending spawn queues and failure caches
pendingCreatureSpawns_.clear();
pendingCreatureSpawnGuids_.clear();
creatureSpawnRetryCounts_.clear();
creaturePermanentFailureGuids_.clear(); // Clear so previously-failed GUIDs can retry
deadCreatureGuids_.clear(); // Will be re-populated from fresh server state
pendingPlayerSpawns_.clear();
pendingPlayerSpawnGuids_.clear();
pendingOnlinePlayerEquipment_.clear();
deferredEquipmentQueue_.clear();
pendingGameObjectSpawns_.clear();
// Properly despawn all tracked instances from the renderer
{
std::vector<uint64_t> guids;
guids.reserve(creatureInstances_.size());
for (const auto& [g, _] : creatureInstances_) guids.push_back(g);
for (auto g : guids) despawnOnlineCreature(g);
}
{
std::vector<uint64_t> guids;
guids.reserve(playerInstances_.size());
for (const auto& [g, _] : playerInstances_) guids.push_back(g);
for (auto g : guids) despawnOnlinePlayer(g);
}
{
std::vector<uint64_t> guids;
guids.reserve(gameObjectInstances_.size());
for (const auto& [g, _] : gameObjectInstances_) guids.push_back(g);
for (auto g : guids) despawnOnlineGameObject(g);
}
// Update player position and re-queue nearby tiles (same logic as teleport)
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z));
glm::vec3 renderPos = core::coords::canonicalToRender(canonical);
renderer->getCharacterPosition() = renderPos;
if (renderer->getCameraController()) {
auto* ft = renderer->getCameraController()->getFollowTargetMutable();
if (ft) *ft = renderPos;
renderer->getCameraController()->clearMovementInputs();
renderer->getCameraController()->suppressMovementFor(1.0f);
}
worldEntryMovementGraceTimer_ = 2.0f;
taxiLandingClampTimer_ = 0.0f;
lastTaxiFlight_ = false;
renderer->getTerrainManager()->processAllReadyTiles();
{
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> nearbyTiles;
nearbyTiles.reserve(289);
for (int dy = -8; dy <= 8; dy++)
for (int dx = -8; dx <= 8; dx++)
nearbyTiles.push_back({tileX + dx, tileY + dy});
renderer->getTerrainManager()->precacheTiles(nearbyTiles);
}
return;
}
// Same-map teleport (taxi landing, GM teleport on same continent):
// just update position, let terrain streamer handle tile loading incrementally.
@ -1714,6 +1808,21 @@ void Application::setupUICallbacks() {
// (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before
// the first frame at the new position.
renderer->getTerrainManager()->processAllReadyTiles();
// Queue all remaining tiles within the load radius (8 tiles = 17x17)
// at the new position. precacheTiles skips already-loaded/pending tiles,
// so this only enqueues tiles that aren't yet in the pipeline.
// This ensures background workers immediately start loading everything
// visible from the new position (hearthstone may land far from old location).
{
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> nearbyTiles;
nearbyTiles.reserve(289);
for (int dy = -8; dy <= 8; dy++)
for (int dx = -8; dx <= 8; dx++)
nearbyTiles.push_back({tileX + dx, tileY + dy});
renderer->getTerrainManager()->precacheTiles(nearbyTiles);
}
return;
}
@ -1976,13 +2085,15 @@ void Application::setupUICallbacks() {
if (mapId == loadedMapId_) {
// Same map: pre-enqueue tiles around the bind point so workers start
// loading them now. Uses render-space coords (canonicalToRender).
// Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time
// for workers to parse most of these before the player arrives.
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y);
std::vector<std::pair<int,int>> tiles;
tiles.reserve(25);
for (int dy = -2; dy <= 2; dy++)
for (int dx = -2; dx <= 2; dx++)
tiles.reserve(81);
for (int dy = -4; dy <= 4; dy++)
for (int dx = -4; dx <= 4; dx++)
tiles.push_back({tileX + dx, tileY + dy});
terrainMgr->precacheTiles(tiles);
@ -2341,8 +2452,9 @@ void Application::setupUICallbacks() {
gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) {
if (!renderer || !renderer->getCharacterRenderer()) return;
uint32_t instanceId = 0;
bool isPlayer = false;
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
if (pit != playerInstances_.end()) { instanceId = pit->second; isPlayer = true; }
else {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
@ -2351,6 +2463,19 @@ void Application::setupUICallbacks() {
glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z));
float durationSec = static_cast<float>(durationMs) / 1000.0f;
renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec);
// Play Run animation (anim 5) for the duration of the spline move.
// WoW M2 animation IDs: 4=Walk, 5=Run.
// Don't override Death animation (1). The per-frame sync loop will return to
// Stand when movement stops.
if (durationMs > 0) {
uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f;
auto* cr = renderer->getCharacterRenderer();
bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur);
if (!gotState || curAnimId != 1 /*Death*/) {
cr->playAnimation(instanceId, 5u, /*loop=*/true);
}
if (!isPlayer) creatureWasMoving_[guid] = true;
}
}
});
@ -2655,6 +2780,136 @@ void Application::setupUICallbacks() {
}
});
// Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs.
// Swim/walking state is now authoritative from the move-flags callback below.
// animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync.
gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) {
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
uint32_t instanceId = 0;
{
auto it = playerInstances_.find(guid);
if (it != playerInstances_.end()) instanceId = it->second;
}
if (instanceId == 0) {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
}
if (instanceId == 0) return;
// Don't override Death animation (1)
uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f;
if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) return;
cr->playAnimation(instanceId, animId, /*loop=*/true);
});
// Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet.
// This is more reliable than opcode-based hints for cold joins and heartbeats:
// a player already swimming when we join will have SWIMMING set on the first heartbeat.
// Walking(4) vs Running(5) is also driven here from the WALKING flag.
gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) {
const bool isSwimming = (moveFlags & static_cast<uint32_t>(game::MovementFlags::SWIMMING)) != 0;
const bool isWalking = (moveFlags & static_cast<uint32_t>(game::MovementFlags::WALKING)) != 0;
if (isSwimming) creatureSwimmingState_[guid] = true;
else creatureSwimmingState_.erase(guid);
if (isWalking) creatureWalkingState_[guid] = true;
else creatureWalkingState_.erase(guid);
});
// Emote animation callback — play server-driven emote animations on NPCs and other players
gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) {
if (!renderer || emoteAnim == 0) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
// Look up creature instance first, then online players
{
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) {
cr->playAnimation(it->second, emoteAnim, false);
return;
}
}
{
auto it = playerInstances_.find(guid);
if (it != playerInstances_.end()) {
cr->playAnimation(it->second, emoteAnim, false);
}
}
});
// Spell cast animation callback — play cast animation on caster (player or NPC/other player)
gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) {
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
// Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer)
const uint32_t castAnim = 3;
// Check player character
{
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) {
if (start) cr->playAnimation(charInstId, castAnim, false);
// On finish: playAnimation(castAnim, loop=false) will auto-return to Stand
return;
}
}
// Check creatures and other online players
{
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) {
if (start) cr->playAnimation(it->second, castAnim, false);
return;
}
}
{
auto it = playerInstances_.find(guid);
if (it != playerInstances_.end()) {
if (start) cr->playAnimation(it->second, castAnim, false);
}
}
});
// Ghost state callback — make player semi-transparent when in spirit form
gameHandler->setGhostStateCallback([this](bool isGhost) {
if (!renderer) return;
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId == 0) return;
cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f);
});
// Stand state animation callback — map server stand state to M2 animation on player
// and sync camera sit flag so movement is blocked while sitting
gameHandler->setStandStateCallback([this](uint8_t standState) {
if (!renderer) return;
// Sync camera controller sitting flag: block movement while sitting/kneeling
if (auto* cc = renderer->getCameraController()) {
cc->setSitting(standState >= 1 && standState <= 8 && standState != 7);
}
auto* cr = renderer->getCharacterRenderer();
if (!cr) return;
uint32_t charInstId = renderer->getCharacterInstanceId();
if (charInstId == 0) return;
// WoW stand state → M2 animation ID mapping
// 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72
uint32_t animId = 0;
if (standState == 0) {
animId = 0; // Stand
} else if (standState >= 1 && standState <= 6) {
animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height)
} else if (standState == 7) {
animId = 1; // Death
} else if (standState == 8) {
animId = 72; // Kneel
}
// Loop sit/kneel (not death) so the held-pose frame stays visible
const bool loop = (animId != 1);
cr->playAnimation(charInstId, animId, loop);
});
// NPC greeting callback - play voice line
gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) {
if (renderer && renderer->getNpcVoiceManager()) {
@ -3348,7 +3603,9 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan
auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc");
if (!itemDisplayDbc) return false;
auto itemDbc = assetManager->loadDBC("Item.dbc");
// Item.dbc is not distributed to clients in Vanilla 1.12; on those expansions
// item display IDs are resolved via the server-sent item cache instead.
auto itemDbc = assetManager->loadDBCOptional("Item.dbc");
const auto* idiL = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
const auto* itemL = pipeline::getActiveDBCLayout()
@ -3356,7 +3613,7 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan
auto resolveDisplayInfoId = [&](uint32_t rawId) -> uint32_t {
if (rawId == 0) return 0;
// AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID.
// Primary path: AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID.
// Resolve strictly through Item.dbc entry -> DisplayID to avoid
// accidental ItemDisplayInfo ID collisions (staff/hilt mismatches).
if (itemDbc) {
@ -3369,6 +3626,17 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan
}
}
}
// Fallback: Vanilla 1.12 does not distribute Item.dbc to clients.
// Items arrive via SMSG_ITEM_QUERY_SINGLE_RESPONSE and are cached in
// itemInfoCache_. Use the server-sent displayInfoId when available.
if (!itemDbc && gameHandler) {
if (const auto* info = gameHandler->getItemInfo(rawId)) {
uint32_t displayIdB = info->displayInfoId;
if (displayIdB != 0 && itemDisplayDbc->findRecordById(displayIdB) >= 0) {
return displayIdB;
}
}
}
return 0;
};
@ -4380,7 +4648,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
// During load screen warmup: lift per-frame budgets so GPU uploads
// and spawns happen in bulk while the loading screen is still visible.
processCreatureSpawnQueue(true);
processAsyncNpcCompositeResults();
processAsyncNpcCompositeResults(true);
// Process equipment queue more aggressively during warmup (multiple per iteration)
for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) {
processDeferredEquipmentQueue();
@ -4862,6 +5130,26 @@ bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const {
return renderer->getCharacterRenderer()->getInstanceFootZ(instanceId, outFootZ);
}
bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const {
if (!renderer || !renderer->getCharacterRenderer()) return false;
uint32_t instanceId = 0;
if (gameHandler && guid == gameHandler->getPlayerGuid()) {
instanceId = renderer->getCharacterInstanceId();
}
if (instanceId == 0) {
auto pit = playerInstances_.find(guid);
if (pit != playerInstances_.end()) instanceId = pit->second;
}
if (instanceId == 0) {
auto it = creatureInstances_.find(guid);
if (it != creatureInstances_.end()) instanceId = it->second;
}
if (instanceId == 0) return false;
return renderer->getCharacterRenderer()->getInstancePosition(instanceId, outPos);
}
pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) {
auto m2Data = assetManager->readFile(m2Path);
if (m2Data.empty()) {
@ -5595,11 +5883,11 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
if (did == 0) return 0;
int32_t idx = itemDisplayDbc->findRecordById(did);
if (idx < 0) {
LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc");
LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc");
return 0;
}
uint32_t gg = itemDisplayDbc->getUInt32(static_cast<uint32_t>(idx), fGG1);
LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg, " (field=", fGG1, ")");
LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg);
return gg;
};
@ -5729,23 +6017,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x
activeGeosets.insert(101); // Default group 1 connector
}
// Log model's actual submesh IDs for debugging geoset mismatches
if (auto* md = charRenderer->getModelData(modelId)) {
std::string batchIds;
for (const auto& b : md->batches) {
if (!batchIds.empty()) batchIds += ",";
batchIds += std::to_string(b.submeshId);
}
LOG_INFO("Model batches submeshIds: [", batchIds, "]");
}
// Log what geosets we're setting for debugging
std::string geosetList;
for (uint16_t g : activeGeosets) {
if (!geosetList.empty()) geosetList += ",";
geosetList += std::to_string(g);
}
LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]");
charRenderer->setActiveGeosets(instanceId, activeGeosets);
if (geosetCape != 0 && npcCapeTextureId) {
charRenderer->setGroupTextureOverride(instanceId, 15, npcCapeTextureId);
@ -6661,6 +6932,8 @@ void Application::despawnOnlinePlayer(uint64_t guid) {
playerInstances_.erase(it);
onlinePlayerAppearance_.erase(guid);
pendingOnlinePlayerEquipment_.erase(guid);
creatureSwimmingState_.erase(guid);
creatureWalkingState_.erase(guid);
}
void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) {
@ -7052,11 +7325,21 @@ void Application::processAsyncCreatureResults(bool unlimited) {
}
}
void Application::processAsyncNpcCompositeResults() {
void Application::processAsyncNpcCompositeResults(bool unlimited) {
auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr;
if (!charRenderer) return;
// Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing
// simultaneously. In unlimited mode (load screen), process everything without cap.
static constexpr float kCompositeBudgetMs = 2.0f;
auto startTime = std::chrono::steady_clock::now();
for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) {
if (!unlimited) {
float elapsed = std::chrono::duration<float, std::milli>(
std::chrono::steady_clock::now() - startTime).count();
if (elapsed >= kCompositeBudgetMs) break;
}
if (!it->future.valid() ||
it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) {
++it;
@ -8243,6 +8526,9 @@ void Application::despawnOnlineCreature(uint64_t guid) {
creatureRenderPosCache_.erase(guid);
creatureWeaponsAttached_.erase(guid);
creatureWeaponAttachAttempts_.erase(guid);
creatureWasMoving_.erase(guid);
creatureSwimmingState_.erase(guid);
creatureWalkingState_.erase(guid);
LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec);
}

View file

@ -483,8 +483,13 @@ void GameHandler::disconnect() {
playerNameCache.clear();
pendingNameQueries.clear();
friendGuids_.clear();
contacts_.clear();
transportAttachments_.clear();
serverUpdatedTransportGuids_.clear();
// Clear in-flight query sets so reconnect can re-issue queries for any
// entries whose responses were lost during the disconnect.
pendingCreatureQueries.clear();
pendingGameObjectQueries_.clear();
requiresWarden_ = false;
wardenGateSeen_ = false;
wardenGateElapsed_ = 0.0f;
@ -498,6 +503,8 @@ void GameHandler::disconnect() {
wardenModuleSize_ = 0;
wardenModuleData_.clear();
wardenLoadedModule_.reset();
// Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects.
entityManager.clear();
setState(WorldState::DISCONNECTED);
LOG_INFO("Disconnected from world server");
}
@ -520,6 +527,13 @@ void GameHandler::resetDbcCaches() {
talentDbcLoaded_ = false;
talentCache_.clear();
talentTabCache_.clear();
// Clear the AssetManager DBC file cache so that expansion-specific DBCs
// (CharSections, ItemDisplayInfo, etc.) are reloaded from the new expansion's
// MPQ files instead of returning stale data from a previous session/expansion.
auto* am = core::Application::getInstance().getAssetManager();
if (am) {
am->clearDBCCache();
}
LOG_INFO("GameHandler: DBC caches cleared for expansion switch");
}
@ -651,6 +665,30 @@ void GameHandler::update(float deltaTime) {
}
}
// Periodically re-query names for players whose initial CMSG_NAME_QUERY was
// lost (server didn't respond) or whose entity was recreated while the query
// was still pending. Runs every 5 seconds to keep overhead minimal.
if (state == WorldState::IN_WORLD && socket) {
static float nameResyncTimer = 0.0f;
nameResyncTimer += deltaTime;
if (nameResyncTimer >= 5.0f) {
nameResyncTimer = 0.0f;
for (const auto& [guid, entity] : entityManager.getEntities()) {
if (!entity || entity->getType() != ObjectType::PLAYER) continue;
if (guid == playerGuid) continue;
auto player = std::static_pointer_cast<Player>(entity);
if (!player->getName().empty()) continue;
if (playerNameCache.count(guid)) continue;
if (pendingNameQueries.count(guid)) continue;
// Player entity exists with empty name and no pending query — resend.
LOG_DEBUG("Name resync: re-querying guid=0x", std::hex, guid, std::dec);
pendingNameQueries.insert(guid);
auto pkt = NameQueryPacket::build(guid);
socket->send(pkt);
}
}
}
if (pendingLootMoneyNotifyTimer_ > 0.0f) {
pendingLootMoneyNotifyTimer_ -= deltaTime;
if (pendingLootMoneyNotifyTimer_ <= 0.0f) {
@ -1573,13 +1611,21 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_EXPLORATION_EXPERIENCE: {
// uint32 areaId + uint32 xpGained
if (packet.getSize() - packet.getReadPos() >= 8) {
/*uint32_t areaId =*/ packet.readUInt32();
uint32_t areaId = packet.readUInt32();
uint32_t xpGained = packet.readUInt32();
if (xpGained > 0) {
char buf[128];
std::snprintf(buf, sizeof(buf),
"Discovered new area! Gained %u experience.", xpGained);
addSystemChatMessage(buf);
std::string areaName = getAreaName(areaId);
std::string msg;
if (!areaName.empty()) {
msg = "Discovered " + areaName + "! Gained " +
std::to_string(xpGained) + " experience.";
} else {
char buf[128];
std::snprintf(buf, sizeof(buf),
"Discovered new area! Gained %u experience.", xpGained);
msg = buf;
}
addSystemChatMessage(msg);
// XP is updated via PLAYER_XP update fields from the server.
}
}
@ -2301,24 +2347,40 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_MONSTER_MOVE_TRANSPORT:
handleMonsterMoveTransport(packet);
break;
case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE:
case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE:
case Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL:
case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE:
case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE:
case Opcode::SMSG_SPLINE_MOVE_LAND_WALK:
case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL:
case Opcode::SMSG_SPLINE_MOVE_ROOT:
case Opcode::SMSG_SPLINE_MOVE_SET_FLYING:
case Opcode::SMSG_SPLINE_MOVE_SET_HOVER:
case Opcode::SMSG_SPLINE_MOVE_START_SWIM:
case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: {
// Minimal parse: PackedGuid only — entity state flag change.
case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: {
// Minimal parse: PackedGuid only — no animation-relevant state change.
if (packet.getSize() - packet.getReadPos() >= 1) {
(void)UpdateObjectParser::readPackedGuid(packet);
}
break;
}
case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE:
case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE:
case Opcode::SMSG_SPLINE_MOVE_SET_FLYING:
case Opcode::SMSG_SPLINE_MOVE_START_SWIM:
case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: {
// PackedGuid + synthesised move-flags → drives animation state in application layer.
// SWIMMING=0x00200000, WALKING=0x00000100, CAN_FLY=0x00800000, FLYING=0x01000000
if (packet.getSize() - packet.getReadPos() < 1) break;
uint64_t guid = UpdateObjectParser::readPackedGuid(packet);
if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break;
uint32_t synthFlags = 0;
if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_START_SWIM)
synthFlags = 0x00200000u; // SWIMMING
else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE)
synthFlags = 0x00000100u; // WALKING
else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_FLYING)
synthFlags = 0x01000000u | 0x00800000u; // FLYING | CAN_FLY
// STOP_SWIM and SET_RUN_MODE: synthFlags stays 0 → clears swim/walk
unitMoveFlagsCallback_(guid, synthFlags);
break;
}
case Opcode::SMSG_SPLINE_SET_RUN_SPEED:
case Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED:
case Opcode::SMSG_SPLINE_SET_SWIM_SPEED: {
@ -2559,9 +2621,15 @@ void GameHandler::handlePacket(network::Packet& packet) {
ssm->stopPrecast();
}
}
if (spellCastAnimCallback_) {
spellCastAnimCallback_(playerGuid, false, false);
}
} else {
// Another unit's cast failed — clear their tracked cast bar
unitCastStates_.erase(failGuid);
if (spellCastAnimCallback_) {
spellCastAnimCallback_(failGuid, false, false);
}
}
break;
}
@ -3325,12 +3393,11 @@ void GameHandler::handlePacket(network::Packet& packet) {
/*uint8_t mode =*/ packet.readUInt8();
rem--;
constexpr int SERVER_BAR_SLOTS = 144;
constexpr int OUR_BAR_SLOTS = 12; // our actionBar array size
for (int i = 0; i < SERVER_BAR_SLOTS; ++i) {
if (rem < 4) break;
uint32_t packed = packet.readUInt32();
rem -= 4;
if (i >= OUR_BAR_SLOTS) continue; // only load first bar
if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2
if (packed == 0) {
// Empty slot — only clear if not already set to Attack/Hearthstone defaults
// so we don't wipe hardcoded fallbacks when the server sends zeros.
@ -3645,8 +3712,33 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
break;
}
case Opcode::MSG_RAID_TARGET_UPDATE:
case Opcode::MSG_RAID_TARGET_UPDATE: {
// uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)),
// 1 = single update (uint8 icon + uint64 guid)
size_t remRTU = packet.getSize() - packet.getReadPos();
if (remRTU < 1) break;
uint8_t rtuType = packet.readUInt8();
if (rtuType == 0) {
// Full update: always 8 entries
for (uint32_t i = 0; i < kRaidMarkCount; ++i) {
if (packet.getSize() - packet.getReadPos() < 9) break;
uint8_t icon = packet.readUInt8();
uint64_t guid = packet.readUInt64();
if (icon < kRaidMarkCount)
raidTargetGuids_[icon] = guid;
}
} else {
// Single update
if (packet.getSize() - packet.getReadPos() >= 9) {
uint8_t icon = packet.readUInt8();
uint64_t guid = packet.readUInt64();
if (icon < kRaidMarkCount)
raidTargetGuids_[icon] = guid;
}
}
LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast<int>(rtuType));
break;
}
case Opcode::SMSG_BUY_ITEM: {
// uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount
// Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT.
@ -3938,9 +4030,12 @@ void GameHandler::handlePacket(network::Packet& packet) {
}
quest.killCounts[entry] = {count, reqCount};
std::string progressMsg = quest.title + ": " +
std::to_string(count) + "/" +
std::to_string(reqCount);
std::string creatureName = getCachedCreatureName(entry);
std::string progressMsg = quest.title + ": ";
if (!creatureName.empty()) {
progressMsg += creatureName + " ";
}
progressMsg += std::to_string(count) + "/" + std::to_string(reqCount);
addSystemChatMessage(progressMsg);
LOG_INFO("Updated kill count for quest ", questId, ": ",
@ -4140,7 +4235,7 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_TRANSFER_PENDING: {
// SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data
uint32_t pendingMapId = packet.readUInt32();
LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId);
// Optional: if remaining data, there's a transport entry + mapId
if (packet.getReadPos() + 8 <= packet.getSize()) {
uint32_t transportEntry = packet.readUInt32();
@ -4174,6 +4269,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
LOG_INFO("Stand state updated: ", static_cast<int>(standState_),
" (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit"
: standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")");
if (standStateCallback_) {
standStateCallback_(standState_);
}
}
break;
case Opcode::SMSG_NEW_TAXI_PATH:
@ -4375,6 +4473,13 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::MSG_MOVE_HEARTBEAT:
case Opcode::MSG_MOVE_START_SWIM:
case Opcode::MSG_MOVE_STOP_SWIM:
case Opcode::MSG_MOVE_SET_WALK_MODE:
case Opcode::MSG_MOVE_SET_RUN_MODE:
case Opcode::MSG_MOVE_START_PITCH_UP:
case Opcode::MSG_MOVE_START_PITCH_DOWN:
case Opcode::MSG_MOVE_STOP_PITCH:
case Opcode::MSG_MOVE_START_ASCEND:
case Opcode::MSG_MOVE_STOP_ASCEND:
if (state == WorldState::IN_WORLD) {
handleOtherPlayerMovement(packet);
}
@ -4588,8 +4693,9 @@ void GameHandler::handlePacket(network::Packet& packet) {
case Opcode::SMSG_SET_REST_START: {
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t restTrigger = packet.readUInt32();
addSystemChatMessage(restTrigger > 0 ? "You are now resting."
: "You are no longer resting.");
isResting_ = (restTrigger > 0);
addSystemChatMessage(isResting_ ? "You are now resting."
: "You are no longer resting.");
}
break;
}
@ -5952,7 +6058,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
// Initialize movement info with world entry position (server → canonical)
glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z));
LOG_WARNING("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z,
") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId);
movementInfo.x = canonical.x;
movementInfo.y = canonical.y;
@ -5978,8 +6084,18 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
mountCallback_(0);
}
// Clear boss encounter unit slots on world transfer
// Clear boss encounter unit slots and raid marks on world transfer
encounterUnitGuids_.fill(0);
raidTargetGuids_.fill(0);
// Reset talent initialization so the first SMSG_TALENTS_INFO after login
// correctly sets the active spec (static locals don't reset across logins)
talentsInitialized_ = false;
learnedTalents_[0].clear();
learnedTalents_[1].clear();
unspentTalentPoints_[0] = 0;
unspentTalentPoints_[1] = 0;
activeTalentSpec_ = 0;
// Suppress area triggers on initial login — prevents exit portals from
// immediately firing when spawning inside a dungeon/instance.
@ -5996,7 +6112,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) {
// Notify application to load terrain for this map/position (online mode)
if (worldEntryCallback_) {
worldEntryCallback_(data.mapId, data.x, data.y, data.z);
worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry);
}
// Auto-join default chat channels
@ -7247,7 +7363,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
static int updateObjErrors = 0;
if (++updateObjErrors <= 5)
LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT");
return;
if (data.blocks.empty()) return;
// Fall through: process any blocks that were successfully parsed before the failure.
}
auto extractPlayerAppearance = [&](const std::map<uint16_t, uint32_t>& fields,
@ -7403,6 +7520,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
otherPlayerMoveTimeMs_.erase(guid);
inspectedPlayerItemEntries_.erase(guid);
pendingAutoInspect_.erase(guid);
// Clear pending name query so the query is re-sent when this player
// comes back into range (entity is recreated as a new object).
pendingNameQueries.erase(guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(guid);
}
@ -7652,6 +7772,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
releasedSpirit_ = true;
playerDead_ = true;
LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)");
if (ghostStateCallback_) ghostStateCallback_(true);
}
}
// Determine hostility from faction template for online creatures
@ -7688,6 +7809,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
npcDeathCallback_(block.guid);
}
}
// Initialise swim/walk state from spawn-time movement flags (cold-join fix).
// Without this, an entity already swimming/walking when the client joins
// won't get its animation state set until the next MSG_MOVE_* heartbeat.
if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ &&
block.guid != playerGuid) {
unitMoveFlagsCallback_(block.guid, block.moveFlags);
}
// Query quest giver status for NPCs with questgiver flag (0x02)
if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) {
network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY));
@ -7713,13 +7841,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
queryGameObjectInfo(itEntry->second, block.guid);
}
// Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002)
LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(), " displayId=", go->getDisplayId(),
" updateFlags=0x", std::hex, block.updateFlags, std::dec,
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
if (block.updateFlags & 0x0002) {
transportGuids_.insert(block.guid);
LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec,
" entry=", go->getEntry(),
" displayId=", go->getDisplayId(),
" pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")");
@ -7775,6 +7903,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
bool slotsChanged = false;
const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP);
const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP);
const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE);
const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL);
const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE);
const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES);
@ -7782,6 +7911,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
for (const auto& [key, val] : block.fields) {
if (key == ufPlayerXp) { playerXp_ = val; }
else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; }
else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; }
else if (key == ufPlayerLevel) {
serverPlayerLevel_ = val;
for (auto& ch : characters) {
@ -8113,12 +8243,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) {
if (!wasGhost && nowGhost) {
releasedSpirit_ = true;
LOG_INFO("Player entered ghost form (PLAYER_FLAGS)");
if (ghostStateCallback_) ghostStateCallback_(true);
} else if (wasGhost && !nowGhost) {
releasedSpirit_ = false;
playerDead_ = false;
repopPending_ = false;
resurrectPending_ = false;
LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)");
if (ghostStateCallback_) ghostStateCallback_(false);
}
}
}
@ -8366,6 +8498,15 @@ void GameHandler::handleDestroyObject(network::Packet& packet) {
if (entity) {
if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) {
creatureDespawnCallback_(data.guid);
} else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) {
// Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range.
playerDespawnCallback_(data.guid);
otherPlayerVisibleItemEntries_.erase(data.guid);
otherPlayerVisibleDirty_.erase(data.guid);
otherPlayerMoveTimeMs_.erase(data.guid);
inspectedPlayerItemEntries_.erase(data.guid);
pendingAutoInspect_.erase(data.guid);
pendingNameQueries.erase(data.guid);
} else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) {
gameObjectDespawnCallback_(data.guid);
}
@ -8719,6 +8860,10 @@ void GameHandler::setTarget(uint64_t guid) {
targetGuid = guid;
// Clear stale aura data from the previous target so the buff bar shows
// an empty state until the server sends SMSG_AURA_UPDATE_ALL for the new target.
for (auto& slot : targetAuras) slot = AuraSlot{};
// Clear previous target's cast bar on target change
// (the new target's cast state is naturally fetched from unitCastStates_ by GUID)
@ -9822,7 +9967,20 @@ void GameHandler::addLocalChatMessage(const MessageChatData& msg) {
// ============================================================
void GameHandler::queryPlayerName(uint64_t guid) {
if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return;
// If already cached, apply the name to the entity (handles entity recreation after
// moving out/in range — the entity object is new but the cached name is valid).
auto cacheIt = playerNameCache.find(guid);
if (cacheIt != playerNameCache.end()) {
auto entity = entityManager.getEntity(guid);
if (entity && entity->getType() == ObjectType::PLAYER) {
auto player = std::static_pointer_cast<Player>(entity);
if (player->getName().empty()) {
player->setName(cacheIt->second);
}
}
return;
}
if (pendingNameQueries.count(guid)) return;
if (state != WorldState::IN_WORLD || !socket) {
LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec,
" state=", worldStateName(state), " socket=", (socket ? "yes" : "no"));
@ -10037,8 +10195,8 @@ void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) {
? packetParsers_->buildItemQuery(entry, queryGuid)
: ItemQueryPacket::build(entry, queryGuid);
socket->send(packet);
LOG_INFO("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
" pending=", pendingItemQueries_.size());
LOG_DEBUG("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec,
" pending=", pendingItemQueries_.size());
}
void GameHandler::handleItemQueryResponse(network::Packet& packet) {
@ -10052,9 +10210,8 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) {
}
pendingItemQueries_.erase(data.entry);
LOG_INFO("handleItemQueryResponse: entry=", data.entry, " valid=", data.valid,
" name='", data.name, "' displayInfoId=", data.displayInfoId,
" pending=", pendingItemQueries_.size());
LOG_DEBUG("handleItemQueryResponse: entry=", data.entry, " name='", data.name,
"' displayInfoId=", data.displayInfoId, " pending=", pendingItemQueries_.size());
if (data.valid) {
itemInfoCache_[data.entry] = data;
@ -11056,7 +11213,8 @@ void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* na
if (guid != playerGuid) return;
// Always ACK the speed change to prevent server stall.
if (socket && !isClassicLikeExpansion()) {
// Classic/TBC use full uint64 GUID; WotLK uses packed GUID.
if (socket) {
network::Packet ack(wireOpcode(ackOpcode));
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
@ -11142,7 +11300,7 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted)
movementInfo.flags &= ~static_cast<uint32_t>(MovementFlags::ROOT);
}
if (!socket || isClassicLikeExpansion()) return;
if (!socket) return;
uint16_t ackWire = wireOpcode(rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK
: Opcode::CMSG_FORCE_MOVE_UNROOT_ACK);
if (ackWire == 0xFFFF) return;
@ -11203,7 +11361,7 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char*
}
}
if (!socket || isClassicLikeExpansion()) return;
if (!socket) return;
uint16_t ackWire = wireOpcode(ackOpcode);
if (ackWire == 0xFFFF) return;
@ -11258,7 +11416,7 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) {
if (guid != playerGuid) return;
if (!socket || isClassicLikeExpansion()) return;
if (!socket) return;
uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK);
if (ackWire == 0xFFFF) return;
@ -12037,11 +12195,39 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) {
}
otherPlayerMoveTimeMs_[moverGuid] = info.time;
entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, durationMs / 1000.0f);
// Classify the opcode so we can drive the correct entity update and animation.
const uint16_t wireOp = packet.getOpcode();
const bool isStopOpcode =
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP)) ||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE)) ||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_TURN)) ||
(wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)) ||
(wireOp == wireOpcode(Opcode::MSG_MOVE_FALL_LAND));
const bool isJumpOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_JUMP));
// Notify renderer
// For stop opcodes snap the entity position (duration=0) so it doesn't keep interpolating,
// and pass durationMs=0 to the renderer so the Run-anim flash is suppressed.
// The per-frame sync will detect no movement and play Stand on the next frame.
const float entityDuration = isStopOpcode ? 0.0f : (durationMs / 1000.0f);
entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, entityDuration);
// Notify renderer of position change
if (creatureMoveCallback_) {
creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, durationMs);
const uint32_t notifyDuration = isStopOpcode ? 0u : durationMs;
creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration);
}
// Signal specific animation transitions that the per-frame sync can't detect reliably.
// WoW M2 animation ID 38=JumpMid (loops during airborne).
// Swim/walking state is now authoritative from the movement flags field via unitMoveFlagsCallback_.
if (unitAnimHintCallback_ && isJumpOpcode) {
unitAnimHintCallback_(moverGuid, 38u);
}
// Fire move-flags callback so application.cpp can update swimming/walking state
// from the flags field embedded in every movement packet (covers heartbeats and cold joins).
if (unitMoveFlagsCallback_) {
unitMoveFlagsCallback_(moverGuid, info.flags);
}
}
@ -12059,7 +12245,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) {
// Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*)
// Not static — wireOpcode() depends on runtime active opcode table.
const std::array<uint16_t, 15> kMoveOpcodes = {
const std::array<uint16_t, 22> kMoveOpcodes = {
wireOpcode(Opcode::MSG_MOVE_START_FORWARD),
wireOpcode(Opcode::MSG_MOVE_START_BACKWARD),
wireOpcode(Opcode::MSG_MOVE_STOP),
@ -12075,6 +12261,13 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) {
wireOpcode(Opcode::MSG_MOVE_HEARTBEAT),
wireOpcode(Opcode::MSG_MOVE_START_SWIM),
wireOpcode(Opcode::MSG_MOVE_STOP_SWIM),
wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE),
wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE),
wireOpcode(Opcode::MSG_MOVE_START_PITCH_UP),
wireOpcode(Opcode::MSG_MOVE_START_PITCH_DOWN),
wireOpcode(Opcode::MSG_MOVE_STOP_PITCH),
wireOpcode(Opcode::MSG_MOVE_START_ASCEND),
wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND),
};
// Track unhandled sub-opcodes once per compressed packet (avoid log spam)
@ -12164,16 +12357,6 @@ void GameHandler::handleMonsterMove(network::Packet& packet) {
return;
}
decompressed.resize(destLen);
// Dump ALL bytes for format diagnosis (remove once confirmed)
static int dumpCount = 0;
if (dumpCount < 10) {
++dumpCount;
std::string hex;
for (size_t i = 0; i < destLen; ++i) {
char buf[4]; snprintf(buf, sizeof(buf), "%02X ", decompressed[i]); hex += buf;
}
LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex);
}
std::vector<uint8_t> stripped;
bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped);
@ -12763,11 +12946,8 @@ void GameHandler::handleInitialSpells(network::Packet& packet) {
knownSpells = {data.spellIds.begin(), data.spellIds.end()};
// Debug: check if specific spells are in initial spells
bool has527 = knownSpells.count(527u);
bool has988 = knownSpells.count(988u);
bool has1180 = knownSpells.count(1180u);
LOG_INFO("Initial spells include: 527=", has527, " 988=", has988, " 1180=", has1180);
LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u),
" 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u));
// Ensure Attack (6603) and Hearthstone (8690) are always present
knownSpells.insert(6603u);
@ -12846,6 +13026,10 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
s.spellId = data.spellId;
s.timeTotal = data.castTime / 1000.0f;
s.timeRemaining = s.timeTotal;
// Trigger cast animation on the casting unit
if (spellCastAnimCallback_) {
spellCastAnimCallback_(data.casterUnit, true, false);
}
}
// If this is the player's own cast, start cast bar
@ -12867,6 +13051,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) {
}
}
// Trigger cast animation on player character
if (spellCastAnimCallback_) {
spellCastAnimCallback_(playerGuid, true, false);
}
// Hearthstone cast: begin pre-loading terrain at bind point during cast time
// so tiles are ready when the teleport fires (avoids falling through un-loaded terrain).
// Spell IDs: 6948 = Vanilla Hearthstone (rank 1), 8690 = TBC/WotLK Hearthstone
@ -12918,6 +13107,14 @@ void GameHandler::handleSpellGo(network::Packet& packet) {
casting = false;
currentCastSpellId = 0;
castTimeRemaining = 0.0f;
// End cast animation on player character
if (spellCastAnimCallback_) {
spellCastAnimCallback_(playerGuid, false, false);
}
} else if (spellCastAnimCallback_) {
// End cast animation on other unit
spellCastAnimCallback_(data.casterUnit, false, false);
}
// Clear unit cast bar when the spell lands (for any tracked unit)
@ -13134,10 +13331,9 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) {
" unspent=", (int)unspentTalentPoints_[data.talentSpec],
" learned=", learnedTalents_[data.talentSpec].size());
// If this is the first spec received, set it as active
static bool firstSpecReceived = false;
if (!firstSpecReceived) {
firstSpecReceived = true;
// If this is the first spec received after login, set it as the active spec
if (!talentsInitialized_) {
talentsInitialized_ = true;
activeTalentSpec_ = data.talentSpec;
// Show message to player about active spec
@ -13261,6 +13457,9 @@ void GameHandler::handleGroupList(network::Packet& packet) {
// WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder.
// Classic 1.12 and TBC 2.4.3 do not send the roles byte.
const bool hasRoles = isActiveExpansion("wotlk");
// Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta.
// Without this, repeated GROUP_LIST packets push duplicate members.
partyData = GroupListData{};
if (!GroupListParser::parse(packet, partyData, hasRoles)) return;
if (partyData.isEmpty()) {
@ -14688,12 +14887,12 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) {
void GameHandler::useItemById(uint32_t itemId) {
if (itemId == 0) return;
LOG_INFO("useItemById: searching for itemId=", itemId, " in backpack (", inventory.getBackpackSize(), " slots)");
LOG_DEBUG("useItemById: searching for itemId=", itemId);
// Search backpack first
for (int i = 0; i < inventory.getBackpackSize(); i++) {
const auto& slot = inventory.getBackpackSlot(i);
if (!slot.empty() && slot.item.itemId == itemId) {
LOG_INFO("useItemById: found itemId=", itemId, " at backpack slot ", i);
LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i);
useItemBySlot(i);
return;
}
@ -14704,7 +14903,7 @@ void GameHandler::useItemById(uint32_t itemId) {
for (int slot = 0; slot < bagSize; slot++) {
const auto& bagSlot = inventory.getBagSlot(bag, slot);
if (!bagSlot.empty() && bagSlot.item.itemId == itemId) {
LOG_INFO("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot);
useItemInBag(bag, slot);
return;
}
@ -15003,29 +15202,24 @@ void GameHandler::handleTrainerList(network::Packet& packet) {
trainerWindowOpen_ = true;
gossipWindowOpen = false;
// Debug: log known spells
LOG_INFO("Known spells count: ", knownSpells.size());
LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells");
LOG_DEBUG("Known spells count: ", knownSpells.size());
if (knownSpells.size() <= 50) {
std::string spellList;
for (uint32_t id : knownSpells) {
if (!spellList.empty()) spellList += ", ";
spellList += std::to_string(id);
}
LOG_INFO("Known spells: ", spellList);
LOG_DEBUG("Known spells: ", spellList);
}
// Check if specific prerequisite spells are known
bool has527 = knownSpells.count(527u);
bool has25312 = knownSpells.count(25312u);
LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312);
// Debug: log first few trainer spells to see their state
LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells");
LOG_DEBUG("Prerequisite check: 527=", knownSpells.count(527u),
" 25312=", knownSpells.count(25312u));
for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) {
const auto& s = currentTrainerList_.spells[i];
LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
LOG_DEBUG(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state,
" cost=", s.spellCost, " reqLvl=", (int)s.reqLevel,
" chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")");
}
@ -15460,12 +15654,13 @@ void GameHandler::handleTeleportAck(network::Packet& packet) {
// Send the ack back to the server
// Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time
if (socket && !isClassicLikeExpansion()) {
// Classic/TBC use full uint64 GUID; WotLK uses packed GUID.
if (socket) {
network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK));
const bool legacyGuidAck =
isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle");
if (legacyGuidAck) {
ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for teleport ACK
ack.writeUInt64(playerGuid); // CMaNGOS/VMaNGOS expects full GUID for Classic/TBC
} else {
MovementPacket::writePackedGuid(ack, playerGuid);
}
@ -15478,7 +15673,7 @@ void GameHandler::handleTeleportAck(network::Packet& packet) {
// Notify application of teleport — the callback decides whether to do
// a full world reload (map change) or just update position (same map).
if (worldEntryCallback_) {
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ);
worldEntryCallback_(currentMapId_, serverX, serverY, serverZ, false);
}
}
@ -15495,7 +15690,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
float serverZ = packet.readFloat();
float orientation = packet.readFloat();
LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId,
LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId,
" pos=(", serverX, ", ", serverY, ", ", serverZ, ")",
" orient=", orientation);
@ -15585,7 +15780,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) {
// Reload terrain at new position
if (worldEntryCallback_) {
worldEntryCallback_(mapId, serverX, serverY, serverZ);
worldEntryCallback_(mapId, serverX, serverY, serverZ, false);
}
}
@ -16398,16 +16593,23 @@ void GameHandler::handleWho(network::Packet& packet) {
uint32_t raceId = packet.readUInt32();
if (hasGender && packet.getSize() - packet.getReadPos() >= 1)
packet.readUInt8(); // gender (WotLK only, unused)
uint32_t zoneId = 0;
if (packet.getSize() - packet.getReadPos() >= 4)
packet.readUInt32(); // zoneId (unused)
zoneId = packet.readUInt32();
std::string msg = " " + playerName;
if (!guildName.empty())
msg += " <" + guildName + ">";
msg += " - Level " + std::to_string(level);
if (zoneId != 0) {
std::string zoneName = getAreaName(zoneId);
if (!zoneName.empty())
msg += " [" + zoneName + "]";
}
addSystemChatMessage(msg);
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId);
LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId,
" Race:", raceId, " Zone:", zoneId);
}
}
@ -16425,6 +16627,11 @@ void GameHandler::handleFriendList(network::Packet& packet) {
if (rem() < 1) return;
uint8_t count = packet.readUInt8();
LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries");
// Rebuild friend contacts (keep ignores from previous contact_ entries)
contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(),
[](const ContactEntry& e){ return e.isFriend(); }), contacts_.end());
for (uint8_t i = 0; i < count && rem() >= 9; ++i) {
uint64_t guid = packet.readUInt64();
uint8_t status = packet.readUInt8();
@ -16434,18 +16641,28 @@ void GameHandler::handleFriendList(network::Packet& packet) {
level = packet.readUInt32();
classId = packet.readUInt32();
}
(void)area; (void)level; (void)classId;
// Track as a friend GUID; resolve name via name query
friendGuids_.insert(guid);
auto nit = playerNameCache.find(guid);
std::string name;
if (nit != playerNameCache.end()) {
friendsCache[nit->second] = guid;
LOG_INFO(" Friend: ", nit->second, " status=", (int)status);
name = nit->second;
friendsCache[name] = guid;
LOG_INFO(" Friend: ", name, " status=", (int)status);
} else {
LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec,
" status=", (int)status, " (name pending)");
queryPlayerName(guid);
}
ContactEntry entry;
entry.guid = guid;
entry.name = name;
entry.flags = 0x1; // friend
entry.status = status;
entry.areaId = area;
entry.level = level;
entry.classId = classId;
contacts_.push_back(std::move(entry));
}
}
@ -16469,19 +16686,23 @@ void GameHandler::handleContactList(network::Packet& packet) {
}
lastContactListMask_ = packet.readUInt32();
lastContactListCount_ = packet.readUInt32();
contacts_.clear();
for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) {
uint64_t guid = packet.readUInt64();
if (rem() < 4) break;
uint32_t flags = packet.readUInt32();
std::string note = packet.readString(); // may be empty
(void)note;
uint8_t status = 0;
uint32_t areaId = 0;
uint32_t level = 0;
uint32_t classId = 0;
if (flags & 0x1) { // SOCIAL_FLAG_FRIEND
if (rem() < 1) break;
uint8_t status = packet.readUInt8();
status = packet.readUInt8();
if (status != 0 && rem() >= 12) {
packet.readUInt32(); // area
packet.readUInt32(); // level
packet.readUInt32(); // class
areaId = packet.readUInt32();
level = packet.readUInt32();
classId = packet.readUInt32();
}
friendGuids_.insert(guid);
auto nit = playerNameCache.find(guid);
@ -16492,6 +16713,17 @@ void GameHandler::handleContactList(network::Packet& packet) {
}
}
// ignore / mute entries: no additional fields beyond guid+flags+note
ContactEntry entry;
entry.guid = guid;
entry.flags = flags;
entry.note = std::move(note);
entry.status = status;
entry.areaId = areaId;
entry.level = level;
entry.classId = classId;
auto nit = playerNameCache.find(guid);
if (nit != playerNameCache.end()) entry.name = nit->second;
contacts_.push_back(std::move(entry));
}
LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_,
" count=", lastContactListCount_);
@ -16520,6 +16752,28 @@ void GameHandler::handleFriendStatus(network::Packet& packet) {
friendsCache.erase(playerName);
}
// Mirror into contacts_: update existing entry or add/remove as needed
if (data.status == 0) { // Removed from friends list
contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(),
[&](const ContactEntry& e){ return e.guid == data.guid; }), contacts_.end());
} else {
auto cit = std::find_if(contacts_.begin(), contacts_.end(),
[&](const ContactEntry& e){ return e.guid == data.guid; });
if (cit != contacts_.end()) {
if (!playerName.empty() && playerName != "Unknown") cit->name = playerName;
// status: 2=online→1, 3=offline→0, 1=added→1 (online on add)
if (data.status == 2) cit->status = 1;
else if (data.status == 3) cit->status = 0;
} else {
ContactEntry entry;
entry.guid = data.guid;
entry.name = playerName;
entry.flags = 0x1; // friend
entry.status = (data.status == 2) ? 1 : 0;
contacts_.push_back(std::move(entry));
}
}
// Status messages
switch (data.status) {
case 0:
@ -17361,7 +17615,7 @@ void GameHandler::handleShowBank(network::Packet& packet) {
for (int i = 0; i < effectiveBankBagSlots_; i++) {
if (inventory.getBankBagSize(i) > 0) filledBags++;
}
LOG_WARNING("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec,
LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec,
" purchased=", static_cast<int>(inventory.getPurchasedBankBagSlots()),
" filledBags=", filledBags,
" effectiveBankBagSlots=", effectiveBankBagSlots_);
@ -17370,7 +17624,7 @@ void GameHandler::handleShowBank(network::Packet& packet) {
void GameHandler::handleBuyBankSlotResult(network::Packet& packet) {
if (packet.getSize() - packet.getReadPos() < 4) return;
uint32_t result = packet.readUInt32();
LOG_WARNING("SMSG_BUY_BANK_SLOT_RESULT: result=", result);
LOG_INFO("SMSG_BUY_BANK_SLOT_RESULT: result=", result);
// AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK
if (result == 3) {
addSystemChatMessage("Bank slot purchased.");
@ -18124,6 +18378,45 @@ const std::string& GameHandler::getFactionNamePublic(uint32_t factionId) const {
return empty;
}
// ---------------------------------------------------------------------------
// Area name cache (lazy-loaded from WorldMapArea.dbc)
// ---------------------------------------------------------------------------
void GameHandler::loadAreaNameCache() {
if (areaNameCacheLoaded_) return;
areaNameCacheLoaded_ = true;
auto* am = core::Application::getInstance().getAssetManager();
if (!am || !am->isInitialized()) return;
auto dbc = am->loadDBC("WorldMapArea.dbc");
if (!dbc || !dbc->isLoaded()) return;
const auto* layout = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("WorldMapArea") : nullptr;
const uint32_t areaIdField = layout ? (*layout)["AreaID"] : 2;
const uint32_t areaNameField = layout ? (*layout)["AreaName"] : 3;
if (dbc->getFieldCount() <= areaNameField) return;
for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) {
uint32_t areaId = dbc->getUInt32(i, areaIdField);
if (areaId == 0) continue;
std::string name = dbc->getString(i, areaNameField);
if (!name.empty() && !areaNameCache_.count(areaId)) {
areaNameCache_[areaId] = std::move(name);
}
}
LOG_INFO("WorldMapArea.dbc: loaded ", areaNameCache_.size(), " area names");
}
std::string GameHandler::getAreaName(uint32_t areaId) const {
if (areaId == 0) return {};
const_cast<GameHandler*>(this)->loadAreaNameCache();
auto it = areaNameCache_.find(areaId);
return (it != areaNameCache_.end()) ? it->second : std::string{};
}
// ---------------------------------------------------------------------------
// Aura duration update
// ---------------------------------------------------------------------------

View file

@ -103,6 +103,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
/*float turnRate =*/ packet.readFloat();
block.runSpeed = runSpeed;
block.moveFlags = moveFlags;
// Spline data (Classic: SPLINE_ENABLED=0x00400000)
if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) {
@ -447,9 +448,9 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att
data.blocked = packet.readUInt32();
}
LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
LOG_DEBUG("[Classic] Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
return true;
}
@ -484,8 +485,8 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam
data.isCrit = (flags & 0x02) != 0;
data.overkill = 0; // no overkill field in Vanilla (same as TBC)
LOG_INFO("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
LOG_DEBUG("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
return true;
}
@ -510,8 +511,8 @@ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealL
data.overheal = packet.readUInt32();
data.isCrit = (packet.readUInt8() != 0);
LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
LOG_DEBUG("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
return true;
}
@ -700,13 +701,9 @@ bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumRespon
character.equipment.push_back(item);
}
LOG_INFO(" Character ", (int)(i + 1), ": ", character.name);
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
LOG_INFO(" ", getRaceName(character.race), " ",
getClassName(character.characterClass), " (",
getGenderName(character.gender), ")");
LOG_INFO(" Level: ", (int)character.level);
LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId);
LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name,
" (", getRaceName(character.race), " ", getClassName(character.characterClass),
" level ", (int)character.level, " zone ", character.zoneId, ")");
response.characters.push_back(character);
}
@ -1016,7 +1013,7 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes
data.quests.push_back(quest);
}
LOG_INFO("Classic Gossip: ", optionCount, " options, ", questCount, " quests");
LOG_DEBUG("Classic Gossip: ", optionCount, " options, ", questCount, " quests");
return true;
}
@ -1507,7 +1504,7 @@ bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveD
packet.setReadPos(start);
if (MonsterMoveParser::parse(packet, data)) {
LOG_WARNING("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout");
LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout");
return true;
}
@ -1561,66 +1558,6 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u
return packet;
}
// ============================================================================
// Classic SMSG_QUESTGIVER_QUEST_DETAILS — Vanilla 1.12 format
// WotLK inserts an informUnit GUID (8 bytes) between npcGuid and questId.
// Vanilla has: npcGuid(8) + questId(4) + title + details + objectives + ...
// ============================================================================
bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) {
if (packet.getSize() < 16) return false;
data.npcGuid = packet.readUInt64();
// Vanilla: questId follows immediately — no informUnit GUID
data.questId = packet.readUInt32();
data.title = normalizeWowTextTokens(packet.readString());
data.details = normalizeWowTextTokens(packet.readString());
data.objectives = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 5 > packet.getSize()) {
LOG_INFO("Quest details classic (short): id=", data.questId, " title='", data.title, "'");
return !data.title.empty() || data.questId != 0;
}
/*activateAccept*/ packet.readUInt8();
data.suggestedPlayers = packet.readUInt32();
// Vanilla 1.12: emote section before reward items
// Format: emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t emoteCount = packet.readUInt32();
for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
packet.readUInt32(); // delay
packet.readUInt32(); // type
}
}
// Choice reward items: variable count + 3 uint32s each
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t choiceCount = packet.readUInt32();
for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
packet.readUInt32(); // itemId
packet.readUInt32(); // count
packet.readUInt32(); // displayInfo
}
}
// Fixed reward items: variable count + 3 uint32s each
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t rewardCount = packet.readUInt32();
for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
packet.readUInt32(); // itemId
packet.readUInt32(); // count
packet.readUInt32(); // displayInfo
}
}
if (packet.getReadPos() + 4 <= packet.getSize())
data.rewardMoney = packet.readUInt32();
LOG_INFO("Quest details classic: id=", data.questId, " title='", data.title, "'");
return true;
}
// ============================================================================
// ClassicPacketParsers::parseCreatureQueryResponse
//

View file

@ -116,6 +116,7 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock&
/*float turnRate =*/ packet.readFloat();
block.runSpeed = runSpeed;
block.moveFlags = moveFlags;
// Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000)
if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) {
@ -355,13 +356,9 @@ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse&
character.equipment.push_back(item);
}
LOG_INFO(" Character ", (int)(i + 1), ": ", character.name);
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
LOG_INFO(" ", getRaceName(character.race), " ",
getClassName(character.characterClass), " (",
getGenderName(character.gender), ")");
LOG_INFO(" Level: ", (int)character.level);
LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId);
LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name,
" (", getRaceName(character.race), " ", getClassName(character.characterClass),
" level ", (int)character.level, " zone ", character.zoneId, ")");
response.characters.push_back(character);
}
@ -488,7 +485,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa
packet.setReadPos(startPos);
if (parseWithLayout(false, parsed)) {
LOG_WARNING("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback");
LOG_DEBUG("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback");
data = std::move(parsed);
return true;
}
@ -540,7 +537,7 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage
data.quests.push_back(quest);
}
LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests");
LOG_DEBUG("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests");
return true;
}
@ -698,6 +695,75 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3
return packet;
}
// ============================================================================
// TBC 2.4.3 SMSG_QUESTGIVER_QUEST_DETAILS
//
// TBC and Classic share the same format — neither has the WotLK-specific fields
// (informUnit GUID, flags uint32, isFinished uint8) that were added in 3.x.
//
// Format:
// npcGuid(8) + questId(4) + title + details + objectives
// + activateAccept(1) + suggestedPlayers(4)
// + emoteCount(4) + [delay(4)+type(4)] × emoteCount
// + choiceCount(4) + [itemId(4)+count(4)+displayInfo(4)] × choiceCount
// + rewardCount(4) + [itemId(4)+count(4)+displayInfo(4)] × rewardCount
// + rewardMoney(4) + rewardXp(4)
// ============================================================================
bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) {
if (packet.getSize() < 16) return false;
data.npcGuid = packet.readUInt64();
data.questId = packet.readUInt32();
data.title = normalizeWowTextTokens(packet.readString());
data.details = normalizeWowTextTokens(packet.readString());
data.objectives = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 5 > packet.getSize()) {
LOG_DEBUG("Quest details tbc/classic (short): id=", data.questId, " title='", data.title, "'");
return !data.title.empty() || data.questId != 0;
}
/*activateAccept*/ packet.readUInt8();
data.suggestedPlayers = packet.readUInt32();
// TBC/Classic: emote section before reward items
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t emoteCount = packet.readUInt32();
for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) {
packet.readUInt32(); // delay
packet.readUInt32(); // type
}
}
// Choice reward items (variable count, up to QUEST_REWARD_CHOICES_COUNT)
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t choiceCount = packet.readUInt32();
for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
packet.readUInt32(); // itemId
packet.readUInt32(); // count
packet.readUInt32(); // displayInfo
}
}
// Fixed reward items (variable count, up to QUEST_REWARDS_COUNT)
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t rewardCount = packet.readUInt32();
for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) {
packet.readUInt32(); // itemId
packet.readUInt32(); // count
packet.readUInt32(); // displayInfo
}
}
if (packet.getReadPos() + 4 <= packet.getSize())
data.rewardMoney = packet.readUInt32();
if (packet.getReadPos() + 4 <= packet.getSize())
data.rewardXp = packet.readUInt32();
LOG_DEBUG("Quest details tbc/classic: id=", data.questId, " title='", data.title, "'");
return true;
}
// ============================================================================
// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST
//
@ -718,7 +784,7 @@ network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32
// SMSG_SET_EXTRA_AURA_INFO_OBSOLETE (0x3A4) instead
// ============================================================================
bool TbcPacketParsers::parseAuraUpdate(network::Packet& /*packet*/, AuraUpdateData& /*data*/, bool /*isAll*/) {
LOG_WARNING("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3");
LOG_DEBUG("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3");
return false;
}
@ -1131,9 +1197,9 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke
data.blocked = packet.readUInt32();
}
LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
LOG_DEBUG("[TBC] Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
return true;
}
@ -1163,8 +1229,8 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL
// TBC does not have an overkill field here
data.overkill = 0;
LOG_INFO("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
LOG_DEBUG("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
return true;
}
@ -1187,8 +1253,8 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa
data.isCrit = (critFlag != 0);
}
LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
LOG_DEBUG("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
return true;
}

View file

@ -14,9 +14,11 @@ namespace game {
#ifdef HAVE_UNICORN
// Memory layout for emulated environment
// Note: heap must not overlap the module region (typically loaded at 0x400000)
// or the stack. Keep heap above 0x02000000 (32MB) to leave space for module + padding.
constexpr uint32_t STACK_BASE = 0x00100000; // 1MB
constexpr uint32_t STACK_SIZE = 0x00100000; // 1MB stack
constexpr uint32_t HEAP_BASE = 0x00200000; // 2MB
constexpr uint32_t HEAP_BASE = 0x02000000; // 32MB — well above typical module base (0x400000)
constexpr uint32_t HEAP_SIZE = 0x01000000; // 16MB heap
constexpr uint32_t API_STUB_BASE = 0x70000000; // API stub area (high memory)
@ -58,6 +60,17 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
moduleBase_ = baseAddress;
moduleSize_ = (moduleSize + 0xFFF) & ~0xFFF; // Align to 4KB
// Detect overlap between module and heap/stack regions early.
uint32_t modEnd = moduleBase_ + moduleSize_;
if (modEnd > heapBase_ && moduleBase_ < heapBase_ + heapSize_) {
std::cerr << "[WardenEmulator] Module [0x" << std::hex << moduleBase_
<< ", 0x" << modEnd << ") overlaps heap [0x" << heapBase_
<< ", 0x" << (heapBase_ + heapSize_) << ") — adjust HEAP_BASE\n" << std::dec;
uc_close(uc_);
uc_ = nullptr;
return false;
}
// Map module memory (code + data)
err = uc_mem_map(uc_, moduleBase_, moduleSize_, UC_PROT_ALL);
if (err != UC_ERR_OK) {
@ -108,6 +121,15 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3
return false;
}
// Map a null guard page at address 0 (read-only, zeroed) so that NULL-pointer
// dereferences in the module don't crash the emulator with UC_ERR_MAP.
// This allows execution to continue past NULL reads, making diagnostics easier.
err = uc_mem_map(uc_, 0x0, 0x1000, UC_PROT_READ);
if (err != UC_ERR_OK) {
// Non-fatal — just log it; the emulator will still function
std::cerr << "[WardenEmulator] Note: could not map null guard page: " << uc_strerror(err) << '\n';
}
// Add hooks for debugging and invalid memory access
uc_hook hh;
uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0);

View file

@ -228,10 +228,10 @@ std::vector<uint8_t> AuthSessionPacket::computeAuthHash(
}
return s;
};
LOG_INFO("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed,
" serverSeed=0x", serverSeed, std::dec);
LOG_INFO("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size()));
LOG_INFO("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size()));
LOG_DEBUG("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed,
" serverSeed=0x", serverSeed, std::dec);
LOG_DEBUG("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size()));
LOG_DEBUG("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size()));
}
// Compute SHA1 hash
@ -245,7 +245,7 @@ std::vector<uint8_t> AuthSessionPacket::computeAuthHash(
}
return s;
};
LOG_INFO("AUTH HASH: digest=", toHex(result.data(), result.size()));
LOG_DEBUG("AUTH HASH: digest=", toHex(result.data(), result.size()));
}
return result;
@ -265,22 +265,22 @@ bool AuthChallengeParser::parse(network::Packet& packet, AuthChallengeData& data
// Original vanilla/TBC format: just the server seed (4 bytes)
data.unknown1 = 0;
data.serverSeed = packet.readUInt32();
LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (TBC format, 4 bytes):");
LOG_INFO("SMSG_AUTH_CHALLENGE: TBC format (", packet.getSize(), " bytes)");
} else if (packet.getSize() < 40) {
// Vanilla with encryption seeds (36 bytes): serverSeed + 32 bytes seeds
// No "unknown1" prefix — first uint32 IS the server seed
data.unknown1 = 0;
data.serverSeed = packet.readUInt32();
LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (Classic+seeds format, ", packet.getSize(), " bytes):");
LOG_INFO("SMSG_AUTH_CHALLENGE: Classic+seeds format (", packet.getSize(), " bytes)");
} else {
// WotLK format (40+ bytes): unknown1 + serverSeed + 32 bytes encryption seeds
data.unknown1 = packet.readUInt32();
data.serverSeed = packet.readUInt32();
LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (WotLK format, ", packet.getSize(), " bytes):");
LOG_INFO(" Unknown1: 0x", std::hex, data.unknown1, std::dec);
LOG_INFO("SMSG_AUTH_CHALLENGE: WotLK format (", packet.getSize(), " bytes)");
LOG_DEBUG(" Unknown1: 0x", std::hex, data.unknown1, std::dec);
}
LOG_INFO(" Server seed: 0x", std::hex, data.serverSeed, std::dec);
LOG_DEBUG(" Server seed: 0x", std::hex, data.serverSeed, std::dec);
return true;
}
@ -480,21 +480,9 @@ bool CharEnumParser::parse(network::Packet& packet, CharEnumResponse& response)
character.equipment.push_back(item);
}
LOG_INFO(" Character ", (int)(i + 1), ": ", character.name);
LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec);
LOG_INFO(" ", getRaceName(character.race), " ",
getClassName(character.characterClass), " (",
getGenderName(character.gender), ")");
LOG_INFO(" Level: ", (int)character.level);
LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId);
LOG_INFO(" Position: (", character.x, ", ", character.y, ", ", character.z, ")");
if (character.hasGuild()) {
LOG_INFO(" Guild ID: ", character.guildId);
}
if (character.hasPet()) {
LOG_INFO(" Pet: Model ", character.pet.displayModel,
", Level ", character.pet.level);
}
LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name,
" (", getRaceName(character.race), " ", getClassName(character.characterClass),
" level ", (int)character.level, " zone ", character.zoneId, ")");
response.characters.push_back(character);
}
@ -598,8 +586,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) {
uint32_t lineCount = packet.readUInt32();
LOG_INFO("Parsed SMSG_MOTD:");
LOG_INFO(" Line count: ", lineCount);
LOG_INFO("Parsed SMSG_MOTD: ", lineCount, " line(s)");
data.lines.clear();
data.lines.reserve(lineCount);
@ -607,7 +594,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) {
for (uint32_t i = 0; i < lineCount; ++i) {
std::string line = packet.readString();
data.lines.push_back(line);
LOG_INFO(" [", i + 1, "] ", line);
LOG_DEBUG(" MOTD[", i + 1, "]: ", line);
}
return true;
@ -878,7 +865,19 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
}
// Swimming/flying pitch
if ((moveFlags & 0x02000000) || (moveFlags2 & 0x0010)) { // MOVEMENTFLAG_SWIMMING or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING
// WotLK 3.3.5a movement flags relevant here:
// SWIMMING = 0x00200000
// FLYING = 0x01000000 (player/creature actively flying)
// SPLINE_ELEVATION = 0x02000000 (smooth vertical spline offset — no pitch field)
// MovementFlags2:
// MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING = 0x0010
//
// Pitch is present when SWIMMING or FLYING are set, or the always-allow flag is set.
// The original code checked 0x02000000 (SPLINE_ELEVATION) which neither covers SWIMMING
// nor FLYING, causing misaligned reads for swimming/flying entities in SMSG_UPDATE_OBJECT.
if ((moveFlags & 0x00200000) /* SWIMMING */ ||
(moveFlags & 0x01000000) /* FLYING */ ||
(moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) {
/*float pitch =*/ packet.readFloat();
}
@ -910,6 +909,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
/*float pitchRate =*/ packet.readFloat();
block.runSpeed = runSpeed;
block.moveFlags = moveFlags;
// Spline data
if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED
@ -1033,9 +1033,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock
block.hasMovement = true;
if (block.onTransport) {
LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec,
" pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation,
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
LOG_DEBUG(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec,
" pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation,
" offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")");
}
}
else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) {
@ -1265,11 +1265,14 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data)
if (!parseUpdateBlock(packet, block)) {
static int parseBlockErrors = 0;
if (++parseBlockErrors <= 5) {
LOG_ERROR("Failed to parse update block ", i + 1);
LOG_ERROR("Failed to parse update block ", i + 1, " of ", data.blockCount,
" (", i, " blocks parsed successfully before failure)");
if (parseBlockErrors == 5)
LOG_ERROR("(suppressing further update block parse errors)");
}
return false;
// Cannot reliably re-sync to the next block after a parse failure,
// but still return true so the blocks already parsed are processed.
break;
}
data.blocks.emplace_back(std::move(block));
@ -2241,7 +2244,7 @@ bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseDa
data.gender = packet.readUInt8();
data.classId = packet.readUInt8();
LOG_INFO("Name query response: ", data.name, " (race=", (int)data.race,
LOG_DEBUG("Name query response: ", data.name, " (race=", (int)data.race,
" class=", (int)data.classId, ")");
return true;
}
@ -2731,7 +2734,7 @@ bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) {
if (packet.getSize() < 16) return false;
data.attackerGuid = packet.readUInt64();
data.victimGuid = packet.readUInt64();
LOG_INFO("Attack started: 0x", std::hex, data.attackerGuid,
LOG_DEBUG("Attack started: 0x", std::hex, data.attackerGuid,
" -> 0x", data.victimGuid, std::dec);
return true;
}
@ -2742,7 +2745,7 @@ bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) {
if (packet.getReadPos() < packet.getSize()) {
data.unknown = packet.readUInt32();
}
LOG_INFO("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec);
LOG_DEBUG("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec);
return true;
}
@ -2771,9 +2774,9 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
data.blocked = packet.readUInt32();
}
LOG_INFO("Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
LOG_DEBUG("Melee hit: ", data.totalDamage, " damage",
data.isCrit() ? " (CRIT)" : "",
data.isMiss() ? " (MISS)" : "");
return true;
}
@ -2797,8 +2800,8 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da
// Check crit flag
data.isCrit = (flags & 0x02) != 0;
LOG_INFO("Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
LOG_DEBUG("Spell damage: spellId=", data.spellId, " dmg=", data.damage,
data.isCrit ? " CRIT" : "");
return true;
}
@ -2812,8 +2815,8 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data)
uint8_t critFlag = packet.readUInt8();
data.isCrit = (critFlag != 0);
LOG_INFO("Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
LOG_DEBUG("Spell heal: spellId=", data.spellId, " heal=", data.heal,
data.isCrit ? " CRIT" : "");
return true;
}
@ -2834,7 +2837,7 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) {
data.groupBonus = data.totalXp - static_cast<uint32_t>(data.totalXp / groupRate);
}
}
LOG_INFO("XP gain: ", data.totalXp, " xp (type=", static_cast<int>(data.type), ")");
LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast<int>(data.type), ")");
return data.totalXp > 0;
}
@ -2852,8 +2855,8 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data
size_t remainingAfterHeader = packetSize - 3; // subtract talentSpec(1) + spellCount(2)
bool vanillaFormat = remainingAfterHeader < static_cast<size_t>(spellCount) * 6 + 2;
LOG_INFO("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount,
vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)");
LOG_DEBUG("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount,
vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)");
data.spellIds.reserve(spellCount);
for (uint16_t i = 0; i < spellCount; ++i) {
@ -2889,14 +2892,13 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data
LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ",
data.cooldowns.size(), " cooldowns");
// Log first 10 spell IDs for debugging
if (!data.spellIds.empty()) {
std::string first10;
for (size_t i = 0; i < std::min(size_t(10), data.spellIds.size()); ++i) {
if (!first10.empty()) first10 += ", ";
first10 += std::to_string(data.spellIds[i]);
}
LOG_INFO("First spells: ", first10);
LOG_DEBUG("Initial spell IDs (first 10): ", first10);
}
return true;
@ -3187,7 +3189,7 @@ bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResult
data.command = static_cast<PartyCommand>(packet.readUInt32());
data.name = packet.readString();
data.result = static_cast<PartyResult>(packet.readUInt32());
LOG_INFO("Party command result: ", (int)data.result);
LOG_DEBUG("Party command result: ", (int)data.result);
return true;
}
@ -3334,7 +3336,7 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data)
}
}
LOG_INFO("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount,
LOG_DEBUG("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount,
" quest items, ", data.gold, " copper");
return true;
}
@ -3403,7 +3405,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
data.objectives = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 10 > packet.getSize()) {
LOG_INFO("Quest details (short): id=", data.questId, " title='", data.title, "'");
LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'");
return true;
}
@ -3440,7 +3442,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data)
if (packet.getReadPos() + 4 <= packet.getSize())
data.rewardXp = packet.readUInt32();
LOG_INFO("Quest details: id=", data.questId, " title='", data.title, "'");
LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'");
return true;
}
@ -3477,7 +3479,7 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data
data.quests.push_back(quest);
}
LOG_INFO("Gossip: ", optionCount, " options, ", questCount, " quests");
LOG_DEBUG("Gossip: ", optionCount, " options, ", questCount, " quests");
return true;
}
@ -3509,7 +3511,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa
data.completionText = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 9 > packet.getSize()) {
LOG_INFO("Quest request items (short): id=", data.questId, " title='", data.title, "'");
LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'");
return true;
}
@ -3585,7 +3587,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa
data.completableFlags = chosen->completableFlags;
data.requiredItems = chosen->requiredItems;
LOG_INFO("Quest request items: id=", data.questId, " title='", data.title,
LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title,
"' items=", data.requiredItems.size(), " completable=", data.isCompletable());
return true;
}
@ -3598,7 +3600,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
data.rewardText = normalizeWowTextTokens(packet.readString());
if (packet.getReadPos() + 10 > packet.getSize()) {
LOG_INFO("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");
LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'");
return true;
}
@ -3712,7 +3714,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData
data.rewardXp = best->rewardXp;
}
LOG_INFO("Quest offer reward: id=", data.questId, " title='", data.title,
LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title,
"' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size());
return true;
}
@ -3825,7 +3827,7 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data
data.items.push_back(item);
}
LOG_INFO("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")");
LOG_DEBUG("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")");
return true;
}

View file

@ -137,8 +137,31 @@ std::string AssetManager::resolveFile(const std::string& normalizedPath) const {
}
}
}
// Fall back to base manifest
return manifest_.resolveFilesystemPath(normalizedPath);
// Primary manifest
std::string primaryPath = manifest_.resolveFilesystemPath(normalizedPath);
if (!primaryPath.empty()) return primaryPath;
// If a base-path fallback is configured (expansion-specific primary that only
// holds DBC overrides), retry against the base extraction.
if (!baseFallbackDataPath_.empty()) {
return baseFallbackManifest_.resolveFilesystemPath(normalizedPath);
}
return {};
}
void AssetManager::setBaseFallbackPath(const std::string& basePath) {
if (basePath.empty() || basePath == dataPath) return; // nothing to do
std::string manifestPath = basePath + "/manifest.json";
if (!std::filesystem::exists(manifestPath)) {
LOG_DEBUG("AssetManager: base fallback manifest not found at ", manifestPath,
" — fallback disabled");
return;
}
if (baseFallbackManifest_.load(manifestPath)) {
baseFallbackDataPath_ = basePath;
LOG_INFO("AssetManager: base fallback path set to '", basePath,
"' (", baseFallbackManifest_.getEntryCount(), " files)");
}
}
BLPImage AssetManager::loadTexture(const std::string& path) {
@ -296,6 +319,55 @@ std::shared_ptr<DBCFile> AssetManager::loadDBC(const std::string& name) {
return dbc;
}
std::shared_ptr<DBCFile> AssetManager::loadDBCOptional(const std::string& name) {
// Check cache first
auto it = dbcCache.find(name);
if (it != dbcCache.end()) return it->second;
// Try binary DBC
std::vector<uint8_t> dbcData;
{
std::string dbcPath = "DBFilesClient\\" + name;
dbcData = readFile(dbcPath);
}
// Fall back to expansion-specific CSV
if (dbcData.empty() && !expansionDataPath_.empty()) {
std::string baseName = name;
auto dot = baseName.rfind('.');
if (dot != std::string::npos) baseName = baseName.substr(0, dot);
std::string csvPath = expansionDataPath_ + "/db/" + baseName + ".csv";
if (std::filesystem::exists(csvPath)) {
std::ifstream f(csvPath, std::ios::binary | std::ios::ate);
if (f) {
auto size = f.tellg();
if (size > 0) {
f.seekg(0);
dbcData.resize(static_cast<size_t>(size));
f.read(reinterpret_cast<char*>(dbcData.data()), size);
LOG_INFO("Binary DBC not found, using CSV fallback: ", csvPath);
}
}
}
}
if (dbcData.empty()) {
// Expected on some expansions — log at debug level only.
LOG_DEBUG("Optional DBC not found (expected on some expansions): ", name);
return nullptr;
}
auto dbc = std::make_shared<DBCFile>();
if (!dbc->load(dbcData)) {
LOG_ERROR("Failed to load DBC: ", name);
return nullptr;
}
dbcCache[name] = dbc;
LOG_INFO("Loaded optional DBC: ", name, " (", dbc->getRecordCount(), " records)");
return dbc;
}
std::shared_ptr<DBCFile> AssetManager::getDBC(const std::string& name) const {
auto it = dbcCache.find(name);
if (it != dbcCache.end()) {

View file

@ -1316,12 +1316,36 @@ void CameraController::update(float deltaTime) {
}
}
// ===== Camera collision (sphere sweep approximation) =====
// Find max safe distance using raycast + sphere radius
// ===== Camera collision (WMO raycast) =====
// Cast a ray from the pivot toward the camera direction to find the
// nearest WMO wall. Uses asymmetric smoothing: pull-in is fast (so
// the camera never visibly clips through a wall) but recovery is slow
// (so passing through a doorway doesn't cause a zoom-out snap).
collisionDistance = currentDistance;
// WMO/M2 camera collision disabled — was pulling camera through
// geometry at doorway transitions and causing erratic zoom behaviour.
if (wmoRenderer && currentDistance > MIN_DISTANCE) {
float rawHitDist = wmoRenderer->raycastBoundingBoxes(pivot, camDir, currentDistance);
// rawHitDist == currentDistance means no hit (function returns maxDistance on miss)
float rawLimit = (rawHitDist < currentDistance)
? std::max(MIN_DISTANCE, rawHitDist - CAM_SPHERE_RADIUS - CAM_EPSILON)
: currentDistance;
// Initialise smoothed state on first use.
if (smoothedCollisionDist_ < 0.0f) {
smoothedCollisionDist_ = rawLimit;
}
// Asymmetric smoothing:
// • Pull-in: τ ≈ 60 ms — react quickly to prevent clipping
// • Recover: τ ≈ 400 ms — zoom out slowly after leaving geometry
const float tau = (rawLimit < smoothedCollisionDist_) ? 0.06f : 0.40f;
float alpha = 1.0f - std::exp(-deltaTime / tau);
smoothedCollisionDist_ += (rawLimit - smoothedCollisionDist_) * alpha;
collisionDistance = std::min(collisionDistance, smoothedCollisionDist_);
} else {
smoothedCollisionDist_ = -1.0f; // Reset when wmoRenderer unavailable
}
// Camera collision: terrain-only floor clamping
auto getTerrainFloorAt = [&](float x, float y) -> std::optional<float> {
@ -1421,6 +1445,9 @@ void CameraController::update(float deltaTime) {
// Honor first-person intent even if anti-clipping pushes camera back slightly.
bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f);
characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer);
// Note: the Renderer's CharAnimState machine drives player character animations
// (Run, Walk, Jump, Swim, etc.) — no additional animation driving needed here.
}
} else {
// Free-fly camera mode (original behavior)

View file

@ -547,20 +547,6 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
return false;
}
// Diagnostic: log equipment vector and DBC state
LOG_INFO("applyEquipment: ", equipment.size(), " items, ItemDisplayInfo.dbc records=",
displayInfoDbc->getRecordCount(), " fields=", displayInfoDbc->getFieldCount(),
" bodySkin=", bodySkinPath_.empty() ? "(empty)" : bodySkinPath_);
for (size_t ei = 0; ei < equipment.size(); ++ei) {
const auto& it = equipment[ei];
if (it.displayModel == 0) continue;
int32_t dbcRec = displayInfoDbc->findRecordById(it.displayModel);
LOG_INFO(" slot[", ei, "]: displayModel=", it.displayModel,
" invType=", (int)it.inventoryType,
" dbcRec=", dbcRec,
(dbcRec >= 0 ? " (found)" : " (NOT FOUND in ItemDisplayInfo.dbc)"));
}
auto hasInvType = [&](std::initializer_list<uint8_t> types) -> bool {
for (const auto& it : equipment) {
if (it.displayModel == 0) continue;
@ -586,10 +572,6 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId);
if (recIdx < 0) return 0;
uint32_t val = displayInfoDbc->getUInt32(static_cast<uint32_t>(recIdx), 7 + groupField);
if (val > 0) {
LOG_INFO(" getGeosetGroup: displayInfoId=", displayInfoId,
" groupField=", groupField, " field=", (7 + groupField), " val=", val);
}
return val;
};
@ -661,6 +643,20 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
"LegUpperTexture", "LegLowerTexture", "FootTexture",
};
// Texture component region fields — use DBC layout when available, fall back to binary offsets.
const auto* idiL = pipeline::getActiveDBCLayout()
? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr;
const uint32_t texRegionFields[8] = {
idiL ? (*idiL)["TextureArmUpper"] : 14u,
idiL ? (*idiL)["TextureArmLower"] : 15u,
idiL ? (*idiL)["TextureHand"] : 16u,
idiL ? (*idiL)["TextureTorsoUpper"] : 17u,
idiL ? (*idiL)["TextureTorsoLower"] : 18u,
idiL ? (*idiL)["TextureLegUpper"] : 19u,
idiL ? (*idiL)["TextureLegLower"] : 20u,
idiL ? (*idiL)["TextureFoot"] : 21u,
};
std::vector<std::pair<int, std::string>> regionLayers;
regionLayers.reserve(32);
@ -670,13 +666,9 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
if (recIdx < 0) continue;
for (int region = 0; region < 8; region++) {
uint32_t fieldIdx = 14 + region; // texture_1..texture_8
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), fieldIdx);
std::string texName = displayInfoDbc->getString(static_cast<uint32_t>(recIdx), texRegionFields[region]);
if (texName.empty()) continue;
LOG_INFO(" texture region ", region, " (field ", fieldIdx, "): texName=", texName,
" for displayModel=", it.displayModel);
std::string base = "Item\\TextureComponents\\" +
std::string(componentDirs[region]) + "\\" + texName;
@ -692,7 +684,6 @@ bool CharacterPreview::applyEquipment(const std::vector<game::EquipmentItem>& eq
} else if (assetManager_->fileExists(basePath)) {
fullPath = basePath;
} else {
LOG_INFO(" texture path not found: ", base, " (_M/_F/_U/.blp)");
continue;
}
regionLayers.emplace_back(region, fullPath);

View file

@ -38,7 +38,6 @@
#include <unordered_set>
#include <chrono>
#include <cstdlib>
#include <fstream>
#include <limits>
#include <cstring>
@ -1061,19 +1060,6 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector<std::string>&
}
}
// Debug: dump composite to temp dir for visual inspection
{
std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" +
std::to_string(width) + "x" + std::to_string(height) + ".raw")).string();
std::ofstream dump(dumpPath, std::ios::binary);
if (dump) {
dump.write(reinterpret_cast<const char*>(composite.data()),
static_cast<std::streamsize>(composite.size()));
core::Logger::getInstance().info("Composite debug dump: ", dumpPath,
" (", width, "x", height, ", ", composite.size(), " bytes)");
}
}
// Upload composite to GPU via VkTexture
auto tex = std::make_unique<VkTexture>();
tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true);
@ -1673,7 +1659,13 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) {
if (inst.animationLoop) {
inst.animationTime = std::fmod(inst.animationTime, static_cast<float>(seq.duration));
} else {
inst.animationTime = static_cast<float>(seq.duration);
// One-shot animation finished: return to Stand (0) unless dead
if (inst.currentAnimationId != 1 /*Death*/) {
playAnimation(pair.first, 0, true);
} else {
// Stay on last frame of death
inst.animationTime = static_cast<float>(seq.duration);
}
}
}
}
@ -2207,7 +2199,6 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet,
return whiteTexture_.get();
};
// One-time debug dump of rendered batches per model
// Draw batches (submeshes) with per-batch textures
for (const auto& batch : gpuModel.data.batches) {
if (applyGeosetFilter) {
@ -2885,6 +2876,15 @@ void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds)
it->second.fadeInDuration = durationSeconds;
}
void CharacterRenderer::setInstanceOpacity(uint32_t instanceId, float opacity) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
it->second.opacity = std::clamp(opacity, 0.0f, 1.0f);
// Cancel any fade-in in progress to avoid overwriting the new opacity
it->second.fadeInDuration = 0.0f;
}
}
void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set<uint16_t>& geosets) {
auto it = instances.find(instanceId);
if (it != instances.end()) {
@ -3175,6 +3175,13 @@ bool CharacterRenderer::getInstanceFootZ(uint32_t instanceId, float& outFootZ) c
return true;
}
bool CharacterRenderer::getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const {
auto it = instances.find(instanceId);
if (it == instances.end()) return false;
outPos = it->second.position;
return true;
}
void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) {
auto charIt = instances.find(charInstanceId);
if (charIt == instances.end()) return;

File diff suppressed because it is too large Load diff

View file

@ -261,6 +261,18 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
ImGui::BeginChild("QuestListPane", ImVec2(paneW, 0), true);
ImGui::TextColored(ImVec4(0.85f, 0.82f, 0.74f, 1.0f), "Quest List");
ImGui::Separator();
// Resolve pending select from tracker click
if (pendingSelectQuestId_ != 0) {
for (size_t i = 0; i < quests.size(); i++) {
if (quests[i].questId == pendingSelectQuestId_) {
selectedIndex = static_cast<int>(i);
break;
}
}
pendingSelectQuestId_ = 0;
}
for (size_t i = 0; i < quests.size(); i++) {
const auto& q = quests[i];
ImGui::PushID(static_cast<int>(i));
@ -274,6 +286,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
if (rowW < 1.0f) rowW = 1.0f;
bool clicked = ImGui::InvisibleButton("questRowBtn", ImVec2(rowW, rowH));
bool hovered = ImGui::IsItemHovered();
// Scroll to selected quest on the first frame after openAndSelectQuest()
if (selected && scrollToSelected_) {
ImGui::SetScrollHereY(0.5f);
scrollToSelected_ = false;
}
ImVec2 rowMin = ImGui::GetItemRectMin();
ImVec2 rowMax = ImGui::GetItemRectMax();
@ -373,11 +390,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) {
}
}
// Abandon button
// Track / Abandon buttons
ImGui::Separator();
bool isTracked = gameHandler.isQuestTracked(sel.questId);
if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) {
gameHandler.setQuestTracked(sel.questId, !isTracked);
}
if (!sel.complete) {
ImGui::Separator();
ImGui::SameLine();
if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) {
gameHandler.abandonQuest(sel.questId);
gameHandler.setQuestTracked(sel.questId, false);
selectedIndex = -1;
}
}

View file

@ -76,8 +76,29 @@ static std::vector<uint8_t> readFileBytes(const std::string& path) {
return buf;
}
static bool isValidStringOffset(const std::vector<uint8_t>& stringBlock, uint32_t offset) {
// Precompute the set of valid string-boundary offsets in the string block.
// An offset is a valid boundary if it is 0 or immediately follows a null byte.
// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely
// detected as string offsets just because they land in the middle of a longer
// string that starts at a lower offset.
static std::set<uint32_t> computeStringBoundaries(const std::vector<uint8_t>& stringBlock) {
std::set<uint32_t> boundaries;
if (stringBlock.empty()) return boundaries;
boundaries.insert(0);
for (size_t i = 0; i + 1 < stringBlock.size(); ++i) {
if (stringBlock[i] == 0) {
boundaries.insert(static_cast<uint32_t>(i + 1));
}
}
return boundaries;
}
static bool isValidStringOffset(const std::vector<uint8_t>& stringBlock,
const std::set<uint32_t>& boundaries,
uint32_t offset) {
if (offset >= stringBlock.size()) return false;
// Must start at a string boundary (offset 0 or right after a null byte).
if (!boundaries.count(offset)) return false;
for (size_t i = offset; i < stringBlock.size(); ++i) {
uint8_t c = stringBlock[i];
if (c == 0) return true;
@ -105,21 +126,33 @@ static std::set<uint32_t> detectStringColumns(const DBCFile& dbc,
std::set<uint32_t> cols;
if (stringBlock.size() <= 1) return cols;
auto boundaries = computeStringBoundaries(stringBlock);
for (uint32_t col = 0; col < fieldCount; ++col) {
bool allZeroOrValid = true;
bool hasNonZero = false;
std::set<std::string> distinctStrings;
for (uint32_t row = 0; row < recordCount; ++row) {
uint32_t val = dbc.getUInt32(row, col);
if (val == 0) continue;
hasNonZero = true;
if (!isValidStringOffset(stringBlock, val)) {
if (!isValidStringOffset(stringBlock, boundaries, val)) {
allZeroOrValid = false;
break;
}
// Collect distinct non-empty strings for diversity check.
const char* s = reinterpret_cast<const char*>(stringBlock.data() + val);
if (*s != '\0') {
distinctStrings.insert(std::string(s, strnlen(s, 256)));
}
}
if (allZeroOrValid && hasNonZero) {
// Require at least 2 distinct non-empty string values. Columns that
// only ever point to a single string (e.g. SexID=1 always resolves to
// the same path fragment at offset 1 in the block) are almost certainly
// integer fields whose small values accidentally land at a string boundary.
if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) {
cols.insert(col);
}
}

View file

@ -41,9 +41,31 @@ std::vector<uint8_t> readFileBytes(const std::string& path) {
return buf;
}
// Check whether offset points to a plausible string in the string block.
bool isValidStringOffset(const std::vector<uint8_t>& stringBlock, uint32_t offset) {
// Precompute the set of valid string-boundary offsets in the string block.
// An offset is a valid boundary if it is 0 or immediately follows a null byte.
// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely
// detected as string offsets just because they land in the middle of a longer
// string that starts at a lower offset.
std::set<uint32_t> computeStringBoundaries(const std::vector<uint8_t>& stringBlock) {
std::set<uint32_t> boundaries;
if (stringBlock.empty()) return boundaries;
boundaries.insert(0); // offset 0 is always a valid start
for (size_t i = 0; i + 1 < stringBlock.size(); ++i) {
if (stringBlock[i] == 0) {
boundaries.insert(static_cast<uint32_t>(i + 1));
}
}
return boundaries;
}
// Check whether offset points to a valid string-boundary position in the block
// and that the string there is printable and null-terminated.
bool isValidStringOffset(const std::vector<uint8_t>& stringBlock,
const std::set<uint32_t>& boundaries,
uint32_t offset) {
if (offset >= stringBlock.size()) return false;
// Must start at a string boundary (offset 0 or right after a null byte).
if (!boundaries.count(offset)) return false;
// Must be null-terminated within the block and contain only printable/whitespace bytes.
for (size_t i = offset; i < stringBlock.size(); ++i) {
uint8_t c = stringBlock[i];
@ -75,21 +97,35 @@ std::set<uint32_t> detectStringColumns(const DBCFile& dbc,
// If no string block (or trivial size), no string columns.
if (stringBlock.size() <= 1) return stringCols;
// Precompute valid string-start boundaries to avoid false positives from
// integer fields whose small values accidentally land inside longer strings.
auto boundaries = computeStringBoundaries(stringBlock);
for (uint32_t col = 0; col < fieldCount; ++col) {
bool allZeroOrValid = true;
bool hasNonZero = false;
std::set<std::string> distinctStrings;
for (uint32_t row = 0; row < recordCount; ++row) {
uint32_t val = dbc.getUInt32(row, col);
if (val == 0) continue;
hasNonZero = true;
if (!isValidStringOffset(stringBlock, val)) {
if (!isValidStringOffset(stringBlock, boundaries, val)) {
allZeroOrValid = false;
break;
}
// Collect distinct non-empty strings for diversity check.
const char* s = reinterpret_cast<const char*>(stringBlock.data() + val);
if (*s != '\0') {
distinctStrings.insert(std::string(s, strnlen(s, 256)));
}
}
if (allZeroOrValid && hasNonZero) {
// Require at least 2 distinct non-empty string values. Columns that
// only ever point to a single string (e.g. SexID=1 always resolves to
// the same path fragment at offset 1 in the block) are almost certainly
// integer fields whose small values accidentally land at a string boundary.
if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) {
stringCols.insert(col);
}
}