diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6893f717..0a3c81d1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,6 +7,7 @@ on: branches: [master] env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true WOWEE_AMD_FSR2_REPO: https://github.com/GPUOpen-Effects/FidelityFX-FSR2.git WOWEE_AMD_FSR2_REF: master WOWEE_FFX_SDK_REPO: https://github.com/Kelsidavis/FidelityFX-SDK.git diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0698607a..34917dd1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ['v*'] +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + permissions: contents: write diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4709225b..6e5f63af 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -7,6 +7,9 @@ on: branches: [master] workflow_dispatch: +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + permissions: contents: read diff --git a/.gitignore b/.gitignore index 4ddf59ab..e4348ceb 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ extern/* !extern/imgui !extern/vk-bootstrap !extern/vk_mem_alloc.h +!extern/lua-5.1.5 # ImGui state imgui.ini @@ -100,3 +101,10 @@ node_modules/ # Python cache artifacts tools/__pycache__/ *.pyc + +# artifacts +.codex-loop/ + +# Local agent instructions +AGENTS.md +codex-loop.sh diff --git a/.semgrepignore b/.semgrepignore new file mode 100644 index 00000000..eb36847a --- /dev/null +++ b/.semgrepignore @@ -0,0 +1,8 @@ +# Vendored third-party code (frozen releases, not ours to modify) +extern/lua-5.1.5/ +extern/imgui/ +extern/stb_image.h +extern/stb_image_write.h +extern/vk-bootstrap/ +extern/FidelityFX-FSR2/ +extern/FidelityFX-SDK/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 54f39283..16be9564 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.15) -project(wowee VERSION 1.0.0 LANGUAGES CXX) +project(wowee VERSION 1.0.0 LANGUAGES C CXX) include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 20) @@ -550,6 +550,12 @@ set(WOWEE_SOURCES src/ui/quest_log_screen.cpp src/ui/spellbook_screen.cpp src/ui/talent_screen.cpp + src/ui/keybinding_manager.cpp + + # Addons + src/addons/addon_manager.cpp + src/addons/lua_engine.cpp + src/addons/toc_parser.cpp # Main src/main.cpp @@ -653,6 +659,7 @@ set(WOWEE_HEADERS include/ui/inventory_screen.hpp include/ui/spellbook_screen.hpp include/ui/talent_screen.hpp + include/ui/keybinding_manager.hpp ) set(WOWEE_PLATFORM_SOURCES) @@ -666,6 +673,27 @@ if(WIN32) list(APPEND WOWEE_PLATFORM_SOURCES resources/wowee.rc) endif() +# ---- Lua 5.1.5 (vendored, static library) ---- +set(LUA_DIR ${CMAKE_CURRENT_SOURCE_DIR}/extern/lua-5.1.5/src) +set(LUA_SOURCES + ${LUA_DIR}/lapi.c ${LUA_DIR}/lcode.c ${LUA_DIR}/ldebug.c + ${LUA_DIR}/ldo.c ${LUA_DIR}/ldump.c ${LUA_DIR}/lfunc.c + ${LUA_DIR}/lgc.c ${LUA_DIR}/llex.c ${LUA_DIR}/lmem.c + ${LUA_DIR}/lobject.c ${LUA_DIR}/lopcodes.c ${LUA_DIR}/lparser.c + ${LUA_DIR}/lstate.c ${LUA_DIR}/lstring.c ${LUA_DIR}/ltable.c + ${LUA_DIR}/ltm.c ${LUA_DIR}/lundump.c ${LUA_DIR}/lvm.c + ${LUA_DIR}/lzio.c ${LUA_DIR}/lauxlib.c ${LUA_DIR}/lbaselib.c + ${LUA_DIR}/ldblib.c ${LUA_DIR}/liolib.c ${LUA_DIR}/lmathlib.c + ${LUA_DIR}/loslib.c ${LUA_DIR}/ltablib.c ${LUA_DIR}/lstrlib.c + ${LUA_DIR}/linit.c +) +add_library(lua51 STATIC ${LUA_SOURCES}) +set_target_properties(lua51 PROPERTIES LINKER_LANGUAGE C C_STANDARD 99 POSITION_INDEPENDENT_CODE ON) +target_include_directories(lua51 PUBLIC ${LUA_DIR}) +if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(lua51 PRIVATE -w) +endif() + # Create executable add_executable(wowee ${WOWEE_SOURCES} ${WOWEE_HEADERS} ${WOWEE_PLATFORM_SOURCES}) if(TARGET opcodes-generate) @@ -707,6 +735,7 @@ target_link_libraries(wowee PRIVATE OpenSSL::Crypto Threads::Threads ZLIB::ZLIB + lua51 ${CMAKE_DL_LIBS} ) diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 4ec229d5..ae75e254 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,96 +1,256 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 + "ID": 0, + "Attributes": 5, + "AttributesEx": 6, + "IconID": 117, + "Name": 120, + "Tooltip": 147, + "Rank": 129, + "SchoolEnum": 1, + "CastingTimeIndex": 15, + "PowerType": 28, + "ManaCost": 29, + "RangeIndex": 33, + "DispelType": 4 + }, + "SpellRange": { + "MaxRange": 2 }, "ItemDisplayInfo": { - "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, - "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, - "TextureArmUpper": 14, "TextureArmLower": 15, "TextureHand": 16, - "TextureTorsoUpper": 17, "TextureTorsoLower": 18, - "TextureLegUpper": 19, "TextureLegLower": 20, "TextureFoot": 21 + "ID": 0, + "LeftModel": 1, + "LeftModelTexture": 3, + "InventoryIcon": 5, + "GeosetGroup1": 7, + "GeosetGroup3": 9, + "TextureArmUpper": 14, + "TextureArmLower": 15, + "TextureHand": 16, + "TextureTorsoUpper": 17, + "TextureTorsoLower": 18, + "TextureLegUpper": 19, + "TextureLegLower": 20, + "TextureFoot": 21 }, "CharSections": { - "RaceID": 1, "SexID": 2, "BaseSection": 3, - "VariationIndex": 4, "ColorIndex": 5, - "Texture1": 6, "Texture2": 7, "Texture3": 8, + "RaceID": 1, + "SexID": 2, + "BaseSection": 3, + "VariationIndex": 4, + "ColorIndex": 5, + "Texture1": 6, + "Texture2": 7, + "Texture3": 8, "Flags": 9 }, - "SpellIcon": { "ID": 0, "Path": 1 }, + "SpellIcon": { + "ID": 0, + "Path": 1 + }, "FactionTemplate": { - "ID": 0, "Faction": 1, "FactionGroup": 3, - "FriendGroup": 4, "EnemyGroup": 5, - "Enemy0": 6, "Enemy1": 7, "Enemy2": 8, "Enemy3": 9 + "ID": 0, + "Faction": 1, + "FactionGroup": 3, + "FriendGroup": 4, + "EnemyGroup": 5, + "Enemy0": 6, + "Enemy1": 7, + "Enemy2": 8, + "Enemy3": 9 }, "Faction": { - "ID": 0, "ReputationRaceMask0": 2, "ReputationRaceMask1": 3, - "ReputationRaceMask2": 4, "ReputationRaceMask3": 5, - "ReputationBase0": 10, "ReputationBase1": 11, - "ReputationBase2": 12, "ReputationBase3": 13 + "ID": 0, + "ReputationRaceMask0": 2, + "ReputationRaceMask1": 3, + "ReputationRaceMask2": 4, + "ReputationRaceMask3": 5, + "ReputationBase0": 10, + "ReputationBase1": 11, + "ReputationBase2": 12, + "ReputationBase3": 13 + }, + "AreaTable": { + "ID": 0, + "MapID": 1, + "ParentAreaNum": 2, + "ExploreFlag": 3 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { - "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, - "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, - "EquipDisplay0": 8, "EquipDisplay1": 9, "EquipDisplay2": 10, - "EquipDisplay3": 11, "EquipDisplay4": 12, "EquipDisplay5": 13, - "EquipDisplay6": 14, "EquipDisplay7": 15, "EquipDisplay8": 16, - "EquipDisplay9": 17, "EquipDisplay10": 18, "BakeName": 20 + "ID": 0, + "RaceID": 1, + "SexID": 2, + "SkinID": 3, + "FaceID": 4, + "HairStyleID": 5, + "HairColorID": 6, + "FacialHairID": 7, + "EquipDisplay0": 8, + "EquipDisplay1": 9, + "EquipDisplay2": 10, + "EquipDisplay3": 11, + "EquipDisplay4": 12, + "EquipDisplay5": 13, + "EquipDisplay6": 14, + "EquipDisplay7": 15, + "EquipDisplay8": 16, + "EquipDisplay9": 17, + "EquipDisplay10": 18, + "BakeName": 20 }, "CreatureDisplayInfo": { - "ID": 0, "ModelID": 1, "ExtraDisplayId": 3, - "Skin1": 6, "Skin2": 7, "Skin3": 8 + "ID": 0, + "ModelID": 1, + "ExtraDisplayId": 3, + "Skin1": 6, + "Skin2": 7, + "Skin3": 8 }, "TaxiNodes": { - "ID": 0, "MapID": 1, "X": 2, "Y": 3, "Z": 4, "Name": 5 + "ID": 0, + "MapID": 1, + "X": 2, + "Y": 3, + "Z": 4, + "Name": 5 + }, + "TaxiPath": { + "ID": 0, + "FromNode": 1, + "ToNode": 2, + "Cost": 3 }, - "TaxiPath": { "ID": 0, "FromNode": 1, "ToNode": 2, "Cost": 3 }, "TaxiPathNode": { - "ID": 0, "PathID": 1, "NodeIndex": 2, "MapID": 3, - "X": 4, "Y": 5, "Z": 6 + "ID": 0, + "PathID": 1, + "NodeIndex": 2, + "MapID": 3, + "X": 4, + "Y": 5, + "Z": 6 }, "TalentTab": { - "ID": 0, "Name": 1, "ClassMask": 12, - "OrderIndex": 14, "BackgroundFile": 15 + "ID": 0, + "Name": 1, + "ClassMask": 12, + "OrderIndex": 14, + "BackgroundFile": 15 }, "Talent": { - "ID": 0, "TabID": 1, "Row": 2, "Column": 3, - "RankSpell0": 4, "PrereqTalent0": 9, "PrereqRank0": 12 + "ID": 0, + "TabID": 1, + "Row": 2, + "Column": 3, + "RankSpell0": 4, + "PrereqTalent0": 9, + "PrereqRank0": 12 + }, + "SkillLineAbility": { + "SkillLineID": 1, + "SpellID": 2 + }, + "SkillLine": { + "ID": 0, + "Category": 1, + "Name": 3 + }, + "Map": { + "ID": 0, + "InternalName": 1 + }, + "CreatureModelData": { + "ID": 0, + "ModelPath": 2 }, - "SkillLineAbility": { "SkillLineID": 1, "SpellID": 2 }, - "SkillLine": { "ID": 0, "Category": 1, "Name": 3 }, - "Map": { "ID": 0, "InternalName": 1 }, - "CreatureModelData": { "ID": 0, "ModelPath": 2 }, "CharHairGeosets": { - "RaceID": 1, "SexID": 2, "Variation": 3, "GeosetID": 4 + "RaceID": 1, + "SexID": 2, + "Variation": 3, + "GeosetID": 4 }, "CharacterFacialHairStyles": { - "RaceID": 0, "SexID": 1, "Variation": 2, - "Geoset100": 3, "Geoset300": 4, "Geoset200": 5 + "RaceID": 0, + "SexID": 1, + "Variation": 2, + "Geoset100": 3, + "Geoset300": 4, + "Geoset200": 5 + }, + "GameObjectDisplayInfo": { + "ID": 0, + "ModelName": 1 + }, + "Emotes": { + "ID": 0, + "AnimID": 2 }, - "GameObjectDisplayInfo": { "ID": 0, "ModelName": 1 }, - "Emotes": { "ID": 0, "AnimID": 2 }, "EmotesText": { - "ID": 0, "Command": 1, "EmoteRef": 2, - "OthersTargetTextID": 3, "SenderTargetTextID": 5, - "OthersNoTargetTextID": 7, "SenderNoTargetTextID": 9 + "ID": 0, + "Command": 1, + "EmoteRef": 2, + "OthersTargetTextID": 3, + "SenderTargetTextID": 5, + "OthersNoTargetTextID": 7, + "SenderNoTargetTextID": 9 + }, + "EmotesTextData": { + "ID": 0, + "Text": 1 }, - "EmotesTextData": { "ID": 0, "Text": 1 }, "Light": { - "ID": 0, "MapID": 1, "X": 2, "Z": 3, "Y": 4, - "InnerRadius": 5, "OuterRadius": 6, "LightParamsID": 7, - "LightParamsIDRain": 8, "LightParamsIDUnderwater": 9 + "ID": 0, + "MapID": 1, + "X": 2, + "Z": 3, + "Y": 4, + "InnerRadius": 5, + "OuterRadius": 6, + "LightParamsID": 7, + "LightParamsIDRain": 8, + "LightParamsIDUnderwater": 9 + }, + "LightParams": { + "LightParamsID": 0 }, - "LightParams": { "LightParamsID": 0 }, "LightIntBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "LightFloatBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "WorldMapArea": { - "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, - "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, - "DisplayMapID": 8, "ParentWorldMapID": 10 + "ID": 0, + "MapID": 1, + "AreaID": 2, + "AreaName": 3, + "LocLeft": 4, + "LocRight": 5, + "LocTop": 6, + "LocBottom": 7, + "DisplayMapID": 8, + "ParentWorldMapID": 10 + }, + "SpellVisual": { + "ID": 0, + "CastKit": 2, + "ImpactKit": 3, + "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, + "BaseEffect": 5, + "SpecialEffect0": 11, + "SpecialEffect1": 12, + "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, + "FilePath": 2 } } diff --git a/Data/expansions/classic/opcodes.json b/Data/expansions/classic/opcodes.json index b99e4223..760647d9 100644 --- a/Data/expansions/classic/opcodes.json +++ b/Data/expansions/classic/opcodes.json @@ -273,7 +273,7 @@ "SMSG_INVENTORY_CHANGE_FAILURE": "0x112", "SMSG_OPEN_CONTAINER": "0x113", "CMSG_INSPECT": "0x114", - "SMSG_INSPECT": "0x115", + "SMSG_INSPECT_RESULTS_UPDATE": "0x115", "CMSG_INITIATE_TRADE": "0x116", "CMSG_BEGIN_TRADE": "0x117", "CMSG_BUSY_TRADE": "0x118", @@ -300,7 +300,7 @@ "CMSG_NEW_SPELL_SLOT": "0x12D", "CMSG_CAST_SPELL": "0x12E", "CMSG_CANCEL_CAST": "0x12F", - "SMSG_CAST_RESULT": "0x130", + "SMSG_CAST_FAILED": "0x130", "SMSG_SPELL_START": "0x131", "SMSG_SPELL_GO": "0x132", "SMSG_SPELL_FAILURE": "0x133", @@ -504,8 +504,7 @@ "CMSG_GM_SET_SECURITY_GROUP": "0x1F9", "CMSG_GM_NUKE": "0x1FA", "MSG_RANDOM_ROLL": "0x1FB", - "SMSG_ENVIRONMENTALDAMAGELOG": "0x1FC", - "CMSG_RWHOIS_OBSOLETE": "0x1FD", + "SMSG_ENVIRONMENTAL_DAMAGE_LOG": "0x1FC", "SMSG_RWHOIS": "0x1FE", "MSG_LOOKING_FOR_GROUP": "0x1FF", "CMSG_SET_LOOKING_FOR_GROUP": "0x200", @@ -528,7 +527,6 @@ "CMSG_GMTICKET_GETTICKET": "0x211", "SMSG_GMTICKET_GETTICKET": "0x212", "CMSG_UNLEARN_TALENTS": "0x213", - "SMSG_GAMEOBJECT_SPAWN_ANIM_OBSOLETE": "0x214", "SMSG_GAMEOBJECT_DESPAWN_ANIM": "0x215", "MSG_CORPSE_QUERY": "0x216", "CMSG_GMTICKET_DELETETICKET": "0x217", @@ -538,7 +536,7 @@ "SMSG_GMTICKET_SYSTEMSTATUS": "0x21B", "CMSG_SPIRIT_HEALER_ACTIVATE": "0x21C", "CMSG_SET_STAT_CHEAT": "0x21D", - "SMSG_SET_REST_START": "0x21E", + "SMSG_QUEST_FORCE_REMOVE": "0x21E", "CMSG_SKILL_BUY_STEP": "0x21F", "CMSG_SKILL_BUY_RANK": "0x220", "CMSG_XP_CHEAT": "0x221", @@ -571,8 +569,6 @@ "CMSG_BATTLEFIELD_LIST": "0x23C", "SMSG_BATTLEFIELD_LIST": "0x23D", "CMSG_BATTLEFIELD_JOIN": "0x23E", - "SMSG_BATTLEFIELD_WIN_OBSOLETE": "0x23F", - "SMSG_BATTLEFIELD_LOSE_OBSOLETE": "0x240", "CMSG_TAXICLEARNODE": "0x241", "CMSG_TAXIENABLENODE": "0x242", "CMSG_ITEM_TEXT_QUERY": "0x243", @@ -605,7 +601,6 @@ "SMSG_AUCTION_BIDDER_NOTIFICATION": "0x25E", "SMSG_AUCTION_OWNER_NOTIFICATION": "0x25F", "SMSG_PROCRESIST": "0x260", - "SMSG_STANDSTATE_CHANGE_FAILURE_OBSOLETE": "0x261", "SMSG_DISPEL_FAILED": "0x262", "SMSG_SPELLORDAMAGE_IMMUNE": "0x263", "CMSG_AUCTION_LIST_BIDDER_ITEMS": "0x264", @@ -693,8 +688,8 @@ "SMSG_SCRIPT_MESSAGE": "0x2B6", "SMSG_DUEL_COUNTDOWN": "0x2B7", "SMSG_AREA_TRIGGER_MESSAGE": "0x2B8", - "CMSG_TOGGLE_HELM": "0x2B9", - "CMSG_TOGGLE_CLOAK": "0x2BA", + "CMSG_SHOWING_HELM": "0x2B9", + "CMSG_SHOWING_CLOAK": "0x2BA", "SMSG_MEETINGSTONE_JOINFAILED": "0x2BB", "SMSG_PLAYER_SKINNED": "0x2BC", "SMSG_DURABILITY_DAMAGE_DEATH": "0x2BD", @@ -821,6 +816,5 @@ "SMSG_LOTTERY_RESULT_OBSOLETE": "0x337", "SMSG_CHARACTER_PROFILE": "0x338", "SMSG_CHARACTER_PROFILE_REALM_CONNECTED": "0x339", - "SMSG_UNK": "0x33A", "SMSG_DEFENSE_MESSAGE": "0x33B" } diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 5f97f29f..4f340df5 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, @@ -13,15 +14,22 @@ "UNIT_FIELD_DISPLAYID": 131, "UNIT_FIELD_MOUNTDISPLAYID": 133, "UNIT_FIELD_AURAS": 50, + "UNIT_FIELD_AURAFLAGS": 98, "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, + "PLAYER_REST_STATE_EXPERIENCE": 1175, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, @@ -33,6 +41,8 @@ "PLAYER_END": 1282, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 48, + "ITEM_FIELD_MAXDURABILITY": 49, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 } diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index d40a5766..8142434e 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,98 +1,303 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 + "ID": 0, + "Attributes": 5, + "AttributesEx": 6, + "IconID": 124, + "Name": 127, + "Tooltip": 154, + "Rank": 136, + "SchoolMask": 215, + "CastingTimeIndex": 22, + "PowerType": 35, + "ManaCost": 36, + "RangeIndex": 40, + "DispelType": 3 + }, + "SpellRange": { + "MaxRange": 4 }, "ItemDisplayInfo": { - "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, - "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, - "TextureArmUpper": 14, "TextureArmLower": 15, "TextureHand": 16, - "TextureTorsoUpper": 17, "TextureTorsoLower": 18, - "TextureLegUpper": 19, "TextureLegLower": 20, "TextureFoot": 21 + "ID": 0, + "LeftModel": 1, + "LeftModelTexture": 3, + "InventoryIcon": 5, + "GeosetGroup1": 7, + "GeosetGroup3": 9, + "TextureArmUpper": 14, + "TextureArmLower": 15, + "TextureHand": 16, + "TextureTorsoUpper": 17, + "TextureTorsoLower": 18, + "TextureLegUpper": 19, + "TextureLegLower": 20, + "TextureFoot": 21 }, "CharSections": { - "RaceID": 1, "SexID": 2, "BaseSection": 3, - "VariationIndex": 4, "ColorIndex": 5, - "Texture1": 6, "Texture2": 7, "Texture3": 8, + "RaceID": 1, + "SexID": 2, + "BaseSection": 3, + "VariationIndex": 4, + "ColorIndex": 5, + "Texture1": 6, + "Texture2": 7, + "Texture3": 8, "Flags": 9 }, - "SpellIcon": { "ID": 0, "Path": 1 }, + "SpellIcon": { + "ID": 0, + "Path": 1 + }, "FactionTemplate": { - "ID": 0, "Faction": 1, "FactionGroup": 3, - "FriendGroup": 4, "EnemyGroup": 5, - "Enemy0": 6, "Enemy1": 7, "Enemy2": 8, "Enemy3": 9 + "ID": 0, + "Faction": 1, + "FactionGroup": 3, + "FriendGroup": 4, + "EnemyGroup": 5, + "Enemy0": 6, + "Enemy1": 7, + "Enemy2": 8, + "Enemy3": 9 }, "Faction": { - "ID": 0, "ReputationRaceMask0": 2, "ReputationRaceMask1": 3, - "ReputationRaceMask2": 4, "ReputationRaceMask3": 5, - "ReputationBase0": 10, "ReputationBase1": 11, - "ReputationBase2": 12, "ReputationBase3": 13 + "ID": 0, + "ReputationRaceMask0": 2, + "ReputationRaceMask1": 3, + "ReputationRaceMask2": 4, + "ReputationRaceMask3": 5, + "ReputationBase0": 10, + "ReputationBase1": 11, + "ReputationBase2": 12, + "ReputationBase3": 13 + }, + "CharTitles": { + "ID": 0, + "Title": 2, + "TitleBit": 20 + }, + "AreaTable": { + "ID": 0, + "MapID": 1, + "ParentAreaNum": 2, + "ExploreFlag": 3 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { - "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, - "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, - "EquipDisplay0": 8, "EquipDisplay1": 9, "EquipDisplay2": 10, - "EquipDisplay3": 11, "EquipDisplay4": 12, "EquipDisplay5": 13, - "EquipDisplay6": 14, "EquipDisplay7": 15, "EquipDisplay8": 16, - "EquipDisplay9": 17, "EquipDisplay10": 18, "BakeName": 20 + "ID": 0, + "RaceID": 1, + "SexID": 2, + "SkinID": 3, + "FaceID": 4, + "HairStyleID": 5, + "HairColorID": 6, + "FacialHairID": 7, + "EquipDisplay0": 8, + "EquipDisplay1": 9, + "EquipDisplay2": 10, + "EquipDisplay3": 11, + "EquipDisplay4": 12, + "EquipDisplay5": 13, + "EquipDisplay6": 14, + "EquipDisplay7": 15, + "EquipDisplay8": 16, + "EquipDisplay9": 17, + "EquipDisplay10": 18, + "BakeName": 20 }, "CreatureDisplayInfo": { - "ID": 0, "ModelID": 1, "ExtraDisplayId": 3, - "Skin1": 6, "Skin2": 7, "Skin3": 8 + "ID": 0, + "ModelID": 1, + "ExtraDisplayId": 3, + "Skin1": 6, + "Skin2": 7, + "Skin3": 8 }, "TaxiNodes": { - "ID": 0, "MapID": 1, "X": 2, "Y": 3, "Z": 4, "Name": 5, - "MountDisplayIdAllianceFallback": 12, "MountDisplayIdHordeFallback": 13, - "MountDisplayIdAlliance": 14, "MountDisplayIdHorde": 15 + "ID": 0, + "MapID": 1, + "X": 2, + "Y": 3, + "Z": 4, + "Name": 5, + "MountDisplayIdAllianceFallback": 12, + "MountDisplayIdHordeFallback": 13, + "MountDisplayIdAlliance": 14, + "MountDisplayIdHorde": 15 + }, + "TaxiPath": { + "ID": 0, + "FromNode": 1, + "ToNode": 2, + "Cost": 3 }, - "TaxiPath": { "ID": 0, "FromNode": 1, "ToNode": 2, "Cost": 3 }, "TaxiPathNode": { - "ID": 0, "PathID": 1, "NodeIndex": 2, "MapID": 3, - "X": 4, "Y": 5, "Z": 6 + "ID": 0, + "PathID": 1, + "NodeIndex": 2, + "MapID": 3, + "X": 4, + "Y": 5, + "Z": 6 }, "TalentTab": { - "ID": 0, "Name": 1, "ClassMask": 12, - "OrderIndex": 14, "BackgroundFile": 15 + "ID": 0, + "Name": 1, + "ClassMask": 12, + "OrderIndex": 14, + "BackgroundFile": 15 }, "Talent": { - "ID": 0, "TabID": 1, "Row": 2, "Column": 3, - "RankSpell0": 4, "PrereqTalent0": 9, "PrereqRank0": 12 + "ID": 0, + "TabID": 1, + "Row": 2, + "Column": 3, + "RankSpell0": 4, + "PrereqTalent0": 9, + "PrereqRank0": 12 + }, + "SkillLineAbility": { + "SkillLineID": 1, + "SpellID": 2 + }, + "SkillLine": { + "ID": 0, + "Category": 1, + "Name": 3 + }, + "Map": { + "ID": 0, + "InternalName": 1 + }, + "CreatureModelData": { + "ID": 0, + "ModelPath": 2 }, - "SkillLineAbility": { "SkillLineID": 1, "SpellID": 2 }, - "SkillLine": { "ID": 0, "Category": 1, "Name": 3 }, - "Map": { "ID": 0, "InternalName": 1 }, - "CreatureModelData": { "ID": 0, "ModelPath": 2 }, "CharHairGeosets": { - "RaceID": 1, "SexID": 2, "Variation": 3, "GeosetID": 4 + "RaceID": 1, + "SexID": 2, + "Variation": 3, + "GeosetID": 4 }, "CharacterFacialHairStyles": { - "RaceID": 0, "SexID": 1, "Variation": 2, - "Geoset100": 3, "Geoset300": 4, "Geoset200": 5 + "RaceID": 0, + "SexID": 1, + "Variation": 2, + "Geoset100": 3, + "Geoset300": 4, + "Geoset200": 5 + }, + "GameObjectDisplayInfo": { + "ID": 0, + "ModelName": 1 + }, + "Emotes": { + "ID": 0, + "AnimID": 2 }, - "GameObjectDisplayInfo": { "ID": 0, "ModelName": 1 }, - "Emotes": { "ID": 0, "AnimID": 2 }, "EmotesText": { - "ID": 0, "Command": 1, "EmoteRef": 2, - "OthersTargetTextID": 3, "SenderTargetTextID": 5, - "OthersNoTargetTextID": 7, "SenderNoTargetTextID": 9 + "ID": 0, + "Command": 1, + "EmoteRef": 2, + "OthersTargetTextID": 3, + "SenderTargetTextID": 5, + "OthersNoTargetTextID": 7, + "SenderNoTargetTextID": 9 + }, + "EmotesTextData": { + "ID": 0, + "Text": 1 }, - "EmotesTextData": { "ID": 0, "Text": 1 }, "Light": { - "ID": 0, "MapID": 1, "X": 2, "Z": 3, "Y": 4, - "InnerRadius": 5, "OuterRadius": 6, "LightParamsID": 7, - "LightParamsIDRain": 8, "LightParamsIDUnderwater": 9 + "ID": 0, + "MapID": 1, + "X": 2, + "Z": 3, + "Y": 4, + "InnerRadius": 5, + "OuterRadius": 6, + "LightParamsID": 7, + "LightParamsIDRain": 8, + "LightParamsIDUnderwater": 9 + }, + "LightParams": { + "LightParamsID": 0 }, - "LightParams": { "LightParamsID": 0 }, "LightIntBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "LightFloatBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "WorldMapArea": { - "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, - "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, - "DisplayMapID": 8, "ParentWorldMapID": 10 + "ID": 0, + "MapID": 1, + "AreaID": 2, + "AreaName": 3, + "LocLeft": 4, + "LocRight": 5, + "LocTop": 6, + "LocBottom": 7, + "DisplayMapID": 8, + "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, + "Name": 8 + }, + "ItemSet": { + "ID": 0, + "Name": 1, + "Item0": 18, + "Item1": 19, + "Item2": 20, + "Item3": 21, + "Item4": 22, + "Item5": 23, + "Item6": 24, + "Item7": 25, + "Item8": 26, + "Item9": 27, + "Spell0": 28, + "Spell1": 29, + "Spell2": 30, + "Spell3": 31, + "Spell4": 32, + "Spell5": 33, + "Spell6": 34, + "Spell7": 35, + "Spell8": 36, + "Spell9": 37, + "Threshold0": 38, + "Threshold1": 39, + "Threshold2": 40, + "Threshold3": 41, + "Threshold4": 42, + "Threshold5": 43, + "Threshold6": 44, + "Threshold7": 45, + "Threshold8": 46, + "Threshold9": 47 + }, + "SpellVisual": { + "ID": 0, + "CastKit": 2, + "ImpactKit": 3, + "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, + "BaseEffect": 5, + "SpecialEffect0": 11, + "SpecialEffect1": 12, + "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, + "FilePath": 2 } } diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index bbcedec5..fa443aa1 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, @@ -16,12 +17,18 @@ "UNIT_NPC_FLAGS": 168, "UNIT_DYNAMIC_FLAGS": 164, "UNIT_FIELD_RESISTANCES": 185, + "UNIT_FIELD_STAT0": 159, + "UNIT_FIELD_STAT1": 160, + "UNIT_FIELD_STAT2": 161, + "UNIT_FIELD_STAT3": 162, + "UNIT_FIELD_STAT4": 163, "UNIT_END": 234, "PLAYER_FLAGS": 236, "PLAYER_BYTES": 237, "PLAYER_BYTES_2": 238, "PLAYER_XP": 926, "PLAYER_NEXT_LEVEL_XP": 927, + "PLAYER_REST_STATE_EXPERIENCE": 1440, "PLAYER_FIELD_COINAGE": 1441, "PLAYER_QUEST_LOG_START": 244, "PLAYER_FIELD_INV_SLOT_HEAD": 650, @@ -30,8 +37,12 @@ "PLAYER_FIELD_BANKBAG_SLOT_1": 784, "PLAYER_SKILL_INFO_START": 928, "PLAYER_EXPLORED_ZONES_START": 1312, + "PLAYER_FIELD_HONOR_CURRENCY": 1505, + "PLAYER_FIELD_ARENA_CURRENCY": 1506, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 60, + "ITEM_FIELD_MAXDURABILITY": 61, "CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_SLOT_1": 66 } diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index 4e86338a..42839fc6 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,96 +1,293 @@ { "Spell": { - "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 + "ID": 0, + "Attributes": 5, + "AttributesEx": 6, + "IconID": 117, + "Name": 120, + "Tooltip": 147, + "Rank": 129, + "SchoolEnum": 1, + "CastingTimeIndex": 15, + "PowerType": 28, + "ManaCost": 29, + "RangeIndex": 33, + "DispelType": 4 + }, + "SpellRange": { + "MaxRange": 2 }, "ItemDisplayInfo": { - "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, - "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, - "TextureArmUpper": 14, "TextureArmLower": 15, "TextureHand": 16, - "TextureTorsoUpper": 17, "TextureTorsoLower": 18, - "TextureLegUpper": 19, "TextureLegLower": 20, "TextureFoot": 21 + "ID": 0, + "LeftModel": 1, + "LeftModelTexture": 3, + "InventoryIcon": 5, + "GeosetGroup1": 7, + "GeosetGroup3": 9, + "TextureArmUpper": 14, + "TextureArmLower": 15, + "TextureHand": 16, + "TextureTorsoUpper": 17, + "TextureTorsoLower": 18, + "TextureLegUpper": 19, + "TextureLegLower": 20, + "TextureFoot": 21 }, "CharSections": { - "RaceID": 1, "SexID": 2, "BaseSection": 3, - "VariationIndex": 4, "ColorIndex": 5, - "Texture1": 6, "Texture2": 7, "Texture3": 8, + "RaceID": 1, + "SexID": 2, + "BaseSection": 3, + "VariationIndex": 4, + "ColorIndex": 5, + "Texture1": 6, + "Texture2": 7, + "Texture3": 8, "Flags": 9 }, - "SpellIcon": { "ID": 0, "Path": 1 }, + "SpellIcon": { + "ID": 0, + "Path": 1 + }, "FactionTemplate": { - "ID": 0, "Faction": 1, "FactionGroup": 3, - "FriendGroup": 4, "EnemyGroup": 5, - "Enemy0": 6, "Enemy1": 7, "Enemy2": 8, "Enemy3": 9 + "ID": 0, + "Faction": 1, + "FactionGroup": 3, + "FriendGroup": 4, + "EnemyGroup": 5, + "Enemy0": 6, + "Enemy1": 7, + "Enemy2": 8, + "Enemy3": 9 }, "Faction": { - "ID": 0, "ReputationRaceMask0": 2, "ReputationRaceMask1": 3, - "ReputationRaceMask2": 4, "ReputationRaceMask3": 5, - "ReputationBase0": 10, "ReputationBase1": 11, - "ReputationBase2": 12, "ReputationBase3": 13 + "ID": 0, + "ReputationRaceMask0": 2, + "ReputationRaceMask1": 3, + "ReputationRaceMask2": 4, + "ReputationRaceMask3": 5, + "ReputationBase0": 10, + "ReputationBase1": 11, + "ReputationBase2": 12, + "ReputationBase3": 13 + }, + "AreaTable": { + "ID": 0, + "MapID": 1, + "ParentAreaNum": 2, + "ExploreFlag": 3 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { - "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, - "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, - "EquipDisplay0": 8, "EquipDisplay1": 9, "EquipDisplay2": 10, - "EquipDisplay3": 11, "EquipDisplay4": 12, "EquipDisplay5": 13, - "EquipDisplay6": 14, "EquipDisplay7": 15, "EquipDisplay8": 16, - "EquipDisplay9": 17, "BakeName": 18 + "ID": 0, + "RaceID": 1, + "SexID": 2, + "SkinID": 3, + "FaceID": 4, + "HairStyleID": 5, + "HairColorID": 6, + "FacialHairID": 7, + "EquipDisplay0": 8, + "EquipDisplay1": 9, + "EquipDisplay2": 10, + "EquipDisplay3": 11, + "EquipDisplay4": 12, + "EquipDisplay5": 13, + "EquipDisplay6": 14, + "EquipDisplay7": 15, + "EquipDisplay8": 16, + "EquipDisplay9": 17, + "BakeName": 18 }, "CreatureDisplayInfo": { - "ID": 0, "ModelID": 1, "ExtraDisplayId": 3, - "Skin1": 6, "Skin2": 7, "Skin3": 8 + "ID": 0, + "ModelID": 1, + "ExtraDisplayId": 3, + "Skin1": 6, + "Skin2": 7, + "Skin3": 8 }, "TaxiNodes": { - "ID": 0, "MapID": 1, "X": 2, "Y": 3, "Z": 4, "Name": 5 + "ID": 0, + "MapID": 1, + "X": 2, + "Y": 3, + "Z": 4, + "Name": 5 + }, + "TaxiPath": { + "ID": 0, + "FromNode": 1, + "ToNode": 2, + "Cost": 3 }, - "TaxiPath": { "ID": 0, "FromNode": 1, "ToNode": 2, "Cost": 3 }, "TaxiPathNode": { - "ID": 0, "PathID": 1, "NodeIndex": 2, "MapID": 3, - "X": 4, "Y": 5, "Z": 6 + "ID": 0, + "PathID": 1, + "NodeIndex": 2, + "MapID": 3, + "X": 4, + "Y": 5, + "Z": 6 }, "TalentTab": { - "ID": 0, "Name": 1, "ClassMask": 12, - "OrderIndex": 14, "BackgroundFile": 15 + "ID": 0, + "Name": 1, + "ClassMask": 12, + "OrderIndex": 14, + "BackgroundFile": 15 }, "Talent": { - "ID": 0, "TabID": 1, "Row": 2, "Column": 3, - "RankSpell0": 4, "PrereqTalent0": 9, "PrereqRank0": 12 + "ID": 0, + "TabID": 1, + "Row": 2, + "Column": 3, + "RankSpell0": 4, + "PrereqTalent0": 9, + "PrereqRank0": 12 + }, + "SkillLineAbility": { + "SkillLineID": 1, + "SpellID": 2 + }, + "SkillLine": { + "ID": 0, + "Category": 1, + "Name": 3 + }, + "Map": { + "ID": 0, + "InternalName": 1 + }, + "CreatureModelData": { + "ID": 0, + "ModelPath": 2 }, - "SkillLineAbility": { "SkillLineID": 1, "SpellID": 2 }, - "SkillLine": { "ID": 0, "Category": 1, "Name": 3 }, - "Map": { "ID": 0, "InternalName": 1 }, - "CreatureModelData": { "ID": 0, "ModelPath": 2 }, "CharHairGeosets": { - "RaceID": 1, "SexID": 2, "Variation": 3, "GeosetID": 4 + "RaceID": 1, + "SexID": 2, + "Variation": 3, + "GeosetID": 4 }, "CharacterFacialHairStyles": { - "RaceID": 0, "SexID": 1, "Variation": 2, - "Geoset100": 3, "Geoset300": 4, "Geoset200": 5 + "RaceID": 0, + "SexID": 1, + "Variation": 2, + "Geoset100": 3, + "Geoset300": 4, + "Geoset200": 5 + }, + "GameObjectDisplayInfo": { + "ID": 0, + "ModelName": 1 + }, + "Emotes": { + "ID": 0, + "AnimID": 2 }, - "GameObjectDisplayInfo": { "ID": 0, "ModelName": 1 }, - "Emotes": { "ID": 0, "AnimID": 2 }, "EmotesText": { - "ID": 0, "Command": 1, "EmoteRef": 2, - "OthersTargetTextID": 3, "SenderTargetTextID": 5, - "OthersNoTargetTextID": 7, "SenderNoTargetTextID": 9 + "ID": 0, + "Command": 1, + "EmoteRef": 2, + "OthersTargetTextID": 3, + "SenderTargetTextID": 5, + "OthersNoTargetTextID": 7, + "SenderNoTargetTextID": 9 + }, + "EmotesTextData": { + "ID": 0, + "Text": 1 }, - "EmotesTextData": { "ID": 0, "Text": 1 }, "Light": { - "ID": 0, "MapID": 1, "X": 2, "Z": 3, "Y": 4, - "InnerRadius": 5, "OuterRadius": 6, "LightParamsID": 7, - "LightParamsIDRain": 8, "LightParamsIDUnderwater": 9 + "ID": 0, + "MapID": 1, + "X": 2, + "Z": 3, + "Y": 4, + "InnerRadius": 5, + "OuterRadius": 6, + "LightParamsID": 7, + "LightParamsIDRain": 8, + "LightParamsIDUnderwater": 9 + }, + "LightParams": { + "LightParamsID": 0 }, - "LightParams": { "LightParamsID": 0 }, "LightIntBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "LightFloatBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "WorldMapArea": { - "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, - "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, - "DisplayMapID": 8, "ParentWorldMapID": 10 + "ID": 0, + "MapID": 1, + "AreaID": 2, + "AreaName": 3, + "LocLeft": 4, + "LocRight": 5, + "LocTop": 6, + "LocBottom": 7, + "DisplayMapID": 8, + "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, + "Name": 8 + }, + "ItemSet": { + "ID": 0, + "Name": 1, + "Item0": 10, + "Item1": 11, + "Item2": 12, + "Item3": 13, + "Item4": 14, + "Item5": 15, + "Item6": 16, + "Item7": 17, + "Item8": 18, + "Item9": 19, + "Spell0": 20, + "Spell1": 21, + "Spell2": 22, + "Spell3": 23, + "Spell4": 24, + "Spell5": 25, + "Spell6": 26, + "Spell7": 27, + "Spell8": 28, + "Spell9": 29, + "Threshold0": 30, + "Threshold1": 31, + "Threshold2": 32, + "Threshold3": 33, + "Threshold4": 34, + "Threshold5": 35, + "Threshold6": 36, + "Threshold7": 37, + "Threshold8": 38, + "Threshold9": 39 + }, + "SpellVisual": { + "ID": 0, + "CastKit": 2, + "ImpactKit": 3, + "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, + "BaseEffect": 5, + "SpecialEffect0": 11, + "SpecialEffect1": 12, + "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, + "FilePath": 2 } } diff --git a/Data/expansions/turtle/opcodes.json b/Data/expansions/turtle/opcodes.json index 95a22888..d0f84599 100644 --- a/Data/expansions/turtle/opcodes.json +++ b/Data/expansions/turtle/opcodes.json @@ -1,300 +1,6 @@ { - "CMSG_PING": "0x1DC", - "CMSG_AUTH_SESSION": "0x1ED", - "CMSG_CHAR_CREATE": "0x036", - "CMSG_CHAR_ENUM": "0x037", - "CMSG_CHAR_DELETE": "0x038", - "CMSG_PLAYER_LOGIN": "0x03D", - "MSG_MOVE_START_FORWARD": "0x0B5", - "MSG_MOVE_START_BACKWARD": "0x0B6", - "MSG_MOVE_STOP": "0x0B7", - "MSG_MOVE_START_STRAFE_LEFT": "0x0B8", - "MSG_MOVE_START_STRAFE_RIGHT": "0x0B9", - "MSG_MOVE_STOP_STRAFE": "0x0BA", - "MSG_MOVE_JUMP": "0x0BB", - "MSG_MOVE_START_TURN_LEFT": "0x0BC", - "MSG_MOVE_START_TURN_RIGHT": "0x0BD", - "MSG_MOVE_STOP_TURN": "0x0BE", - "MSG_MOVE_SET_FACING": "0x0DA", - "MSG_MOVE_FALL_LAND": "0x0C9", - "MSG_MOVE_START_SWIM": "0x0CA", - "MSG_MOVE_STOP_SWIM": "0x0CB", - "MSG_MOVE_HEARTBEAT": "0x0EE", - "SMSG_AUTH_CHALLENGE": "0x1EC", - "SMSG_AUTH_RESPONSE": "0x1EE", - "SMSG_CHAR_CREATE": "0x03A", - "SMSG_CHAR_ENUM": "0x03B", - "SMSG_CHAR_DELETE": "0x03C", - "SMSG_CHARACTER_LOGIN_FAILED": "0x041", - "SMSG_PONG": "0x1DD", - "SMSG_LOGIN_VERIFY_WORLD": "0x236", - "SMSG_INIT_WORLD_STATES": "0x2C2", - "SMSG_LOGIN_SETTIMESPEED": "0x042", - "SMSG_TUTORIAL_FLAGS": "0x0FD", - "SMSG_INITIALIZE_FACTIONS": "0x122", - "SMSG_WARDEN_DATA": "0x2E6", - "CMSG_WARDEN_DATA": "0x2E7", - "SMSG_NOTIFICATION": "0x1CB", - "SMSG_ACCOUNT_DATA_TIMES": "0x209", - "SMSG_UPDATE_OBJECT": "0x0A9", - "SMSG_COMPRESSED_UPDATE_OBJECT": "0x1F6", - "SMSG_PARTYKILLLOG": "0x1F5", - "SMSG_MONSTER_MOVE_TRANSPORT": "0x2AE", - "SMSG_SPLINE_MOVE_SET_WALK_MODE": "0x30E", - "SMSG_SPLINE_MOVE_SET_RUN_MODE": "0x30D", - "SMSG_SPLINE_SET_RUN_SPEED": "0x2FE", - "SMSG_SPLINE_SET_RUN_BACK_SPEED": "0x2FF", - "SMSG_SPLINE_SET_SWIM_SPEED": "0x300", - "SMSG_DESTROY_OBJECT": "0x0AA", - "CMSG_MESSAGECHAT": "0x095", - "SMSG_MESSAGECHAT": "0x096", - "CMSG_WHO": "0x062", - "SMSG_WHO": "0x063", - "CMSG_PLAYED_TIME": "0x1CC", - "SMSG_PLAYED_TIME": "0x1CD", - "CMSG_QUERY_TIME": "0x1CE", - "SMSG_QUERY_TIME_RESPONSE": "0x1CF", - "SMSG_FRIEND_STATUS": "0x068", - "SMSG_CONTACT_LIST": "0x067", - "CMSG_ADD_FRIEND": "0x069", - "CMSG_DEL_FRIEND": "0x06A", - "CMSG_ADD_IGNORE": "0x06C", - "CMSG_DEL_IGNORE": "0x06D", - "CMSG_PLAYER_LOGOUT": "0x04A", - "CMSG_LOGOUT_REQUEST": "0x04B", - "CMSG_LOGOUT_CANCEL": "0x04E", - "SMSG_LOGOUT_RESPONSE": "0x04C", - "SMSG_LOGOUT_COMPLETE": "0x04D", - "CMSG_STANDSTATECHANGE": "0x101", - "CMSG_SHOWING_HELM": "0x2B9", - "CMSG_SHOWING_CLOAK": "0x2BA", - "CMSG_TOGGLE_PVP": "0x253", - "CMSG_GUILD_INVITE": "0x082", - "CMSG_GUILD_ACCEPT": "0x084", - "CMSG_GUILD_DECLINE": "0x085", - "CMSG_GUILD_INFO": "0x087", - "CMSG_GUILD_ROSTER": "0x089", - "CMSG_GUILD_PROMOTE": "0x08B", - "CMSG_GUILD_DEMOTE": "0x08C", - "CMSG_GUILD_LEAVE": "0x08D", - "CMSG_GUILD_MOTD": "0x091", - "SMSG_GUILD_INFO": "0x088", - "SMSG_GUILD_ROSTER": "0x08A", - "CMSG_GUILD_QUERY": "0x054", - "SMSG_GUILD_QUERY_RESPONSE": "0x055", - "SMSG_GUILD_INVITE": "0x083", - "CMSG_GUILD_REMOVE": "0x08E", - "SMSG_GUILD_EVENT": "0x092", - "SMSG_GUILD_COMMAND_RESULT": "0x093", - "MSG_RAID_READY_CHECK": "0x322", - "SMSG_ITEM_PUSH_RESULT": "0x166", - "CMSG_DUEL_ACCEPTED": "0x16C", - "CMSG_DUEL_CANCELLED": "0x16D", - "SMSG_DUEL_REQUESTED": "0x167", - "CMSG_INITIATE_TRADE": "0x116", - "MSG_RANDOM_ROLL": "0x1FB", - "CMSG_SET_SELECTION": "0x13D", - "CMSG_NAME_QUERY": "0x050", - "SMSG_NAME_QUERY_RESPONSE": "0x051", - "CMSG_CREATURE_QUERY": "0x060", - "SMSG_CREATURE_QUERY_RESPONSE": "0x061", - "CMSG_GAMEOBJECT_QUERY": "0x05E", - "SMSG_GAMEOBJECT_QUERY_RESPONSE": "0x05F", - "CMSG_SET_ACTIVE_MOVER": "0x26A", - "CMSG_BINDER_ACTIVATE": "0x1B5", - "SMSG_LOG_XPGAIN": "0x1D0", - "_NOTE_MONSTER_MOVE": "These look swapped vs vanilla (0x0DD/0x2FB) but may be intentional Turtle WoW changes. Check if NPC movement breaks.", - "SMSG_MONSTER_MOVE": "0x2FB", - "SMSG_COMPRESSED_MOVES": "0x06B", - "CMSG_ATTACKSWING": "0x141", - "CMSG_ATTACKSTOP": "0x142", - "SMSG_ATTACKSTART": "0x143", - "SMSG_ATTACKSTOP": "0x144", - "SMSG_ATTACKERSTATEUPDATE": "0x14A", - "SMSG_AI_REACTION": "0x13C", - "SMSG_SPELLNONMELEEDAMAGELOG": "0x250", - "SMSG_PLAY_SPELL_VISUAL": "0x1F3", - "SMSG_SPELLHEALLOG": "0x150", - "SMSG_SPELLENERGIZELOG": "0x151", - "SMSG_PERIODICAURALOG": "0x24E", - "SMSG_ENVIRONMENTAL_DAMAGE_LOG": "0x1FC", - "CMSG_CAST_SPELL": "0x12E", - "CMSG_CANCEL_CAST": "0x12F", - "CMSG_CANCEL_AURA": "0x136", - "SMSG_CAST_FAILED": "0x130", - "SMSG_SPELL_START": "0x131", - "SMSG_SPELL_GO": "0x132", - "SMSG_SPELL_FAILURE": "0x133", - "SMSG_SPELL_COOLDOWN": "0x134", - "SMSG_COOLDOWN_EVENT": "0x135", - "SMSG_EQUIPMENT_SET_SAVED": "0x137", - "SMSG_INITIAL_SPELLS": "0x12A", - "SMSG_LEARNED_SPELL": "0x12B", - "SMSG_SUPERCEDED_SPELL": "0x12C", - "SMSG_REMOVED_SPELL": "0x203", - "SMSG_SPELL_DELAYED": "0x1E2", - "SMSG_SET_FLAT_SPELL_MODIFIER": "0x266", - "SMSG_SET_PCT_SPELL_MODIFIER": "0x267", - "CMSG_LEARN_TALENT": "0x251", - "MSG_TALENT_WIPE_CONFIRM": "0x2AA", - "CMSG_GROUP_INVITE": "0x06E", - "SMSG_GROUP_INVITE": "0x06F", - "CMSG_GROUP_ACCEPT": "0x072", - "CMSG_GROUP_DECLINE": "0x073", - "SMSG_GROUP_DECLINE": "0x074", - "CMSG_GROUP_UNINVITE_GUID": "0x076", - "SMSG_GROUP_UNINVITE": "0x077", - "CMSG_GROUP_SET_LEADER": "0x078", - "SMSG_GROUP_SET_LEADER": "0x079", - "CMSG_GROUP_DISBAND": "0x07B", - "SMSG_GROUP_LIST": "0x07D", - "SMSG_PARTY_COMMAND_RESULT": "0x07F", - "MSG_RAID_TARGET_UPDATE": "0x321", - "CMSG_REQUEST_RAID_INFO": "0x2CD", - "SMSG_RAID_INSTANCE_INFO": "0x2CC", - "CMSG_AUTOSTORE_LOOT_ITEM": "0x108", - "CMSG_LOOT": "0x15D", - "CMSG_LOOT_MONEY": "0x15E", - "CMSG_LOOT_RELEASE": "0x15F", - "SMSG_LOOT_RESPONSE": "0x160", - "SMSG_LOOT_RELEASE_RESPONSE": "0x161", - "SMSG_LOOT_REMOVED": "0x162", - "SMSG_LOOT_MONEY_NOTIFY": "0x163", - "SMSG_LOOT_CLEAR_MONEY": "0x165", - "CMSG_ACTIVATETAXI": "0x1AD", - "CMSG_GOSSIP_HELLO": "0x17B", - "CMSG_GOSSIP_SELECT_OPTION": "0x17C", - "SMSG_GOSSIP_MESSAGE": "0x17D", - "SMSG_GOSSIP_COMPLETE": "0x17E", - "SMSG_NPC_TEXT_UPDATE": "0x180", - "CMSG_GAMEOBJ_USE": "0x0B1", - "CMSG_QUESTGIVER_STATUS_QUERY": "0x182", - "SMSG_QUESTGIVER_STATUS": "0x183", - "CMSG_QUESTGIVER_HELLO": "0x184", - "SMSG_QUESTGIVER_QUEST_LIST": "0x185", - "CMSG_QUESTGIVER_QUERY_QUEST": "0x186", - "SMSG_QUESTGIVER_QUEST_DETAILS": "0x188", - "CMSG_QUESTGIVER_ACCEPT_QUEST": "0x189", - "CMSG_QUESTGIVER_COMPLETE_QUEST": "0x18A", - "SMSG_QUESTGIVER_REQUEST_ITEMS": "0x18B", - "CMSG_QUESTGIVER_REQUEST_REWARD": "0x18C", - "SMSG_QUESTGIVER_OFFER_REWARD": "0x18D", - "CMSG_QUESTGIVER_CHOOSE_REWARD": "0x18E", - "SMSG_QUESTGIVER_QUEST_INVALID": "0x18F", - "SMSG_QUESTGIVER_QUEST_COMPLETE": "0x191", - "CMSG_QUESTLOG_REMOVE_QUEST": "0x194", - "SMSG_QUESTUPDATE_ADD_KILL": "0x199", - "SMSG_QUESTUPDATE_COMPLETE": "0x198", - "SMSG_QUEST_FORCE_REMOVE": "0x21E", - "CMSG_QUEST_QUERY": "0x05C", - "SMSG_QUEST_QUERY_RESPONSE": "0x05D", - "SMSG_QUESTLOG_FULL": "0x195", - "CMSG_LIST_INVENTORY": "0x19E", - "SMSG_LIST_INVENTORY": "0x19F", - "CMSG_SELL_ITEM": "0x1A0", - "SMSG_SELL_ITEM": "0x1A1", - "CMSG_BUY_ITEM": "0x1A2", - "CMSG_BUYBACK_ITEM": "0x1A6", - "SMSG_BUY_FAILED": "0x1A5", - "CMSG_TRAINER_LIST": "0x1B0", - "SMSG_TRAINER_LIST": "0x1B1", - "CMSG_TRAINER_BUY_SPELL": "0x1B2", - "SMSG_TRAINER_BUY_FAILED": "0x1B4", - "CMSG_ITEM_QUERY_SINGLE": "0x056", - "SMSG_ITEM_QUERY_SINGLE_RESPONSE": "0x058", - "CMSG_USE_ITEM": "0x0AB", - "CMSG_AUTOEQUIP_ITEM": "0x10A", - "CMSG_SWAP_ITEM": "0x10C", - "CMSG_SWAP_INV_ITEM": "0x10D", - "SMSG_INVENTORY_CHANGE_FAILURE": "0x112", - "CMSG_INSPECT": "0x114", - "SMSG_INSPECT_RESULTS_UPDATE": "0x115", - "CMSG_REPOP_REQUEST": "0x15A", - "SMSG_RESURRECT_REQUEST": "0x15B", - "CMSG_RESURRECT_RESPONSE": "0x15C", - "CMSG_SPIRIT_HEALER_ACTIVATE": "0x21C", - "SMSG_SPIRIT_HEALER_CONFIRM": "0x222", - "MSG_MOVE_TELEPORT_ACK": "0x0C7", - "SMSG_TRANSFER_PENDING": "0x03F", - "SMSG_NEW_WORLD": "0x03E", - "MSG_MOVE_WORLDPORT_ACK": "0x0DC", - "SMSG_TRANSFER_ABORTED": "0x040", - "SMSG_FORCE_RUN_SPEED_CHANGE": "0x0E2", - "SMSG_CLIENT_CONTROL_UPDATE": "0x159", - "CMSG_FORCE_RUN_SPEED_CHANGE_ACK": "0x0E3", - "SMSG_SHOWTAXINODES": "0x1A9", - "SMSG_ACTIVATETAXIREPLY": "0x1AE", - "SMSG_NEW_TAXI_PATH": "0x1AF", - "CMSG_ACTIVATETAXIEXPRESS": "0x312", - "CMSG_TAXINODE_STATUS_QUERY": "0x1AA", - "SMSG_TAXINODE_STATUS": "0x1AB", - "SMSG_TRAINER_BUY_SUCCEEDED": "0x1B3", - "SMSG_BINDPOINTUPDATE": "0x155", - "SMSG_SET_PROFICIENCY": "0x127", - "SMSG_ACTION_BUTTONS": "0x129", - "SMSG_LEVELUP_INFO": "0x1D4", - "SMSG_PLAY_SOUND": "0x2D2", - "CMSG_UPDATE_ACCOUNT_DATA": "0x20B", - "CMSG_BATTLEFIELD_LIST": "0x23C", - "SMSG_BATTLEFIELD_LIST": "0x23D", - "CMSG_BATTLEFIELD_JOIN": "0x23E", - "CMSG_BATTLEFIELD_STATUS": "0x2D3", - "SMSG_BATTLEFIELD_STATUS": "0x2D4", - "CMSG_BATTLEFIELD_PORT": "0x2D5", - "CMSG_BATTLEMASTER_HELLO": "0x2D7", - "MSG_PVP_LOG_DATA": "0x2E0", - "CMSG_LEAVE_BATTLEFIELD": "0x2E1", - "SMSG_GROUP_JOINED_BATTLEGROUND": "0x2E8", - "MSG_BATTLEGROUND_PLAYER_POSITIONS": "0x2E9", - "SMSG_BATTLEGROUND_PLAYER_JOINED": "0x2EC", - "SMSG_BATTLEGROUND_PLAYER_LEFT": "0x2ED", - "CMSG_BATTLEMASTER_JOIN": "0x2EE", - "CMSG_EMOTE": "0x102", - "SMSG_EMOTE": "0x103", - "CMSG_TEXT_EMOTE": "0x104", - "SMSG_TEXT_EMOTE": "0x105", - "CMSG_JOIN_CHANNEL": "0x097", - "CMSG_LEAVE_CHANNEL": "0x098", - "SMSG_CHANNEL_NOTIFY": "0x099", - "CMSG_CHANNEL_LIST": "0x09A", - "SMSG_CHANNEL_LIST": "0x09B", - "SMSG_INSPECT_TALENT": "0x3F4", - "SMSG_SHOW_MAILBOX": "0x297", - "CMSG_GET_MAIL_LIST": "0x23A", - "SMSG_MAIL_LIST_RESULT": "0x23B", - "CMSG_SEND_MAIL": "0x238", - "SMSG_SEND_MAIL_RESULT": "0x239", - "CMSG_MAIL_TAKE_MONEY": "0x245", - "CMSG_MAIL_TAKE_ITEM": "0x246", - "CMSG_MAIL_DELETE": "0x249", - "CMSG_MAIL_MARK_AS_READ": "0x247", - "SMSG_RECEIVED_MAIL": "0x285", - "MSG_QUERY_NEXT_MAIL_TIME": "0x284", - "CMSG_BANKER_ACTIVATE": "0x1B7", - "SMSG_SHOW_BANK": "0x1B8", - "CMSG_BUY_BANK_SLOT": "0x1B9", - "SMSG_BUY_BANK_SLOT_RESULT": "0x1BA", - "CMSG_AUTOSTORE_BANK_ITEM": "0x282", - "CMSG_AUTOBANK_ITEM": "0x283", - "MSG_AUCTION_HELLO": "0x255", - "CMSG_AUCTION_SELL_ITEM": "0x256", - "CMSG_AUCTION_REMOVE_ITEM": "0x257", - "CMSG_AUCTION_LIST_ITEMS": "0x258", - "CMSG_AUCTION_LIST_OWNER_ITEMS": "0x259", - "CMSG_AUCTION_PLACE_BID": "0x25A", - "SMSG_AUCTION_COMMAND_RESULT": "0x25B", - "SMSG_AUCTION_LIST_RESULT": "0x25C", - "SMSG_AUCTION_OWNER_LIST_RESULT": "0x25D", - "SMSG_AUCTION_OWNER_NOTIFICATION": "0x25E", - "SMSG_AUCTION_BIDDER_NOTIFICATION": "0x260", - "CMSG_AUCTION_LIST_BIDDER_ITEMS": "0x264", - "SMSG_AUCTION_BIDDER_LIST_RESULT": "0x265", - "MSG_MOVE_TIME_SKIPPED": "0x319", - "SMSG_CANCEL_AUTO_REPEAT": "0x29C", - "SMSG_WEATHER": "0x2F4", - "SMSG_QUESTUPDATE_ADD_ITEM": "0x19A", - "CMSG_GUILD_DISBAND": "0x08F", - "CMSG_GUILD_LEADER": "0x090", - "CMSG_GUILD_SET_PUBLIC_NOTE": "0x234", - "CMSG_GUILD_SET_OFFICER_NOTE": "0x235" + "_extends": "../classic/opcodes.json", + "_remove": [ + "MSG_SET_DUNGEON_DIFFICULTY" + ] } diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 5f97f29f..a27e84f7 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, @@ -13,15 +14,22 @@ "UNIT_FIELD_DISPLAYID": 131, "UNIT_FIELD_MOUNTDISPLAYID": 133, "UNIT_FIELD_AURAS": 50, + "UNIT_FIELD_AURAFLAGS": 98, "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, + "PLAYER_REST_STATE_EXPERIENCE": 1175, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, @@ -33,6 +41,8 @@ "PLAYER_END": 1282, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 48, + "ITEM_FIELD_MAXDURABILITY": 49, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 -} +} \ No newline at end of file diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 5b500741..5a05a517 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,99 +1,319 @@ { "Spell": { - "ID": 0, "Attributes": 4, "IconID": 133, - "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225 + "ID": 0, + "Attributes": 4, + "AttributesEx": 5, + "IconID": 133, + "Name": 136, + "Tooltip": 139, + "Rank": 153, + "SchoolMask": 225, + "PowerType": 14, + "ManaCost": 39, + "CastingTimeIndex": 47, + "RangeIndex": 49, + "DispelType": 2 + }, + "SpellRange": { + "MaxRange": 4 }, "ItemDisplayInfo": { - "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, - "InventoryIcon": 5, "GeosetGroup1": 7, "GeosetGroup3": 9, - "TextureArmUpper": 14, "TextureArmLower": 15, "TextureHand": 16, - "TextureTorsoUpper": 17, "TextureTorsoLower": 18, - "TextureLegUpper": 19, "TextureLegLower": 20, "TextureFoot": 21 + "ID": 0, + "LeftModel": 1, + "LeftModelTexture": 3, + "InventoryIcon": 5, + "GeosetGroup1": 7, + "GeosetGroup3": 9, + "TextureArmUpper": 14, + "TextureArmLower": 15, + "TextureHand": 16, + "TextureTorsoUpper": 17, + "TextureTorsoLower": 18, + "TextureLegUpper": 19, + "TextureLegLower": 20, + "TextureFoot": 21 }, "CharSections": { - "RaceID": 1, "SexID": 2, "BaseSection": 3, - "VariationIndex": 4, "ColorIndex": 5, - "Texture1": 6, "Texture2": 7, "Texture3": 8, + "RaceID": 1, + "SexID": 2, + "BaseSection": 3, + "VariationIndex": 4, + "ColorIndex": 5, + "Texture1": 6, + "Texture2": 7, + "Texture3": 8, "Flags": 9 }, - "SpellIcon": { "ID": 0, "Path": 1 }, + "SpellIcon": { + "ID": 0, + "Path": 1 + }, "FactionTemplate": { - "ID": 0, "Faction": 1, "FactionGroup": 3, - "FriendGroup": 4, "EnemyGroup": 5, - "Enemy0": 6, "Enemy1": 7, "Enemy2": 8, "Enemy3": 9 + "ID": 0, + "Faction": 1, + "FactionGroup": 3, + "FriendGroup": 4, + "EnemyGroup": 5, + "Enemy0": 6, + "Enemy1": 7, + "Enemy2": 8, + "Enemy3": 9 }, "Faction": { - "ID": 0, "ReputationRaceMask0": 2, "ReputationRaceMask1": 3, - "ReputationRaceMask2": 4, "ReputationRaceMask3": 5, - "ReputationBase0": 10, "ReputationBase1": 11, - "ReputationBase2": 12, "ReputationBase3": 13 + "ID": 0, + "ReputationRaceMask0": 2, + "ReputationRaceMask1": 3, + "ReputationRaceMask2": 4, + "ReputationRaceMask3": 5, + "ReputationBase0": 10, + "ReputationBase1": 11, + "ReputationBase2": 12, + "ReputationBase3": 13 + }, + "CharTitles": { + "ID": 0, + "Title": 2, + "TitleBit": 36 + }, + "Achievement": { + "ID": 0, + "Title": 4, + "Description": 21, + "Points": 39 + }, + "AchievementCriteria": { + "ID": 0, + "AchievementID": 1, + "Quantity": 4, + "Description": 9 + }, + "AreaTable": { + "ID": 0, + "MapID": 1, + "ParentAreaNum": 2, + "ExploreFlag": 3 }, - "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { - "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, - "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, - "EquipDisplay0": 8, "EquipDisplay1": 9, "EquipDisplay2": 10, - "EquipDisplay3": 11, "EquipDisplay4": 12, "EquipDisplay5": 13, - "EquipDisplay6": 14, "EquipDisplay7": 15, "EquipDisplay8": 16, - "EquipDisplay9": 17, "EquipDisplay10": 18, "BakeName": 20 + "ID": 0, + "RaceID": 1, + "SexID": 2, + "SkinID": 3, + "FaceID": 4, + "HairStyleID": 5, + "HairColorID": 6, + "FacialHairID": 7, + "EquipDisplay0": 8, + "EquipDisplay1": 9, + "EquipDisplay2": 10, + "EquipDisplay3": 11, + "EquipDisplay4": 12, + "EquipDisplay5": 13, + "EquipDisplay6": 14, + "EquipDisplay7": 15, + "EquipDisplay8": 16, + "EquipDisplay9": 17, + "EquipDisplay10": 18, + "BakeName": 20 }, "CreatureDisplayInfo": { - "ID": 0, "ModelID": 1, "ExtraDisplayId": 3, - "Skin1": 6, "Skin2": 7, "Skin3": 8 + "ID": 0, + "ModelID": 1, + "ExtraDisplayId": 3, + "Skin1": 6, + "Skin2": 7, + "Skin3": 8 }, "TaxiNodes": { - "ID": 0, "MapID": 1, "X": 2, "Y": 3, "Z": 4, "Name": 5, - "MountDisplayIdAllianceFallback": 20, "MountDisplayIdHordeFallback": 21, - "MountDisplayIdAlliance": 22, "MountDisplayIdHorde": 23 + "ID": 0, + "MapID": 1, + "X": 2, + "Y": 3, + "Z": 4, + "Name": 5, + "MountDisplayIdAllianceFallback": 20, + "MountDisplayIdHordeFallback": 21, + "MountDisplayIdAlliance": 22, + "MountDisplayIdHorde": 23 + }, + "TaxiPath": { + "ID": 0, + "FromNode": 1, + "ToNode": 2, + "Cost": 3 }, - "TaxiPath": { "ID": 0, "FromNode": 1, "ToNode": 2, "Cost": 3 }, "TaxiPathNode": { - "ID": 0, "PathID": 1, "NodeIndex": 2, "MapID": 3, - "X": 4, "Y": 5, "Z": 6 + "ID": 0, + "PathID": 1, + "NodeIndex": 2, + "MapID": 3, + "X": 4, + "Y": 5, + "Z": 6 }, "TalentTab": { - "ID": 0, "Name": 1, "ClassMask": 20, - "OrderIndex": 22, "BackgroundFile": 23 + "ID": 0, + "Name": 1, + "ClassMask": 20, + "OrderIndex": 22, + "BackgroundFile": 23 }, "Talent": { - "ID": 0, "TabID": 1, "Row": 2, "Column": 3, - "RankSpell0": 4, "PrereqTalent0": 9, "PrereqRank0": 12 + "ID": 0, + "TabID": 1, + "Row": 2, + "Column": 3, + "RankSpell0": 4, + "PrereqTalent0": 9, + "PrereqRank0": 12 + }, + "SkillLineAbility": { + "SkillLineID": 1, + "SpellID": 2 + }, + "SkillLine": { + "ID": 0, + "Category": 1, + "Name": 3 + }, + "Map": { + "ID": 0, + "InternalName": 1 + }, + "CreatureModelData": { + "ID": 0, + "ModelPath": 2 }, - "SkillLineAbility": { "SkillLineID": 1, "SpellID": 2 }, - "SkillLine": { "ID": 0, "Category": 1, "Name": 3 }, - "Map": { "ID": 0, "InternalName": 1 }, - "CreatureModelData": { "ID": 0, "ModelPath": 2 }, "CharHairGeosets": { - "RaceID": 1, "SexID": 2, "Variation": 3, "GeosetID": 4 + "RaceID": 1, + "SexID": 2, + "Variation": 3, + "GeosetID": 4 }, "CharacterFacialHairStyles": { - "RaceID": 0, "SexID": 1, "Variation": 2, - "Geoset100": 3, "Geoset300": 4, "Geoset200": 5 + "RaceID": 0, + "SexID": 1, + "Variation": 2, + "Geoset100": 3, + "Geoset300": 4, + "Geoset200": 5 + }, + "GameObjectDisplayInfo": { + "ID": 0, + "ModelName": 1 + }, + "Emotes": { + "ID": 0, + "AnimID": 2 }, - "GameObjectDisplayInfo": { "ID": 0, "ModelName": 1 }, - "Emotes": { "ID": 0, "AnimID": 2 }, "EmotesText": { - "ID": 0, "Command": 1, "EmoteRef": 2, - "OthersTargetTextID": 3, "SenderTargetTextID": 5, - "OthersNoTargetTextID": 7, "SenderNoTargetTextID": 9 + "ID": 0, + "Command": 1, + "EmoteRef": 2, + "OthersTargetTextID": 3, + "SenderTargetTextID": 5, + "OthersNoTargetTextID": 7, + "SenderNoTargetTextID": 9 + }, + "EmotesTextData": { + "ID": 0, + "Text": 1 }, - "EmotesTextData": { "ID": 0, "Text": 1 }, "Light": { - "ID": 0, "MapID": 1, "X": 2, "Z": 3, "Y": 4, - "InnerRadius": 5, "OuterRadius": 6, "LightParamsID": 7, - "LightParamsIDRain": 8, "LightParamsIDUnderwater": 9 + "ID": 0, + "MapID": 1, + "X": 2, + "Z": 3, + "Y": 4, + "InnerRadius": 5, + "OuterRadius": 6, + "LightParamsID": 7, + "LightParamsIDRain": 8, + "LightParamsIDUnderwater": 9 + }, + "LightParams": { + "LightParamsID": 0 }, - "LightParams": { "LightParamsID": 0 }, "LightIntBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "LightFloatBand": { - "BlockIndex": 1, "NumKeyframes": 2, "TimeKey0": 3, "Value0": 19 + "BlockIndex": 1, + "NumKeyframes": 2, + "TimeKey0": 3, + "Value0": 19 }, "WorldMapArea": { - "ID": 0, "MapID": 1, "AreaID": 2, "AreaName": 3, - "LocLeft": 4, "LocRight": 5, "LocTop": 6, "LocBottom": 7, - "DisplayMapID": 8, "ParentWorldMapID": 10 + "ID": 0, + "MapID": 1, + "AreaID": 2, + "AreaName": 3, + "LocLeft": 4, + "LocRight": 5, + "LocTop": 6, + "LocBottom": 7, + "DisplayMapID": 8, + "ParentWorldMapID": 10 + }, + "SpellItemEnchantment": { + "ID": 0, + "Name": 8 + }, + "ItemSet": { + "ID": 0, + "Name": 1, + "Item0": 18, + "Item1": 19, + "Item2": 20, + "Item3": 21, + "Item4": 22, + "Item5": 23, + "Item6": 24, + "Item7": 25, + "Item8": 26, + "Item9": 27, + "Spell0": 28, + "Spell1": 29, + "Spell2": 30, + "Spell3": 31, + "Spell4": 32, + "Spell5": 33, + "Spell6": 34, + "Spell7": 35, + "Spell8": 36, + "Spell9": 37, + "Threshold0": 38, + "Threshold1": 39, + "Threshold2": 40, + "Threshold3": 41, + "Threshold4": 42, + "Threshold5": 43, + "Threshold6": 44, + "Threshold7": 45, + "Threshold8": 46, + "Threshold9": 47 + }, + "LFGDungeons": { + "ID": 0, + "Name": 1 + }, + "SpellVisual": { + "ID": 0, + "CastKit": 2, + "ImpactKit": 3, + "MissileModel": 8 + }, + "SpellVisualKit": { + "ID": 0, + "BaseEffect": 5, + "SpecialEffect0": 11, + "SpecialEffect1": 12, + "SpecialEffect2": 13 + }, + "SpellVisualEffectName": { + "ID": 0, + "FilePath": 2 } } diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index f308cf0d..7b5e12e8 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 6, "UNIT_FIELD_TARGET_HI": 7, "UNIT_FIELD_BYTES_0": 23, @@ -16,12 +17,20 @@ "UNIT_NPC_FLAGS": 82, "UNIT_DYNAMIC_FLAGS": 147, "UNIT_FIELD_RESISTANCES": 99, + "UNIT_FIELD_STAT0": 84, + "UNIT_FIELD_STAT1": 85, + "UNIT_FIELD_STAT2": 86, + "UNIT_FIELD_STAT3": 87, + "UNIT_FIELD_STAT4": 88, "UNIT_END": 148, + "UNIT_FIELD_ATTACK_POWER": 123, + "UNIT_FIELD_RANGED_ATTACK_POWER": 126, "PLAYER_FLAGS": 150, "PLAYER_BYTES": 153, "PLAYER_BYTES_2": 154, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, + "PLAYER_REST_STATE_EXPERIENCE": 1169, "PLAYER_FIELD_COINAGE": 1170, "PLAYER_QUEST_LOG_START": 158, "PLAYER_FIELD_INV_SLOT_HEAD": 324, @@ -30,8 +39,22 @@ "PLAYER_FIELD_BANKBAG_SLOT_1": 458, "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, + "PLAYER_CHOSEN_TITLE": 1349, + "PLAYER_FIELD_MOD_DAMAGE_DONE_POS": 1171, + "PLAYER_FIELD_MOD_HEALING_DONE_POS": 1192, + "PLAYER_BLOCK_PERCENTAGE": 1024, + "PLAYER_DODGE_PERCENTAGE": 1025, + "PLAYER_PARRY_PERCENTAGE": 1026, + "PLAYER_CRIT_PERCENTAGE": 1029, + "PLAYER_RANGED_CRIT_PERCENTAGE": 1030, + "PLAYER_SPELL_CRIT_PERCENTAGE1": 1032, + "PLAYER_FIELD_COMBAT_RATING_1": 1231, + "PLAYER_FIELD_HONOR_CURRENCY": 1422, + "PLAYER_FIELD_ARENA_CURRENCY": 1423, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, + "ITEM_FIELD_DURABILITY": 60, + "ITEM_FIELD_MAXDURABILITY": 61, "CONTAINER_FIELD_NUM_SLOTS": 64, "CONTAINER_FIELD_SLOT_1": 66 } diff --git a/Data/interface/AddOns/HelloWorld/HelloWorld.lua b/Data/interface/AddOns/HelloWorld/HelloWorld.lua new file mode 100644 index 00000000..5ee38fd6 --- /dev/null +++ b/Data/interface/AddOns/HelloWorld/HelloWorld.lua @@ -0,0 +1,36 @@ +-- HelloWorld addon — demonstrates the WoWee addon system + +-- Initialize saved variables (persisted across sessions) +if not HelloWorldDB then + HelloWorldDB = { loginCount = 0 } +end +HelloWorldDB.loginCount = (HelloWorldDB.loginCount or 0) + 1 + +-- Create a frame and register for events (standard WoW addon pattern) +local f = CreateFrame("Frame", "HelloWorldFrame") +f:RegisterEvent("PLAYER_ENTERING_WORLD") +f:RegisterEvent("CHAT_MSG_SAY") + +f:SetScript("OnEvent", function(self, event, ...) + if event == "PLAYER_ENTERING_WORLD" then + local name = UnitName("player") + local level = UnitLevel("player") + print("|cff00ff00[HelloWorld]|r Welcome, " .. name .. "! (Level " .. level .. ")") + print("|cff00ff00[HelloWorld]|r Login count: " .. HelloWorldDB.loginCount) + elseif event == "CHAT_MSG_SAY" then + local msg, sender = ... + if msg and sender then + print("|cff00ff00[HelloWorld]|r " .. sender .. " said: " .. msg) + end + end +end) + +-- Register a custom slash command +SLASH_HELLOWORLD1 = "/hello" +SLASH_HELLOWORLD2 = "/hw" +SlashCmdList["HELLOWORLD"] = function(args) + print("|cff00ff00[HelloWorld]|r Hello! " .. (args ~= "" and args or "Type /hello ")) + print("|cff00ff00[HelloWorld]|r Sessions: " .. HelloWorldDB.loginCount) +end + +print("|cff00ff00[HelloWorld]|r Addon loaded. Type /hello to test.") diff --git a/Data/interface/AddOns/HelloWorld/HelloWorld.toc b/Data/interface/AddOns/HelloWorld/HelloWorld.toc new file mode 100644 index 00000000..f50ef105 --- /dev/null +++ b/Data/interface/AddOns/HelloWorld/HelloWorld.toc @@ -0,0 +1,5 @@ +## Interface: 30300 +## Title: Hello World +## Notes: Test addon for the WoWee addon system +## SavedVariables: HelloWorldDB +HelloWorld.lua diff --git a/Data/opcodes/aliases.json b/Data/opcodes/aliases.json index e3a67348..4677cd5d 100644 --- a/Data/opcodes/aliases.json +++ b/Data/opcodes/aliases.json @@ -41,7 +41,6 @@ "SMSG_SPLINE_MOVE_SET_RUN_BACK_SPEED": "SMSG_SPLINE_SET_RUN_BACK_SPEED", "SMSG_SPLINE_MOVE_SET_RUN_SPEED": "SMSG_SPLINE_SET_RUN_SPEED", "SMSG_SPLINE_MOVE_SET_SWIM_SPEED": "SMSG_SPLINE_SET_SWIM_SPEED", - "SMSG_UPDATE_AURA_DURATION": "SMSG_EQUIPMENT_SET_SAVED", "SMSG_VICTIMSTATEUPDATE_OBSOLETE": "SMSG_BATTLEFIELD_PORT_DENIED" } } diff --git a/EXPANSION_GUIDE.md b/EXPANSION_GUIDE.md new file mode 100644 index 00000000..6a2dc26e --- /dev/null +++ b/EXPANSION_GUIDE.md @@ -0,0 +1,126 @@ +# Multi-Expansion Architecture Guide + +WoWee supports three World of Warcraft expansions in a unified codebase using an expansion profile system. This guide explains how the multi-expansion support works. + +## Supported Expansions + +- **Vanilla (Classic) 1.12** - Original World of Warcraft +- **The Burning Crusade (TBC) 2.4.3** - First expansion +- **Wrath of the Lich King (WotLK) 3.3.5a** - Second expansion + +## Architecture Overview + +The multi-expansion support is built on the **Expansion Profile** system: + +1. **ExpansionProfile** (`include/game/expansion_profile.hpp`) - Metadata about each expansion + - Defines protocol version, data paths, asset locations + - Specifies which packet parsers to use + +2. **Packet Parsers** - Expansion-specific message handling + - `packet_parsers_classic.cpp` - Vanilla 1.12 message parsing + - `packet_parsers_tbc.cpp` - TBC 2.4.3 message parsing + - `packet_parsers_wotlk.cpp` (default) - WotLK 3.3.5a message parsing + +3. **Update Fields** - Expansion-specific entity data layout + - Loaded from `update_fields.json` in expansion data directory + - Defines UNIT_END, OBJECT_END, field indices for stats/health/mana + +## How to Use Different Expansions + +### At Startup + +WoWee auto-detects the expansion based on: +1. Realm list response (protocol version) +2. Server build number +3. Update field count + +### Manual Selection + +Set environment variable: +```bash +WOWEE_EXPANSION=tbc ./wowee # Force TBC +WOWEE_EXPANSION=classic ./wowee # Force Classic +``` + +## Key Differences Between Expansions + +### Packet Format Differences + +#### SMSG_SPELL_COOLDOWN +- **Classic**: 12 bytes per entry (spellId + itemId + cooldown, no flags) +- **TBC/WotLK**: 8 bytes per entry (spellId + cooldown) + flags byte + +#### SMSG_ACTION_BUTTONS +- **Classic**: 120 slots, no mode byte +- **TBC**: 132 slots, no mode byte +- **WotLK**: 144 slots + uint8 mode byte + +#### SMSG_PARTY_MEMBER_STATS +- **Classic/TBC**: Full uint64 for guid, uint16 health +- **WotLK**: PackedGuid format, uint32 health + +### Data Differences + +- **Talent trees**: Different spell IDs and tree structure per expansion +- **Items**: Different ItemDisplayInfo entries +- **Spells**: Different base stats, cooldowns +- **Character textures**: Expansion-specific variants for races + +## Adding Support for Another Expansion + +1. Create new expansion profile entry in `expansion_profile.cpp` +2. Add packet parser file (`packet_parsers_*.cpp`) for message variants +3. Create update_fields.json with correct field layout +4. Test realm connection and character loading + +## Code Patterns + +### Checking Current Expansion + +```cpp +#include "game/expansion_profile.hpp" + +// Global helper +bool isClassicLikeExpansion() { + auto profile = ExpansionProfile::getActive(); + return profile && (profile->name == "Classic" || profile->name == "Vanilla"); +} + +// Specific check +if (GameHandler::getInstance().isActiveExpansion("tbc")) { + // TBC-specific code +} +``` + +### Expansion-Specific Packet Parsing + +```cpp +// In packet_parsers_*.cpp, implement expansion-specific logic +bool parseXxxPacket(BitStream& data, ...) { + // Custom logic for this expansion's packet format +} +``` + +## Common Issues + +### "Update fields mismatch" Error +- Ensure `update_fields.json` matches server's field layout +- Check OBJECT_END and UNIT_END values +- Verify field indices for your target expansion + +### "Unknown packet" Warnings +- Expansion-specific opcodes may not be registered +- Check packet parser registration in `game_handler.cpp` +- Verify expansion profile is active + +### Packet Parsing Failures +- Each expansion has different struct layouts +- Always read data size first, then upfront validate +- Use size capping (e.g., max 100 items in list) + +## References + +- `include/game/expansion_profile.hpp` - Expansion metadata +- `docs/status.md` - Current feature support by expansion +- `src/game/packet_parsers_*.cpp` - Format-specific parsing logic +- `docs/` directory - Additional protocol documentation diff --git a/GETTING_STARTED.md b/GETTING_STARTED.md new file mode 100644 index 00000000..cdf80306 --- /dev/null +++ b/GETTING_STARTED.md @@ -0,0 +1,218 @@ +# Getting Started with WoWee + +WoWee is a native C++ World of Warcraft client that connects to private servers. This guide walks you through setting up and playing WoWee. + +## Prerequisites + +- **World of Warcraft Game Data** (Vanilla 1.12, TBC 2.4.3, or WotLK 3.3.5a) +- **A Private Server** (AzerothCore, TrinityCore, Mangos, or Turtle WoW compatible) +- **System Requirements**: Linux, macOS, or Windows with a Vulkan-capable GPU + +## Installation + +### Step 1: Build WoWee + +See [Building](README.md#building) section in README for detailed build instructions. + +**Quick start (Linux/macOS)**: +```bash +./build.sh +cd build/bin +./wowee +``` + +**Quick start (Windows)**: +```powershell +.\build.ps1 +cd build\bin +.\wowee.exe +``` + +### Step 2: Extract Game Data + +WoWee needs game assets from your WoW installation: + +**Using provided script (Linux/macOS)**: +```bash +./extract_assets.sh /path/to/wow/directory +``` + +**Using provided script (Windows)**: +```powershell +.\extract_assets.ps1 -WowDirectory "C:\Program Files\World of Warcraft" +``` + +**Manual extraction**: +1. Install [StormLib](https://github.com/ladislav-zezula/StormLib) +2. Extract to `./Data/`: + ``` + Data/ + ├── dbc/ # DBC files + ├── map/ # World map data + ├── adt/ # Terrain chunks + ├── wmo/ # Building models + ├── m2/ # Character/creature models + └── blp/ # Textures + ``` + +### Step 3: Connect to a Server + +1. **Start WoWee** + ```bash + cd build/bin && ./wowee + ``` + +2. **Enter Realm Information** + - Server Address: e.g., `localhost:3724` or `play.example.com:3724` + - WoWee fetches the realm list automatically + - Select your realm and click **Connect** + +3. **Choose Character** + - Select existing character or create new one + - Customize appearance and settings + - Click **Enter World** + +## First Steps in Game + +### Default Controls + +| Action | Key | +|--------|-----| +| Move Forward | W | +| Move Backward | S | +| Strafe Left | A | +| Strafe Right | D | +| Jump | Space | +| Toggle Chat | Enter | +| Interact (talk to NPC, loot) | F | +| Open Inventory | B | +| Open Spellbook | P | +| Open Talent Tree | T | +| Open Quest Log | Q | +| Open World Map | W (when not typing) | +| Toggle Minimap | M | +| Toggle Nameplates | V | +| Toggle Party Frames | F | +| Toggle Settings | Escape | +| Target Next Enemy | Tab | +| Target Previous Enemy | Shift+Tab | + +### Customizing Controls + +Press **Escape** → **Keybindings** to customize hotkeys. + +## Recommended First Steps + +### 1. Adjust Graphics Settings +- Press Escape → **Video Settings** +- Select appropriate **Graphics Preset** for your GPU: + - **LOW**: Low-end GPUs or when performance is priority + - **MEDIUM**: Balanced quality and performance + - **HIGH**: Good GPU with modern drivers + - **ULTRA**: High-end GPU for maximum quality + +### 2. Adjust Audio +- Press Escape → **Audio Settings** +- Set **Master Volume** to preferred level +- Adjust individual audio tracks (Music, Ambient, UI, etc.) +- Toggle **Original Soundtrack** if available + +### 3. Configure UI +- Press Escape → **Game Settings** +- Minimap preferences (rotation, square mode, zoom) +- Bag settings (separate windows, compact mode) +- Action bar visibility + +### 4. Complete First Quest +- Talk to nearby NPCs (they have quest markers ! or ?) +- Accept quest, complete objectives, return for reward +- Level up and gain experience + +## Important Notes + +### Data Directory +Game data is loaded from `Data/` subdirectory: +- If running from build folder: `../../Data` (symlinked automatically) +- If running from binary folder: `./Data` (must exist) +- If running in-place: Ensure `Data/` is in correct location + +### Settings +- Settings are saved to `~/.wowee/settings.cfg` (Linux/macOS) +- Or `%APPDATA%\wowee\settings.cfg` (Windows) +- Keybindings, graphics settings, and UI state persist + +### Multi-Expansion Support +WoWee auto-detects expansion from server: +- **Vanilla 1.12** - Original game +- **TBC 2.4.3** - Burning Crusade +- **WotLK 3.3.5a** - Wrath of the Lich King + +You can override with environment variable: +```bash +WOWEE_EXPANSION=tbc ./wowee # Force TBC +``` + +## Troubleshooting + +### "No realm list" or "Connection Failed" +- Check server address is correct +- Verify server is running +- See [Troubleshooting Guide](TROUBLESHOOTING.md#connection-issues) + +### Graphics Errors +- See [Graphics Troubleshooting](TROUBLESHOOTING.md#graphics-issues) +- Start with LOW graphics preset +- Update GPU driver + +### Audio Not Working +- Check system audio is enabled +- Verify audio files are extracted +- See [Audio Troubleshooting](TROUBLESHOOTING.md#audio-issues) + +### General Issues +- Comprehensive troubleshooting: See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) +- Check logs in `~/.wowee/logs/` for errors +- Verify expansion matches server requirements + +## Server Configuration + +### Tested Servers +- **AzerothCore** - Full support, recommended for learning +- **TrinityCore** - Full support, extensive customization +- **Mangos** - Full support, solid foundation +- **Turtle WoW** - Full support, 1.17 custom content + +### Server Requirements +- Must support Vanilla, TBC, or WotLK protocol +- Warden anti-cheat supported (module execution via emulation) +- Network must allow connections to realm list and world server ports + +See [Multi-Expansion Guide](EXPANSION_GUIDE.md) for protocol details. + +## Next Steps + +1. **Explore the World** - Travel to different zones and enjoy the landscape +2. **Join a Guild** - Find other players to group with +3. **Run Dungeons** - Experience instanced content +4. **PvP** - Engage in player-versus-player combat +5. **Twink Alt** - Create additional characters +6. **Customize Settings** - Fine-tune graphics, audio, and UI + +## Getting Help + +- **Game Issues**: See [TROUBLESHOOTING.md](TROUBLESHOOTING.md) +- **Graphics Help**: See [Graphics & Performance](README.md#graphics--performance) section +- **Multi-Expansion**: See [EXPANSION_GUIDE.md](EXPANSION_GUIDE.md) +- **Building Issues**: See [README.md](README.md#building) + +## Tips for Better Performance + +- Start with reasonable graphics preset for your GPU +- Close other applications when testing +- Keep GPU drivers updated +- Use FSR2 (if supported) for smooth 60+ FPS on weaker hardware +- Monitor frame rate with debug overlay (if available) + +## Enjoy! + +WoWee is a project to experience classic World of Warcraft on a modern engine. Have fun exploring Azeroth! diff --git a/README.md b/README.md index 7353ed15..d983133c 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,33 @@ Protocol Compatible with **Vanilla (Classic) 1.12 + TBC 2.4.3 + WotLK 3.3.5a**. - **Gossip** -- NPC interaction, dialogue options - **Chat** -- Tabs/channels, emotes, chat bubbles, clickable URLs, clickable item links with tooltips - **Party** -- Group invites, party list, out-of-range member health via SMSG_PARTY_MEMBER_STATS -- **Pets** -- Pet tracking via SMSG_PET_SPELLS, dismiss pet button +- **Pets** -- Pet tracking via SMSG_PET_SPELLS, action bar (10 slots with icon/autocast tinting/tooltips), dismiss button - **Map Exploration** -- Subzone-level fog-of-war reveal matching retail behavior - **Warden** -- Warden anti-cheat module execution via Unicorn Engine x86 emulation (cross-platform, no Wine) -- **UI** -- Loading screens with progress bar, settings window (shadow distance slider), minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) +- **UI** -- Loading screens with progress bar, settings window with graphics quality presets (LOW/MEDIUM/HIGH/ULTRA), shadow distance slider, minimap with zoom/rotation/square mode, top-right minimap mute speaker, separate bag windows with compact-empty mode (aggregate view) + +## Graphics & Performance + +### Quality Presets + +WoWee includes four built-in graphics quality presets to help you quickly balance visual quality and performance: + +| Preset | Shadows | MSAA | Normal Mapping | Clutter Density | +|--------|---------|------|----------------|-----------------| +| **LOW** | Off | Off | Disabled | 25% | +| **MEDIUM** | 200m distance | 2x | Basic | 60% | +| **HIGH** | 350m distance | 4x | Full (0.8x) | 100% | +| **ULTRA** | 500m distance | 8x | Enhanced (1.2x) | 150% | + +Press Escape to open **Video Settings** and select a preset, or adjust individual settings for a custom configuration. + +### Performance Tips + +- Start with **LOW** or **MEDIUM** if you experience frame drops +- Shadows and MSAA have the largest impact on performance +- Reduce **shadow distance** if shadows cause issues +- Disable **water refraction** if you encounter GPU errors (requires FSR to be active) +- Use **FSR2** (built-in upscaling) for better frame rates on modern GPUs ## Building diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 00000000..034fb769 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,186 @@ +# Troubleshooting Guide + +This guide covers common issues and solutions for WoWee. + +## Connection Issues + +### "Authentication Failed" +- **Cause**: Incorrect server address, expired realm list, or version mismatch +- **Solution**: + 1. Verify server address in realm list is correct + 2. Ensure your WoW data directory is for the correct expansion (Vanilla/TBC/WotLK) + 3. Check that the emulator server is running and reachable + +### "Realm List Connection Failed" +- **Cause**: Server is down, firewall blocking connection, or DNS issue +- **Solution**: + 1. Verify server IP/hostname is correct + 2. Test connectivity: `ping realm-server-address` + 3. Check firewall rules for port 3724 (auth) and 8085 (realm list) + 4. Try using IP address instead of hostname (DNS issues) + +### "Connection Lost During Login" +- **Cause**: Network timeout, server overload, or incompatible protocol version +- **Solution**: + 1. Check your network connection + 2. Reduce number of assets loading (lower graphics preset) + 3. Verify server supports this expansion version + +## Graphics Issues + +### "VK_ERROR_DEVICE_LOST" or Client Crashes +- **Cause**: GPU driver issue, insufficient VRAM, or graphics feature incompatibility +- **Solution**: + 1. **Immediate**: Disable advanced graphics features: + - Press Escape → Video Settings + - Set graphics preset to **LOW** + - Disable Water Refraction (requires FSR) + - Disable MSAA (set to Off) + 2. **Medium term**: Update GPU driver to latest version + 3. **Verify**: Use a graphics test tool to ensure GPU stability + 4. **If persists**: Try FSR2 disabled mode - check renderer logs + +### Black Screen or Rendering Issues +- **Cause**: Missing shaders, GPU memory allocation failure, or incorrect graphics settings +- **Solution**: + 1. Check logs: Look in `~/.wowee/logs/` for error messages + 2. Verify shaders compiled: Check for `.spv` files in `assets/shaders/compiled/` + 3. Reduce shadow distance: Press Escape → Video Settings → Lower shadow distance from 300m to 100m + 4. Disable shadows entirely if issues persist + +### Low FPS or Frame Stuttering +- **Cause**: Too high graphics settings for your GPU, memory fragmentation, or asset loading +- **Solution**: + 1. Apply lower graphics preset: Escape → LOW or MEDIUM + 2. Disable MSAA: Set to "Off" + 3. Reduce draw distance: Move further away from complex areas + 4. Close other applications consuming GPU memory + 5. Check CPU usage - if high, reduce number of visible entities + +### Water/Terrain Flickering +- **Cause**: Shadow mapping artifacts, terrain LOD issues, or GPU memory pressure +- **Solution**: + 1. Increase shadow distance slightly (150m to 200m) + 2. Disable shadows entirely as last resort + 3. Check GPU memory usage + +## Audio Issues + +### No Sound +- **Cause**: Audio initialization failed, missing audio data, or incorrect mixer setup +- **Solution**: + 1. Check system audio is working: Test with another application + 2. Verify audio files extracted: Check for `.wav` files in `Data/Audio/` + 3. Unmute audio: Look for speaker icon in minimap (top-right) - click to unmute + 4. Check settings: Escape → Audio Settings → Master Volume > 0 + +### Sound Cutting Out +- **Cause**: Audio buffer underrun, too many simultaneous sounds, or driver issue +- **Solution**: + 1. Lower audio volume: Escape → Audio Settings → Reduce Master Volume + 2. Disable distant ambient sounds: Reduce Ambient Volume + 3. Reduce number of particle effects + 4. Update audio driver + +## Gameplay Issues + +### Character Stuck or Not Moving +- **Cause**: Network synchronization issue, collision bug, or server desync +- **Solution**: + 1. Try pressing Escape to deselect any target, then move + 2. Jump (Spacebar) to test physics + 3. Reload the character: Press Escape → Disconnect → Reconnect + 4. Check for transport/vehicle state: Press 'R' to dismount if applicable + +### Spells Not Casting or Showing "Error" +- **Cause**: Cooldown, mana insufficient, target out of range, or server desync +- **Solution**: + 1. Verify spell is off cooldown (action bar shows availability) + 2. Check mana/energy: Look at player frame (top-left) + 3. Verify target range: Hover action bar button for range info + 4. Check server logs for error messages (combat log will show reason) + +### Quests Not Updating +- **Cause**: Objective already completed in different session, quest giver not found, or network desync +- **Solution**: + 1. Check quest objective: Open quest log (Q key) → Verify objective requirements + 2. Re-interact with NPC to trigger update packet + 3. Reload character if issue persists + +### Items Not Appearing in Inventory +- **Cause**: Inventory full, item filter active, or network desync +- **Solution**: + 1. Check inventory space: Open inventory (B key) → Count free slots + 2. Verify item isn't already there: Search inventory for item name + 3. Check if bags are full: Open bag windows, consolidate items + 4. Reload character if item is still missing + +## Performance Optimization + +### For Low-End GPUs +``` +Graphics Preset: LOW +- Shadows: OFF +- MSAA: OFF +- Normal Mapping: Disabled +- Clutter Density: 25% +- Draw Distance: Minimum +- Particles: Reduced +``` + +### For Mid-Range GPUs +``` +Graphics Preset: MEDIUM +- Shadows: 200m +- MSAA: 2x +- Normal Mapping: Basic +- Clutter Density: 60% +- FSR2: Enabled (if desired) +``` + +### For High-End GPUs +``` +Graphics Preset: HIGH or ULTRA +- Shadows: 350-500m +- MSAA: 4-8x +- Normal Mapping: Full (1.2x strength) +- Clutter Density: 100-150% +- FSR2: Optional (for 4K smoothness) +``` + +## Getting Help + +### Check Logs +Detailed logs are saved to: +- **Linux/macOS**: `~/.wowee/logs/` +- **Windows**: `%APPDATA%\wowee\logs\` + +Include relevant log entries when reporting issues. + +### Check Server Compatibility +- **AzerothCore**: Full support +- **TrinityCore**: Full support +- **Mangos**: Full support +- **Turtle WoW**: Full support (1.17) + +### Report Issues +If you encounter a bug: +1. Enable logging: Watch console for error messages +2. Reproduce the issue consistently +3. Gather system info: GPU, driver version, OS +4. Check if issue is expansion-specific (Classic/TBC/WotLK) +5. Report with detailed steps to reproduce + +### Clear Cache +If experiencing persistent issues, clear WoWee's cache: +```bash +# Linux/macOS +rm -rf ~/.wowee/warden_cache/ +rm -rf ~/.wowee/asset_cache/ + +# Windows +rmdir %APPDATA%\wowee\warden_cache\ /s +rmdir %APPDATA%\wowee\asset_cache\ /s +``` + +Then restart WoWee to rebuild cache. diff --git a/assets/shaders/fsr2_sharpen.frag.glsl b/assets/shaders/fsr2_sharpen.frag.glsl index 9cd1271c..392a3a37 100644 --- a/assets/shaders/fsr2_sharpen.frag.glsl +++ b/assets/shaders/fsr2_sharpen.frag.glsl @@ -10,9 +10,7 @@ layout(push_constant) uniform PushConstants { } pc; void main() { - // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, - // but we need standard UV coords for texture sampling) - vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + vec2 tc = TexCoord; vec2 texelSize = pc.params.xy; float sharpness = pc.params.z; diff --git a/assets/shaders/fsr2_sharpen.frag.spv b/assets/shaders/fsr2_sharpen.frag.spv index 20672a9e..24519b93 100644 Binary files a/assets/shaders/fsr2_sharpen.frag.spv and b/assets/shaders/fsr2_sharpen.frag.spv differ diff --git a/assets/shaders/fsr_easu.frag.glsl b/assets/shaders/fsr_easu.frag.glsl index 20e5ed32..6a36be75 100644 --- a/assets/shaders/fsr_easu.frag.glsl +++ b/assets/shaders/fsr_easu.frag.glsl @@ -21,9 +21,7 @@ vec3 fsrFetch(vec2 p, vec2 off) { } void main() { - // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, - // but we need standard UV coords for texture sampling) - vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + vec2 tc = TexCoord; // Map output pixel to input space vec2 pp = tc * fsr.con2.xy; // output pixel position diff --git a/assets/shaders/fsr_easu.frag.spv b/assets/shaders/fsr_easu.frag.spv index 5ddc2ea8..12780757 100644 Binary files a/assets/shaders/fsr_easu.frag.spv and b/assets/shaders/fsr_easu.frag.spv differ diff --git a/assets/shaders/fxaa.frag.glsl b/assets/shaders/fxaa.frag.glsl new file mode 100644 index 00000000..3da10854 --- /dev/null +++ b/assets/shaders/fxaa.frag.glsl @@ -0,0 +1,155 @@ +#version 450 + +// FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass. +// Reads the resolved scene color and outputs a smoothed result. +// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), desaturate (1=ghost grayscale). + +layout(set = 0, binding = 0) uniform sampler2D uScene; + +layout(location = 0) in vec2 TexCoord; +layout(location = 0) out vec4 outColor; + +layout(push_constant) uniform PC { + vec2 rcpFrame; + float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range) + float desaturate; // 1 = full grayscale (ghost mode), 0 = normal color +} pc; + +// Quality tuning +#define FXAA_EDGE_THRESHOLD (1.0/8.0) // minimum edge contrast to process +#define FXAA_EDGE_THRESHOLD_MIN (1.0/24.0) // ignore very dark regions +#define FXAA_SEARCH_STEPS 12 +#define FXAA_SEARCH_THRESHOLD (1.0/4.0) +#define FXAA_SUBPIX 0.75 +#define FXAA_SUBPIX_TRIM (1.0/4.0) +#define FXAA_SUBPIX_TRIM_SCALE (1.0/(1.0 - FXAA_SUBPIX_TRIM)) +#define FXAA_SUBPIX_CAP (3.0/4.0) + +float luma(vec3 c) { + return dot(c, vec3(0.299, 0.587, 0.114)); +} + +void main() { + vec2 uv = TexCoord; + vec2 rcp = pc.rcpFrame; + + // --- Centre and cardinal neighbours --- + vec3 rgbM = texture(uScene, uv).rgb; + vec3 rgbN = texture(uScene, uv + vec2( 0.0, -1.0) * rcp).rgb; + vec3 rgbS = texture(uScene, uv + vec2( 0.0, 1.0) * rcp).rgb; + vec3 rgbE = texture(uScene, uv + vec2( 1.0, 0.0) * rcp).rgb; + vec3 rgbW = texture(uScene, uv + vec2(-1.0, 0.0) * rcp).rgb; + + float lumaN = luma(rgbN); + float lumaS = luma(rgbS); + float lumaE = luma(rgbE); + float lumaW = luma(rgbW); + float lumaM = luma(rgbM); + + float lumaMin = min(lumaM, min(min(lumaN, lumaS), min(lumaE, lumaW))); + float lumaMax = max(lumaM, max(max(lumaN, lumaS), max(lumaE, lumaW))); + float range = lumaMax - lumaMin; + + // Early exit on smooth regions + if (range < max(FXAA_EDGE_THRESHOLD_MIN, lumaMax * FXAA_EDGE_THRESHOLD)) { + outColor = vec4(rgbM, 1.0); + return; + } + + // --- Diagonal neighbours --- + vec3 rgbNW = texture(uScene, uv + vec2(-1.0, -1.0) * rcp).rgb; + vec3 rgbNE = texture(uScene, uv + vec2( 1.0, -1.0) * rcp).rgb; + vec3 rgbSW = texture(uScene, uv + vec2(-1.0, 1.0) * rcp).rgb; + vec3 rgbSE = texture(uScene, uv + vec2( 1.0, 1.0) * rcp).rgb; + + float lumaNW = luma(rgbNW); + float lumaNE = luma(rgbNE); + float lumaSW = luma(rgbSW); + float lumaSE = luma(rgbSE); + + // --- Sub-pixel blend factor --- + float lumaL = (lumaN + lumaS + lumaE + lumaW) * 0.25; + float rangeL = abs(lumaL - lumaM); + float blendL = max(0.0, (rangeL / range) - FXAA_SUBPIX_TRIM) * FXAA_SUBPIX_TRIM_SCALE; + blendL = min(FXAA_SUBPIX_CAP, blendL) * FXAA_SUBPIX; + + // --- Edge orientation (horizontal vs. vertical) --- + float edgeHorz = + abs(-2.0*lumaW + lumaNW + lumaSW) + + 2.0*abs(-2.0*lumaM + lumaN + lumaS) + + abs(-2.0*lumaE + lumaNE + lumaSE); + float edgeVert = + abs(-2.0*lumaS + lumaSW + lumaSE) + + 2.0*abs(-2.0*lumaM + lumaW + lumaE) + + abs(-2.0*lumaN + lumaNW + lumaNE); + + bool horzSpan = (edgeHorz >= edgeVert); + float lengthSign = horzSpan ? rcp.y : rcp.x; + + float luma1 = horzSpan ? lumaN : lumaW; + float luma2 = horzSpan ? lumaS : lumaE; + float grad1 = abs(luma1 - lumaM); + float grad2 = abs(luma2 - lumaM); + lengthSign = (grad1 >= grad2) ? -lengthSign : lengthSign; + + // --- Edge search --- + vec2 posB = uv; + vec2 offNP = horzSpan ? vec2(rcp.x, 0.0) : vec2(0.0, rcp.y); + if (!horzSpan) posB.x += lengthSign * 0.5; + if ( horzSpan) posB.y += lengthSign * 0.5; + + float lumaMLSS = lumaM - (luma1 + luma2) * 0.5; + float gradientScaled = max(grad1, grad2) * 0.25; + + vec2 posN = posB - offNP; + vec2 posP = posB + offNP; + bool done1 = false, done2 = false; + float lumaEnd1 = 0.0, lumaEnd2 = 0.0; + + for (int i = 0; i < FXAA_SEARCH_STEPS; ++i) { + if (!done1) lumaEnd1 = luma(texture(uScene, posN).rgb) - lumaMLSS; + if (!done2) lumaEnd2 = luma(texture(uScene, posP).rgb) - lumaMLSS; + done1 = done1 || (abs(lumaEnd1) >= gradientScaled * FXAA_SEARCH_THRESHOLD); + done2 = done2 || (abs(lumaEnd2) >= gradientScaled * FXAA_SEARCH_THRESHOLD); + if (done1 && done2) break; + if (!done1) posN -= offNP; + if (!done2) posP += offNP; + } + + float dstN = horzSpan ? (uv.x - posN.x) : (uv.y - posN.y); + float dstP = horzSpan ? (posP.x - uv.x) : (posP.y - uv.y); + bool dirN = (dstN < dstP); + float lumaEndFinal = dirN ? lumaEnd1 : lumaEnd2; + + float spanLength = dstN + dstP; + float pixelOffset = (dirN ? dstN : dstP) / spanLength; + bool goodSpan = ((lumaEndFinal < 0.0) != (lumaMLSS < 0.0)); + float pixelOffsetFinal = max(goodSpan ? pixelOffset : 0.0, blendL); + + vec2 finalUV = uv; + if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign; + if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign; + + vec3 fxaaResult = texture(uScene, finalUV).rgb; + + // Post-FXAA contrast-adaptive sharpening (unsharp mask). + // Counteracts FXAA's sub-pixel blur when sharpness > 0. + if (pc.sharpness > 0.0) { + vec2 r = pc.rcpFrame; + vec3 blur = (texture(uScene, uv + vec2(-r.x, 0)).rgb + + texture(uScene, uv + vec2( r.x, 0)).rgb + + texture(uScene, uv + vec2(0, -r.y)).rgb + + texture(uScene, uv + vec2(0, r.y)).rgb) * 0.25; + // scale sharpness from [0,2] to a modest [0, 0.3] boost factor + float s = pc.sharpness * 0.15; + fxaaResult = clamp(fxaaResult + s * (fxaaResult - blur), 0.0, 1.0); + } + + // Ghost mode: desaturate to grayscale (with a slight cool blue tint). + if (pc.desaturate > 0.5) { + float gray = dot(fxaaResult, vec3(0.299, 0.587, 0.114)); + fxaaResult = mix(fxaaResult, vec3(gray, gray, gray * 1.05), pc.desaturate); + } + + outColor = vec4(fxaaResult, 1.0); +} diff --git a/assets/shaders/fxaa.frag.spv b/assets/shaders/fxaa.frag.spv new file mode 100644 index 00000000..b87b3dee Binary files /dev/null and b/assets/shaders/fxaa.frag.spv differ diff --git a/assets/shaders/m2_particle.frag.glsl b/assets/shaders/m2_particle.frag.glsl index f91a3fb7..49fac7e1 100644 --- a/assets/shaders/m2_particle.frag.glsl +++ b/assets/shaders/m2_particle.frag.glsl @@ -25,6 +25,9 @@ void main() { if (lum < 0.05) discard; } - float edge = smoothstep(0.5, 0.4, length(p - 0.5)); - outColor = texColor * vColor * vec4(vec3(1.0), edge); + // Soft circular falloff for point-sprite edges. + float edge = 1.0 - smoothstep(0.4, 0.5, length(p - 0.5)); + float alpha = texColor.a * vColor.a * edge; + vec3 rgb = texColor.rgb * vColor.rgb * alpha; + outColor = vec4(rgb, alpha); } diff --git a/assets/shaders/m2_ribbon.frag.glsl b/assets/shaders/m2_ribbon.frag.glsl new file mode 100644 index 00000000..4e0e483e --- /dev/null +++ b/assets/shaders/m2_ribbon.frag.glsl @@ -0,0 +1,25 @@ +#version 450 + +// M2 ribbon emitter fragment shader. +// Samples the ribbon texture, multiplied by vertex color and alpha. +// Uses additive blending (pipeline-level) for magic/spell trails. + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(location = 0) in vec3 vColor; +layout(location = 1) in float vAlpha; +layout(location = 2) in vec2 vUV; +layout(location = 3) in float vFogFactor; + +layout(location = 0) out vec4 outColor; + +void main() { + vec4 tex = texture(uTexture, vUV); + // For additive ribbons alpha comes from texture luminance; multiply by vertex alpha. + float a = tex.a * vAlpha; + if (a < 0.01) discard; + vec3 rgb = tex.rgb * vColor; + // Ribbons fade slightly with fog (additive blend attenuated toward black = invisible in fog). + rgb *= vFogFactor; + outColor = vec4(rgb, a); +} diff --git a/assets/shaders/m2_ribbon.frag.spv b/assets/shaders/m2_ribbon.frag.spv new file mode 100644 index 00000000..9b0a3fe9 Binary files /dev/null and b/assets/shaders/m2_ribbon.frag.spv differ diff --git a/assets/shaders/m2_ribbon.vert.glsl b/assets/shaders/m2_ribbon.vert.glsl new file mode 100644 index 00000000..492a295e --- /dev/null +++ b/assets/shaders/m2_ribbon.vert.glsl @@ -0,0 +1,43 @@ +#version 450 + +// M2 ribbon emitter vertex shader. +// Ribbon geometry is generated CPU-side as a triangle strip. +// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats. + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aColor; +layout(location = 2) in float aAlpha; +layout(location = 3) in vec2 aUV; + +layout(location = 0) out vec3 vColor; +layout(location = 1) out float vAlpha; +layout(location = 2) out vec2 vUV; +layout(location = 3) out float vFogFactor; + +void main() { + vec4 worldPos = vec4(aPos, 1.0); + vec4 viewPos4 = view * worldPos; + gl_Position = projection * viewPos4; + + float dist = length(viewPos4.xyz); + float fogStart = fogParams.x; + float fogEnd = fogParams.y; + vFogFactor = clamp((fogEnd - dist) / max(fogEnd - fogStart, 0.001), 0.0, 1.0); + + vColor = aColor; + vAlpha = aAlpha; + vUV = aUV; +} diff --git a/assets/shaders/m2_ribbon.vert.spv b/assets/shaders/m2_ribbon.vert.spv new file mode 100644 index 00000000..d74ba750 Binary files /dev/null and b/assets/shaders/m2_ribbon.vert.spv differ diff --git a/assets/shaders/minimap_display.frag.glsl b/assets/shaders/minimap_display.frag.glsl index dacaaed1..476017b9 100644 --- a/assets/shaders/minimap_display.frag.glsl +++ b/assets/shaders/minimap_display.frag.glsl @@ -40,23 +40,27 @@ void main() { float cs = cos(push.rotation); float sn = sin(push.rotation); vec2 rotated = vec2(center.x * cs - center.y * sn, center.x * sn + center.y * cs); - vec2 mapUV = push.playerUV + vec2(-rotated.x, rotated.y) * push.zoomRadius * 2.0; + vec2 mapUV = push.playerUV + vec2(rotated.x, rotated.y) * push.zoomRadius * 2.0; vec4 mapColor = texture(uComposite, mapUV); - // Player arrow - float acs = cos(push.arrowRotation); - float asn = sin(push.arrowRotation); - vec2 ac = center; - vec2 arrowPos = vec2(-(ac.x * acs - ac.y * asn), ac.x * asn + ac.y * acs); - - vec2 tip = vec2(0.0, -0.04); - vec2 left = vec2(-0.02, 0.02); - vec2 right = vec2(0.02, 0.02); - - if (pointInTriangle(arrowPos, tip, left, right)) { - mapColor = vec4(1.0, 0.8, 0.0, 1.0); + // Single player direction indicator (center arrow) rendered in-shader. + vec2 local = center; // [-0.5, 0.5] around minimap center + float ac = cos(push.arrowRotation); + float as = sin(push.arrowRotation); + // TexCoord Y grows downward on screen; use negative Y so 0-angle points North (up). + vec2 tip = vec2(0.0, -0.09); + vec2 left = vec2(-0.045, 0.02); + vec2 right = vec2( 0.045, 0.02); + mat2 rot = mat2(ac, -as, as, ac); + tip = rot * tip; + left = rot * left; + right = rot * right; + if (pointInTriangle(local, tip, left, right)) { + mapColor.rgb = vec3(1.0, 0.86, 0.05); } + float centerDot = smoothstep(0.016, 0.0, length(local)); + mapColor.rgb = mix(mapColor.rgb, vec3(1.0), centerDot * 0.95); // Dark border ring float border = smoothstep(0.48, 0.5, dist); diff --git a/assets/shaders/minimap_display.frag.spv b/assets/shaders/minimap_display.frag.spv index 5c0ac7b0..f33deef2 100644 Binary files a/assets/shaders/minimap_display.frag.spv and b/assets/shaders/minimap_display.frag.spv differ diff --git a/assets/shaders/postprocess.vert.glsl b/assets/shaders/postprocess.vert.glsl index aa78b1b5..2ed8f784 100644 --- a/assets/shaders/postprocess.vert.glsl +++ b/assets/shaders/postprocess.vert.glsl @@ -6,5 +6,7 @@ void main() { // Fullscreen triangle trick: 3 vertices, no vertex buffer TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); gl_Position = vec4(TexCoord * 2.0 - 1.0, 0.0, 1.0); - TexCoord.y = 1.0 - TexCoord.y; // flip Y for Vulkan + // No Y-flip: scene textures use Vulkan convention (v=0 at top), + // and NDC y=-1 already maps to framebuffer top, so the triangle + // naturally samples the correct row without any inversion. } diff --git a/assets/shaders/postprocess.vert.spv b/assets/shaders/postprocess.vert.spv index afc10472..89065a80 100644 Binary files a/assets/shaders/postprocess.vert.spv and b/assets/shaders/postprocess.vert.spv differ diff --git a/assets/shaders/quest_marker.frag.glsl b/assets/shaders/quest_marker.frag.glsl index 020b625d..0e209d8f 100644 --- a/assets/shaders/quest_marker.frag.glsl +++ b/assets/shaders/quest_marker.frag.glsl @@ -5,6 +5,7 @@ layout(set = 1, binding = 0) uniform sampler2D markerTexture; layout(push_constant) uniform Push { mat4 model; float alpha; + float grayscale; // 0 = full colour, 1 = fully desaturated (trivial quests) } push; layout(location = 0) in vec2 TexCoord; @@ -14,5 +15,7 @@ layout(location = 0) out vec4 outColor; void main() { vec4 texColor = texture(markerTexture, TexCoord); if (texColor.a < 0.1) discard; - outColor = vec4(texColor.rgb, texColor.a * push.alpha); + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + vec3 rgb = mix(texColor.rgb, vec3(lum), push.grayscale); + outColor = vec4(rgb, texColor.a * push.alpha); } diff --git a/assets/shaders/quest_marker.frag.spv b/assets/shaders/quest_marker.frag.spv index e947d04c..90814c30 100644 Binary files a/assets/shaders/quest_marker.frag.spv and b/assets/shaders/quest_marker.frag.spv differ diff --git a/docs/status.md b/docs/status.md index 8244b425..fca68f19 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,6 +1,6 @@ # Project Status -**Last updated**: 2026-03-07 +**Last updated**: 2026-03-18 ## What This Repo Is @@ -25,6 +25,9 @@ Implemented (working in normal use): - Talent tree UI with proper visuals and functionality - Pet tracking (SMSG_PET_SPELLS), dismiss pet button - Party: group invites, party list, out-of-range member health (SMSG_PARTY_MEMBER_STATS) +- Nameplates: NPC subtitles, guild names, elite/boss/rare borders, quest/raid indicators, cast bars, debuff dots +- Floating combat text: world-space damage/heal numbers above entities with 3D projection +- Target/focus frames: guild name, creature type, rank badges, combo points, cast bars - Map exploration: subzone-level fog-of-war reveal - Warden anti-cheat: full module execution via Unicorn Engine x86 emulation; module caching - Audio: ambient, movement, combat, spell, and UI sound systems @@ -35,10 +38,9 @@ Implemented (working in normal use): In progress / known gaps: - Transports: M2 transports (trams) working with position-delta riding; WMO transports (ships, zeppelins) working with path following; some edge cases remain -- 3D positional audio: not implemented (mono/stereo only) -- Visual edge cases: some M2/WMO rendering gaps (character shin mesh, some particle effects) -- Interior rendering: WMO interior shadows disabled (too dark); lava steam particles sparse -- Water refraction: implemented but disabled by default (can cause VK_ERROR_DEVICE_LOST on some GPUs) +- Visual edge cases: some M2/WMO rendering gaps (some particle effects) +- Lava steam particles: sparse in some areas (tuning opportunity) +- Water refraction: enabled by default; srcAccessMask barrier fix (2026-03-18) resolved prior VK_ERROR_DEVICE_LOST on AMD/Mali GPUs ## Where To Look diff --git a/extern/lua-5.1.5/COPYRIGHT b/extern/lua-5.1.5/COPYRIGHT new file mode 100644 index 00000000..a8602680 --- /dev/null +++ b/extern/lua-5.1.5/COPYRIGHT @@ -0,0 +1,34 @@ +Lua License +----------- + +Lua is licensed under the terms of the MIT license reproduced below. +This means that Lua is free software and can be used for both academic +and commercial purposes at absolutely no cost. + +For details and rationale, see http://www.lua.org/license.html . + +=============================================================================== + +Copyright (C) 1994-2012 Lua.org, PUC-Rio. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +=============================================================================== + +(end of COPYRIGHT) diff --git a/extern/lua-5.1.5/HISTORY b/extern/lua-5.1.5/HISTORY new file mode 100644 index 00000000..ce0c95bc --- /dev/null +++ b/extern/lua-5.1.5/HISTORY @@ -0,0 +1,183 @@ +HISTORY for Lua 5.1 + +* Changes from version 5.0 to 5.1 + ------------------------------- + Language: + + new module system. + + new semantics for control variables of fors. + + new semantics for setn/getn. + + new syntax/semantics for varargs. + + new long strings and comments. + + new `mod' operator (`%') + + new length operator #t + + metatables for all types + API: + + new functions: lua_createtable, lua_get(set)field, lua_push(to)integer. + + user supplies memory allocator (lua_open becomes lua_newstate). + + luaopen_* functions must be called through Lua. + Implementation: + + new configuration scheme via luaconf.h. + + incremental garbage collection. + + better handling of end-of-line in the lexer. + + fully reentrant parser (new Lua function `load') + + better support for 64-bit machines. + + native loadlib support for Mac OS X. + + standard distribution in only one library (lualib.a merged into lua.a) + +* Changes from version 4.0 to 5.0 + ------------------------------- + Language: + + lexical scoping. + + Lua coroutines. + + standard libraries now packaged in tables. + + tags replaced by metatables and tag methods replaced by metamethods, + stored in metatables. + + proper tail calls. + + each function can have its own global table, which can be shared. + + new __newindex metamethod, called when we insert a new key into a table. + + new block comments: --[[ ... ]]. + + new generic for. + + new weak tables. + + new boolean type. + + new syntax "local function". + + (f()) returns the first value returned by f. + + {f()} fills a table with all values returned by f. + + \n ignored in [[\n . + + fixed and-or priorities. + + more general syntax for function definition (e.g. function a.x.y:f()...end). + + more general syntax for function calls (e.g. (print or write)(9)). + + new functions (time/date, tmpfile, unpack, require, load*, etc.). + API: + + chunks are loaded by using lua_load; new luaL_loadfile and luaL_loadbuffer. + + introduced lightweight userdata, a simple "void*" without a metatable. + + new error handling protocol: the core no longer prints error messages; + all errors are reported to the caller on the stack. + + new lua_atpanic for host cleanup. + + new, signal-safe, hook scheme. + Implementation: + + new license: MIT. + + new, faster, register-based virtual machine. + + support for external multithreading and coroutines. + + new and consistent error message format. + + the core no longer needs "stdio.h" for anything (except for a single + use of sprintf to convert numbers to strings). + + lua.c now runs the environment variable LUA_INIT, if present. It can + be "@filename", to run a file, or the chunk itself. + + support for user extensions in lua.c. + sample implementation given for command line editing. + + new dynamic loading library, active by default on several platforms. + + safe garbage-collector metamethods. + + precompiled bytecodes checked for integrity (secure binary dostring). + + strings are fully aligned. + + position capture in string.find. + + read('*l') can read lines with embedded zeros. + +* Changes from version 3.2 to 4.0 + ------------------------------- + Language: + + new "break" and "for" statements (both numerical and for tables). + + uniform treatment of globals: globals are now stored in a Lua table. + + improved error messages. + + no more '$debug': full speed *and* full debug information. + + new read form: read(N) for next N bytes. + + general read patterns now deprecated. + (still available with -DCOMPAT_READPATTERNS.) + + all return values are passed as arguments for the last function + (old semantics still available with -DLUA_COMPAT_ARGRET) + + garbage collection tag methods for tables now deprecated. + + there is now only one tag method for order. + API: + + New API: fully re-entrant, simpler, and more efficient. + + New debug API. + Implementation: + + faster than ever: cleaner virtual machine and new hashing algorithm. + + non-recursive garbage-collector algorithm. + + reduced memory usage for programs with many strings. + + improved treatment for memory allocation errors. + + improved support for 16-bit machines (we hope). + + code now compiles unmodified as both ANSI C and C++. + + numbers in bases other than 10 are converted using strtoul. + + new -f option in Lua to support #! scripts. + + luac can now combine text and binaries. + +* Changes from version 3.1 to 3.2 + ------------------------------- + + redirected all output in Lua's core to _ERRORMESSAGE and _ALERT. + + increased limit on the number of constants and globals per function + (from 2^16 to 2^24). + + debugging info (lua_debug and hooks) moved into lua_state and new API + functions provided to get and set this info. + + new debug lib gives full debugging access within Lua. + + new table functions "foreachi", "sort", "tinsert", "tremove", "getn". + + new io functions "flush", "seek". + +* Changes from version 3.0 to 3.1 + ------------------------------- + + NEW FEATURE: anonymous functions with closures (via "upvalues"). + + new syntax: + - local variables in chunks. + - better scope control with DO block END. + - constructors can now be also written: { record-part; list-part }. + - more general syntax for function calls and lvalues, e.g.: + f(x).y=1 + o:f(x,y):g(z) + f"string" is sugar for f("string") + + strings may now contain arbitrary binary data (e.g., embedded zeros). + + major code re-organization and clean-up; reduced module interdependecies. + + no arbitrary limits on the total number of constants and globals. + + support for multiple global contexts. + + better syntax error messages. + + new traversal functions "foreach" and "foreachvar". + + the default for numbers is now double. + changing it to use floats or longs is easy. + + complete debug information stored in pre-compiled chunks. + + sample interpreter now prompts user when run interactively, and also + handles control-C interruptions gracefully. + +* Changes from version 2.5 to 3.0 + ------------------------------- + + NEW CONCEPT: "tag methods". + Tag methods replace fallbacks as the meta-mechanism for extending the + semantics of Lua. Whereas fallbacks had a global nature, tag methods + work on objects having the same tag (e.g., groups of tables). + Existing code that uses fallbacks should work without change. + + new, general syntax for constructors {[exp] = exp, ... }. + + support for handling variable number of arguments in functions (varargs). + + support for conditional compilation ($if ... $else ... $end). + + cleaner semantics in API simplifies host code. + + better support for writing libraries (auxlib.h). + + better type checking and error messages in the standard library. + + luac can now also undump. + +* Changes from version 2.4 to 2.5 + ------------------------------- + + io and string libraries are now based on pattern matching; + the old libraries are still available for compatibility + + dofile and dostring can now return values (via return statement) + + better support for 16- and 64-bit machines + + expanded documentation, with more examples + +* Changes from version 2.2 to 2.4 + ------------------------------- + + external compiler creates portable binary files that can be loaded faster + + interface for debugging and profiling + + new "getglobal" fallback + + new functions for handling references to Lua objects + + new functions in standard lib + + only one copy of each string is stored + + expanded documentation, with more examples + +* Changes from version 2.1 to 2.2 + ------------------------------- + + functions now may be declared with any "lvalue" as a name + + garbage collection of functions + + support for pipes + +* Changes from version 1.1 to 2.1 + ------------------------------- + + object-oriented support + + fallbacks + + simplified syntax for tables + + many internal improvements + +(end of HISTORY) diff --git a/extern/lua-5.1.5/INSTALL b/extern/lua-5.1.5/INSTALL new file mode 100644 index 00000000..17eb8aee --- /dev/null +++ b/extern/lua-5.1.5/INSTALL @@ -0,0 +1,99 @@ +INSTALL for Lua 5.1 + +* Building Lua + ------------ + Lua is built in the src directory, but the build process can be + controlled from the top-level Makefile. + + Building Lua on Unix systems should be very easy. First do "make" and + see if your platform is listed. If so, just do "make xxx", where xxx + is your platform name. The platforms currently supported are: + aix ansi bsd freebsd generic linux macosx mingw posix solaris + + If your platform is not listed, try the closest one or posix, generic, + ansi, in this order. + + See below for customization instructions and for instructions on how + to build with other Windows compilers. + + If you want to check that Lua has been built correctly, do "make test" + after building Lua. Also, have a look at the example programs in test. + +* Installing Lua + -------------- + Once you have built Lua, you may want to install it in an official + place in your system. In this case, do "make install". The official + place and the way to install files are defined in Makefile. You must + have the right permissions to install files. + + If you want to build and install Lua in one step, do "make xxx install", + where xxx is your platform name. + + If you want to install Lua locally, then do "make local". This will + create directories bin, include, lib, man, and install Lua there as + follows: + + bin: lua luac + include: lua.h luaconf.h lualib.h lauxlib.h lua.hpp + lib: liblua.a + man/man1: lua.1 luac.1 + + These are the only directories you need for development. + + There are man pages for lua and luac, in both nroff and html, and a + reference manual in html in doc, some sample code in test, and some + useful stuff in etc. You don't need these directories for development. + + If you want to install Lua locally, but in some other directory, do + "make install INSTALL_TOP=xxx", where xxx is your chosen directory. + + See below for instructions for Windows and other systems. + +* Customization + ------------- + Three things can be customized by editing a file: + - Where and how to install Lua -- edit Makefile. + - How to build Lua -- edit src/Makefile. + - Lua features -- edit src/luaconf.h. + + You don't actually need to edit the Makefiles because you may set the + relevant variables when invoking make. + + On the other hand, if you need to select some Lua features, you'll need + to edit src/luaconf.h. The edited file will be the one installed, and + it will be used by any Lua clients that you build, to ensure consistency. + + We strongly recommend that you enable dynamic loading. This is done + automatically for all platforms listed above that have this feature + (and also Windows). See src/luaconf.h and also src/Makefile. + +* Building Lua on Windows and other systems + ----------------------------------------- + If you're not using the usual Unix tools, then the instructions for + building Lua depend on the compiler you use. You'll need to create + projects (or whatever your compiler uses) for building the library, + the interpreter, and the compiler, as follows: + + library: lapi.c lcode.c ldebug.c ldo.c ldump.c lfunc.c lgc.c llex.c + lmem.c lobject.c lopcodes.c lparser.c lstate.c lstring.c + ltable.c ltm.c lundump.c lvm.c lzio.c + lauxlib.c lbaselib.c ldblib.c liolib.c lmathlib.c loslib.c + ltablib.c lstrlib.c loadlib.c linit.c + + interpreter: library, lua.c + + compiler: library, luac.c print.c + + If you use Visual Studio .NET, you can use etc/luavs.bat in its + "Command Prompt". + + If all you want is to build the Lua interpreter, you may put all .c files + in a single project, except for luac.c and print.c. Or just use etc/all.c. + + To use Lua as a library in your own programs, you'll need to know how to + create and use libraries with your compiler. + + As mentioned above, you may edit luaconf.h to select some features before + building Lua. + +(end of INSTALL) diff --git a/extern/lua-5.1.5/Makefile b/extern/lua-5.1.5/Makefile new file mode 100644 index 00000000..209a1324 --- /dev/null +++ b/extern/lua-5.1.5/Makefile @@ -0,0 +1,128 @@ +# makefile for installing Lua +# see INSTALL for installation instructions +# see src/Makefile and src/luaconf.h for further customization + +# == CHANGE THE SETTINGS BELOW TO SUIT YOUR ENVIRONMENT ======================= + +# Your platform. See PLATS for possible values. +PLAT= none + +# Where to install. The installation starts in the src and doc directories, +# so take care if INSTALL_TOP is not an absolute path. +INSTALL_TOP= /usr/local +INSTALL_BIN= $(INSTALL_TOP)/bin +INSTALL_INC= $(INSTALL_TOP)/include +INSTALL_LIB= $(INSTALL_TOP)/lib +INSTALL_MAN= $(INSTALL_TOP)/man/man1 +# +# You probably want to make INSTALL_LMOD and INSTALL_CMOD consistent with +# LUA_ROOT, LUA_LDIR, and LUA_CDIR in luaconf.h (and also with etc/lua.pc). +INSTALL_LMOD= $(INSTALL_TOP)/share/lua/$V +INSTALL_CMOD= $(INSTALL_TOP)/lib/lua/$V + +# How to install. If your install program does not support "-p", then you +# may have to run ranlib on the installed liblua.a (do "make ranlib"). +INSTALL= install -p +INSTALL_EXEC= $(INSTALL) -m 0755 +INSTALL_DATA= $(INSTALL) -m 0644 +# +# If you don't have install you can use cp instead. +# INSTALL= cp -p +# INSTALL_EXEC= $(INSTALL) +# INSTALL_DATA= $(INSTALL) + +# Utilities. +MKDIR= mkdir -p +RANLIB= ranlib + +# == END OF USER SETTINGS. NO NEED TO CHANGE ANYTHING BELOW THIS LINE ========= + +# Convenience platforms targets. +PLATS= aix ansi bsd freebsd generic linux macosx mingw posix solaris + +# What to install. +TO_BIN= lua luac +TO_INC= lua.h luaconf.h lualib.h lauxlib.h ../etc/lua.hpp +TO_LIB= liblua.a +TO_MAN= lua.1 luac.1 + +# Lua version and release. +V= 5.1 +R= 5.1.5 + +all: $(PLAT) + +$(PLATS) clean: + cd src && $(MAKE) $@ + +test: dummy + src/lua test/hello.lua + +install: dummy + cd src && $(MKDIR) $(INSTALL_BIN) $(INSTALL_INC) $(INSTALL_LIB) $(INSTALL_MAN) $(INSTALL_LMOD) $(INSTALL_CMOD) + cd src && $(INSTALL_EXEC) $(TO_BIN) $(INSTALL_BIN) + cd src && $(INSTALL_DATA) $(TO_INC) $(INSTALL_INC) + cd src && $(INSTALL_DATA) $(TO_LIB) $(INSTALL_LIB) + cd doc && $(INSTALL_DATA) $(TO_MAN) $(INSTALL_MAN) + +ranlib: + cd src && cd $(INSTALL_LIB) && $(RANLIB) $(TO_LIB) + +local: + $(MAKE) install INSTALL_TOP=.. + +none: + @echo "Please do" + @echo " make PLATFORM" + @echo "where PLATFORM is one of these:" + @echo " $(PLATS)" + @echo "See INSTALL for complete instructions." + +# make may get confused with test/ and INSTALL in a case-insensitive OS +dummy: + +# echo config parameters +echo: + @echo "" + @echo "These are the parameters currently set in src/Makefile to build Lua $R:" + @echo "" + @cd src && $(MAKE) -s echo + @echo "" + @echo "These are the parameters currently set in Makefile to install Lua $R:" + @echo "" + @echo "PLAT = $(PLAT)" + @echo "INSTALL_TOP = $(INSTALL_TOP)" + @echo "INSTALL_BIN = $(INSTALL_BIN)" + @echo "INSTALL_INC = $(INSTALL_INC)" + @echo "INSTALL_LIB = $(INSTALL_LIB)" + @echo "INSTALL_MAN = $(INSTALL_MAN)" + @echo "INSTALL_LMOD = $(INSTALL_LMOD)" + @echo "INSTALL_CMOD = $(INSTALL_CMOD)" + @echo "INSTALL_EXEC = $(INSTALL_EXEC)" + @echo "INSTALL_DATA = $(INSTALL_DATA)" + @echo "" + @echo "See also src/luaconf.h ." + @echo "" + +# echo private config parameters +pecho: + @echo "V = $(V)" + @echo "R = $(R)" + @echo "TO_BIN = $(TO_BIN)" + @echo "TO_INC = $(TO_INC)" + @echo "TO_LIB = $(TO_LIB)" + @echo "TO_MAN = $(TO_MAN)" + +# echo config parameters as Lua code +# uncomment the last sed expression if you want nil instead of empty strings +lecho: + @echo "-- installation parameters for Lua $R" + @echo "VERSION = '$V'" + @echo "RELEASE = '$R'" + @$(MAKE) echo | grep = | sed -e 's/= /= "/' -e 's/$$/"/' #-e 's/""/nil/' + @echo "-- EOF" + +# list targets that do not create files (but not all makes understand .PHONY) +.PHONY: all $(PLATS) clean test install local none dummy echo pecho lecho + +# (end of Makefile) diff --git a/extern/lua-5.1.5/README b/extern/lua-5.1.5/README new file mode 100644 index 00000000..11b4dff7 --- /dev/null +++ b/extern/lua-5.1.5/README @@ -0,0 +1,37 @@ +README for Lua 5.1 + +See INSTALL for installation instructions. +See HISTORY for a summary of changes since the last released version. + +* What is Lua? + ------------ + Lua is a powerful, light-weight programming language designed for extending + applications. Lua is also frequently used as a general-purpose, stand-alone + language. Lua is free software. + + For complete information, visit Lua's web site at http://www.lua.org/ . + For an executive summary, see http://www.lua.org/about.html . + + Lua has been used in many different projects around the world. + For a short list, see http://www.lua.org/uses.html . + +* Availability + ------------ + Lua is freely available for both academic and commercial purposes. + See COPYRIGHT and http://www.lua.org/license.html for details. + Lua can be downloaded at http://www.lua.org/download.html . + +* Installation + ------------ + Lua is implemented in pure ANSI C, and compiles unmodified in all known + platforms that have an ANSI C compiler. In most Unix-like platforms, simply + do "make" with a suitable target. See INSTALL for detailed instructions. + +* Origin + ------ + Lua is developed at Lua.org, a laboratory of the Department of Computer + Science of PUC-Rio (the Pontifical Catholic University of Rio de Janeiro + in Brazil). + For more information about the authors, see http://www.lua.org/authors.html . + +(end of README) diff --git a/extern/lua-5.1.5/doc/contents.html b/extern/lua-5.1.5/doc/contents.html new file mode 100644 index 00000000..3d83da98 --- /dev/null +++ b/extern/lua-5.1.5/doc/contents.html @@ -0,0 +1,497 @@ + + + +Lua 5.1 Reference Manual - contents + + + + + + + +
+

+ +Lua 5.1 Reference Manual +

+ +

+The reference manual is the official definition of the Lua language. +For a complete introduction to Lua programming, see the book +Programming in Lua. + +

+This manual is also available as a book: +

+ + + +Lua 5.1 Reference Manual +
by R. Ierusalimschy, L. H. de Figueiredo, W. Celes +
Lua.org, August 2006 +
ISBN 85-903798-3-3 +
+
+ +

+Buy a copy +of this book and +help to support +the Lua project. + +

+start +· +contents +· +index +· +other versions +


+ +Copyright © 2006–2012 Lua.org, PUC-Rio. +Freely available under the terms of the +Lua license. + + +

Contents

+ + +

Index

+ + + + + + + +
+

Lua functions

+_G
+_VERSION
+

+ +assert
+collectgarbage
+dofile
+error
+getfenv
+getmetatable
+ipairs
+load
+loadfile
+loadstring
+module
+next
+pairs
+pcall
+print
+rawequal
+rawget
+rawset
+require
+select
+setfenv
+setmetatable
+tonumber
+tostring
+type
+unpack
+xpcall
+

+ +coroutine.create
+coroutine.resume
+coroutine.running
+coroutine.status
+coroutine.wrap
+coroutine.yield
+

+ +debug.debug
+debug.getfenv
+debug.gethook
+debug.getinfo
+debug.getlocal
+debug.getmetatable
+debug.getregistry
+debug.getupvalue
+debug.setfenv
+debug.sethook
+debug.setlocal
+debug.setmetatable
+debug.setupvalue
+debug.traceback
+ +

+

 

+file:close
+file:flush
+file:lines
+file:read
+file:seek
+file:setvbuf
+file:write
+

+ +io.close
+io.flush
+io.input
+io.lines
+io.open
+io.output
+io.popen
+io.read
+io.stderr
+io.stdin
+io.stdout
+io.tmpfile
+io.type
+io.write
+

+ +math.abs
+math.acos
+math.asin
+math.atan
+math.atan2
+math.ceil
+math.cos
+math.cosh
+math.deg
+math.exp
+math.floor
+math.fmod
+math.frexp
+math.huge
+math.ldexp
+math.log
+math.log10
+math.max
+math.min
+math.modf
+math.pi
+math.pow
+math.rad
+math.random
+math.randomseed
+math.sin
+math.sinh
+math.sqrt
+math.tan
+math.tanh
+

+ +os.clock
+os.date
+os.difftime
+os.execute
+os.exit
+os.getenv
+os.remove
+os.rename
+os.setlocale
+os.time
+os.tmpname
+

+ +package.cpath
+package.loaded
+package.loaders
+package.loadlib
+package.path
+package.preload
+package.seeall
+

+ +string.byte
+string.char
+string.dump
+string.find
+string.format
+string.gmatch
+string.gsub
+string.len
+string.lower
+string.match
+string.rep
+string.reverse
+string.sub
+string.upper
+

+ +table.concat
+table.insert
+table.maxn
+table.remove
+table.sort
+ +

+

C API

+lua_Alloc
+lua_CFunction
+lua_Debug
+lua_Hook
+lua_Integer
+lua_Number
+lua_Reader
+lua_State
+lua_Writer
+

+ +lua_atpanic
+lua_call
+lua_checkstack
+lua_close
+lua_concat
+lua_cpcall
+lua_createtable
+lua_dump
+lua_equal
+lua_error
+lua_gc
+lua_getallocf
+lua_getfenv
+lua_getfield
+lua_getglobal
+lua_gethook
+lua_gethookcount
+lua_gethookmask
+lua_getinfo
+lua_getlocal
+lua_getmetatable
+lua_getstack
+lua_gettable
+lua_gettop
+lua_getupvalue
+lua_insert
+lua_isboolean
+lua_iscfunction
+lua_isfunction
+lua_islightuserdata
+lua_isnil
+lua_isnone
+lua_isnoneornil
+lua_isnumber
+lua_isstring
+lua_istable
+lua_isthread
+lua_isuserdata
+lua_lessthan
+lua_load
+lua_newstate
+lua_newtable
+lua_newthread
+lua_newuserdata
+lua_next
+lua_objlen
+lua_pcall
+lua_pop
+lua_pushboolean
+lua_pushcclosure
+lua_pushcfunction
+lua_pushfstring
+lua_pushinteger
+lua_pushlightuserdata
+lua_pushliteral
+lua_pushlstring
+lua_pushnil
+lua_pushnumber
+lua_pushstring
+lua_pushthread
+lua_pushvalue
+lua_pushvfstring
+lua_rawequal
+lua_rawget
+lua_rawgeti
+lua_rawset
+lua_rawseti
+lua_register
+lua_remove
+lua_replace
+lua_resume
+lua_setallocf
+lua_setfenv
+lua_setfield
+lua_setglobal
+lua_sethook
+lua_setlocal
+lua_setmetatable
+lua_settable
+lua_settop
+lua_setupvalue
+lua_status
+lua_toboolean
+lua_tocfunction
+lua_tointeger
+lua_tolstring
+lua_tonumber
+lua_topointer
+lua_tostring
+lua_tothread
+lua_touserdata
+lua_type
+lua_typename
+lua_upvalueindex
+lua_xmove
+lua_yield
+ +

+

auxiliary library

+luaL_Buffer
+luaL_Reg
+

+ +luaL_addchar
+luaL_addlstring
+luaL_addsize
+luaL_addstring
+luaL_addvalue
+luaL_argcheck
+luaL_argerror
+luaL_buffinit
+luaL_callmeta
+luaL_checkany
+luaL_checkint
+luaL_checkinteger
+luaL_checklong
+luaL_checklstring
+luaL_checknumber
+luaL_checkoption
+luaL_checkstack
+luaL_checkstring
+luaL_checktype
+luaL_checkudata
+luaL_dofile
+luaL_dostring
+luaL_error
+luaL_getmetafield
+luaL_getmetatable
+luaL_gsub
+luaL_loadbuffer
+luaL_loadfile
+luaL_loadstring
+luaL_newmetatable
+luaL_newstate
+luaL_openlibs
+luaL_optint
+luaL_optinteger
+luaL_optlong
+luaL_optlstring
+luaL_optnumber
+luaL_optstring
+luaL_prepbuffer
+luaL_pushresult
+luaL_ref
+luaL_register
+luaL_typename
+luaL_typerror
+luaL_unref
+luaL_where
+ +

+

+ +


+ +Last update: +Mon Feb 13 18:53:32 BRST 2012 + + + + + diff --git a/extern/lua-5.1.5/doc/cover.png b/extern/lua-5.1.5/doc/cover.png new file mode 100644 index 00000000..2dbb1981 Binary files /dev/null and b/extern/lua-5.1.5/doc/cover.png differ diff --git a/extern/lua-5.1.5/doc/logo.gif b/extern/lua-5.1.5/doc/logo.gif new file mode 100644 index 00000000..2f5e4ac2 Binary files /dev/null and b/extern/lua-5.1.5/doc/logo.gif differ diff --git a/extern/lua-5.1.5/doc/lua.1 b/extern/lua-5.1.5/doc/lua.1 new file mode 100644 index 00000000..24809cc6 --- /dev/null +++ b/extern/lua-5.1.5/doc/lua.1 @@ -0,0 +1,163 @@ +.\" $Id: lua.man,v 1.11 2006/01/06 16:03:34 lhf Exp $ +.TH LUA 1 "$Date: 2006/01/06 16:03:34 $" +.SH NAME +lua \- Lua interpreter +.SH SYNOPSIS +.B lua +[ +.I options +] +[ +.I script +[ +.I args +] +] +.SH DESCRIPTION +.B lua +is the stand-alone Lua interpreter. +It loads and executes Lua programs, +either in textual source form or +in precompiled binary form. +(Precompiled binaries are output by +.BR luac , +the Lua compiler.) +.B lua +can be used as a batch interpreter and also interactively. +.LP +The given +.I options +(see below) +are executed and then +the Lua program in file +.I script +is loaded and executed. +The given +.I args +are available to +.I script +as strings in a global table named +.BR arg . +If these arguments contain spaces or other characters special to the shell, +then they should be quoted +(but note that the quotes will be removed by the shell). +The arguments in +.B arg +start at 0, +which contains the string +.RI ' script '. +The index of the last argument is stored in +.BR arg.n . +The arguments given in the command line before +.IR script , +including the name of the interpreter, +are available in negative indices in +.BR arg . +.LP +At the very start, +before even handling the command line, +.B lua +executes the contents of the environment variable +.BR LUA_INIT , +if it is defined. +If the value of +.B LUA_INIT +is of the form +.RI '@ filename ', +then +.I filename +is executed. +Otherwise, the string is assumed to be a Lua statement and is executed. +.LP +Options start with +.B '\-' +and are described below. +You can use +.B "'\--'" +to signal the end of options. +.LP +If no arguments are given, +then +.B "\-v \-i" +is assumed when the standard input is a terminal; +otherwise, +.B "\-" +is assumed. +.LP +In interactive mode, +.B lua +prompts the user, +reads lines from the standard input, +and executes them as they are read. +If a line does not contain a complete statement, +then a secondary prompt is displayed and +lines are read until a complete statement is formed or +a syntax error is found. +So, one way to interrupt the reading of an incomplete statement is +to force a syntax error: +adding a +.B ';' +in the middle of a statement is a sure way of forcing a syntax error +(except inside multiline strings and comments; these must be closed explicitly). +If a line starts with +.BR '=' , +then +.B lua +displays the values of all the expressions in the remainder of the +line. The expressions must be separated by commas. +The primary prompt is the value of the global variable +.BR _PROMPT , +if this value is a string; +otherwise, the default prompt is used. +Similarly, the secondary prompt is the value of the global variable +.BR _PROMPT2 . +So, +to change the prompts, +set the corresponding variable to a string of your choice. +You can do that after calling the interpreter +or on the command line +(but in this case you have to be careful with quotes +if the prompt string contains a space; otherwise you may confuse the shell.) +The default prompts are "> " and ">> ". +.SH OPTIONS +.TP +.B \- +load and execute the standard input as a file, +that is, +not interactively, +even when the standard input is a terminal. +.TP +.BI \-e " stat" +execute statement +.IR stat . +You need to quote +.I stat +if it contains spaces, quotes, +or other characters special to the shell. +.TP +.B \-i +enter interactive mode after +.I script +is executed. +.TP +.BI \-l " name" +call +.BI require(' name ') +before executing +.IR script . +Typically used to load libraries. +.TP +.B \-v +show version information. +.SH "SEE ALSO" +.BR luac (1) +.br +http://www.lua.org/ +.SH DIAGNOSTICS +Error messages should be self explanatory. +.SH AUTHORS +R. Ierusalimschy, +L. H. de Figueiredo, +and +W. Celes +.\" EOF diff --git a/extern/lua-5.1.5/doc/lua.css b/extern/lua-5.1.5/doc/lua.css new file mode 100644 index 00000000..7fafbb1b --- /dev/null +++ b/extern/lua-5.1.5/doc/lua.css @@ -0,0 +1,83 @@ +body { + color: #000000 ; + background-color: #FFFFFF ; + font-family: Helvetica, Arial, sans-serif ; + text-align: justify ; + margin-right: 30px ; + margin-left: 30px ; +} + +h1, h2, h3, h4 { + font-family: Verdana, Geneva, sans-serif ; + font-weight: normal ; + font-style: italic ; +} + +h2 { + padding-top: 0.4em ; + padding-bottom: 0.4em ; + padding-left: 30px ; + padding-right: 30px ; + margin-left: -30px ; + background-color: #E0E0FF ; +} + +h3 { + padding-left: 0.5em ; + border-left: solid #E0E0FF 1em ; +} + +table h3 { + padding-left: 0px ; + border-left: none ; +} + +a:link { + color: #000080 ; + background-color: inherit ; + text-decoration: none ; +} + +a:visited { + background-color: inherit ; + text-decoration: none ; +} + +a:link:hover, a:visited:hover { + color: #000080 ; + background-color: #E0E0FF ; +} + +a:link:active, a:visited:active { + color: #FF0000 ; +} + +hr { + border: 0 ; + height: 1px ; + color: #a0a0a0 ; + background-color: #a0a0a0 ; +} + +:target { + background-color: #F8F8F8 ; + padding: 8px ; + border: solid #a0a0a0 2px ; +} + +.footer { + color: gray ; + font-size: small ; +} + +input[type=text] { + border: solid #a0a0a0 2px ; + border-radius: 2em ; + -moz-border-radius: 2em ; + background-image: url('images/search.png') ; + background-repeat: no-repeat; + background-position: 4px center ; + padding-left: 20px ; + height: 2em ; +} + diff --git a/extern/lua-5.1.5/doc/lua.html b/extern/lua-5.1.5/doc/lua.html new file mode 100644 index 00000000..1d435ab0 --- /dev/null +++ b/extern/lua-5.1.5/doc/lua.html @@ -0,0 +1,172 @@ + + + +LUA man page + + + + + +

NAME

+lua - Lua interpreter +

SYNOPSIS

+lua +[ +options +] +[ +script +[ +args +] +] +

DESCRIPTION

+lua +is the stand-alone Lua interpreter. +It loads and executes Lua programs, +either in textual source form or +in precompiled binary form. +(Precompiled binaries are output by +luac, +the Lua compiler.) +lua +can be used as a batch interpreter and also interactively. +

+The given +options +(see below) +are executed and then +the Lua program in file +script +is loaded and executed. +The given +args +are available to +script +as strings in a global table named +arg. +If these arguments contain spaces or other characters special to the shell, +then they should be quoted +(but note that the quotes will be removed by the shell). +The arguments in +arg +start at 0, +which contains the string +'script'. +The index of the last argument is stored in +arg.n. +The arguments given in the command line before +script, +including the name of the interpreter, +are available in negative indices in +arg. +

+At the very start, +before even handling the command line, +lua +executes the contents of the environment variable +LUA_INIT, +if it is defined. +If the value of +LUA_INIT +is of the form +'@filename', +then +filename +is executed. +Otherwise, the string is assumed to be a Lua statement and is executed. +

+Options start with +'-' +and are described below. +You can use +'--' +to signal the end of options. +

+If no arguments are given, +then +"-v -i" +is assumed when the standard input is a terminal; +otherwise, +"-" +is assumed. +

+In interactive mode, +lua +prompts the user, +reads lines from the standard input, +and executes them as they are read. +If a line does not contain a complete statement, +then a secondary prompt is displayed and +lines are read until a complete statement is formed or +a syntax error is found. +So, one way to interrupt the reading of an incomplete statement is +to force a syntax error: +adding a +';' +in the middle of a statement is a sure way of forcing a syntax error +(except inside multiline strings and comments; these must be closed explicitly). +If a line starts with +'=', +then +lua +displays the values of all the expressions in the remainder of the +line. The expressions must be separated by commas. +The primary prompt is the value of the global variable +_PROMPT, +if this value is a string; +otherwise, the default prompt is used. +Similarly, the secondary prompt is the value of the global variable +_PROMPT2. +So, +to change the prompts, +set the corresponding variable to a string of your choice. +You can do that after calling the interpreter +or on the command line +(but in this case you have to be careful with quotes +if the prompt string contains a space; otherwise you may confuse the shell.) +The default prompts are "> " and ">> ". +

OPTIONS

+

+- +load and execute the standard input as a file, +that is, +not interactively, +even when the standard input is a terminal. +

+-e stat +execute statement +stat. +You need to quote +stat +if it contains spaces, quotes, +or other characters special to the shell. +

+-i +enter interactive mode after +script +is executed. +

+-l name +call +require('name') +before executing +script. +Typically used to load libraries. +

+-v +show version information. +

SEE ALSO

+luac(1) +
+http://www.lua.org/ +

DIAGNOSTICS

+Error messages should be self explanatory. +

AUTHORS

+R. Ierusalimschy, +L. H. de Figueiredo, +and +W. Celes + + + diff --git a/extern/lua-5.1.5/doc/luac.1 b/extern/lua-5.1.5/doc/luac.1 new file mode 100644 index 00000000..d8146782 --- /dev/null +++ b/extern/lua-5.1.5/doc/luac.1 @@ -0,0 +1,136 @@ +.\" $Id: luac.man,v 1.28 2006/01/06 16:03:34 lhf Exp $ +.TH LUAC 1 "$Date: 2006/01/06 16:03:34 $" +.SH NAME +luac \- Lua compiler +.SH SYNOPSIS +.B luac +[ +.I options +] [ +.I filenames +] +.SH DESCRIPTION +.B luac +is the Lua compiler. +It translates programs written in the Lua programming language +into binary files that can be later loaded and executed. +.LP +The main advantages of precompiling chunks are: +faster loading, +protecting source code from accidental user changes, +and +off-line syntax checking. +.LP +Pre-compiling does not imply faster execution +because in Lua chunks are always compiled into bytecodes before being executed. +.B luac +simply allows those bytecodes to be saved in a file for later execution. +.LP +Pre-compiled chunks are not necessarily smaller than the corresponding source. +The main goal in pre-compiling is faster loading. +.LP +The binary files created by +.B luac +are portable only among architectures with the same word size and byte order. +.LP +.B luac +produces a single output file containing the bytecodes +for all source files given. +By default, +the output file is named +.BR luac.out , +but you can change this with the +.B \-o +option. +.LP +In the command line, +you can mix +text files containing Lua source and +binary files containing precompiled chunks. +This is useful to combine several precompiled chunks, +even from different (but compatible) platforms, +into a single precompiled chunk. +.LP +You can use +.B "'\-'" +to indicate the standard input as a source file +and +.B "'\--'" +to signal the end of options +(that is, +all remaining arguments will be treated as files even if they start with +.BR "'\-'" ). +.LP +The internal format of the binary files produced by +.B luac +is likely to change when a new version of Lua is released. +So, +save the source files of all Lua programs that you precompile. +.LP +.SH OPTIONS +Options must be separate. +.TP +.B \-l +produce a listing of the compiled bytecode for Lua's virtual machine. +Listing bytecodes is useful to learn about Lua's virtual machine. +If no files are given, then +.B luac +loads +.B luac.out +and lists its contents. +.TP +.BI \-o " file" +output to +.IR file , +instead of the default +.BR luac.out . +(You can use +.B "'\-'" +for standard output, +but not on platforms that open standard output in text mode.) +The output file may be a source file because +all files are loaded before the output file is written. +Be careful not to overwrite precious files. +.TP +.B \-p +load files but do not generate any output file. +Used mainly for syntax checking and for testing precompiled chunks: +corrupted files will probably generate errors when loaded. +Lua always performs a thorough integrity test on precompiled chunks. +Bytecode that passes this test is completely safe, +in the sense that it will not break the interpreter. +However, +there is no guarantee that such code does anything sensible. +(None can be given, because the halting problem is unsolvable.) +If no files are given, then +.B luac +loads +.B luac.out +and tests its contents. +No messages are displayed if the file passes the integrity test. +.TP +.B \-s +strip debug information before writing the output file. +This saves some space in very large chunks, +but if errors occur when running a stripped chunk, +then the error messages may not contain the full information they usually do. +For instance, +line numbers and names of local variables are lost. +.TP +.B \-v +show version information. +.SH FILES +.TP 15 +.B luac.out +default output file +.SH "SEE ALSO" +.BR lua (1) +.br +http://www.lua.org/ +.SH DIAGNOSTICS +Error messages should be self explanatory. +.SH AUTHORS +L. H. de Figueiredo, +R. Ierusalimschy and +W. Celes +.\" EOF diff --git a/extern/lua-5.1.5/doc/luac.html b/extern/lua-5.1.5/doc/luac.html new file mode 100644 index 00000000..179ffe82 --- /dev/null +++ b/extern/lua-5.1.5/doc/luac.html @@ -0,0 +1,145 @@ + + + +LUAC man page + + + + + +

NAME

+luac - Lua compiler +

SYNOPSIS

+luac +[ +options +] [ +filenames +] +

DESCRIPTION

+luac +is the Lua compiler. +It translates programs written in the Lua programming language +into binary files that can be later loaded and executed. +

+The main advantages of precompiling chunks are: +faster loading, +protecting source code from accidental user changes, +and +off-line syntax checking. +

+Precompiling does not imply faster execution +because in Lua chunks are always compiled into bytecodes before being executed. +luac +simply allows those bytecodes to be saved in a file for later execution. +

+Precompiled chunks are not necessarily smaller than the corresponding source. +The main goal in precompiling is faster loading. +

+The binary files created by +luac +are portable only among architectures with the same word size and byte order. +

+luac +produces a single output file containing the bytecodes +for all source files given. +By default, +the output file is named +luac.out, +but you can change this with the +-o +option. +

+In the command line, +you can mix +text files containing Lua source and +binary files containing precompiled chunks. +This is useful because several precompiled chunks, +even from different (but compatible) platforms, +can be combined into a single precompiled chunk. +

+You can use +'-' +to indicate the standard input as a source file +and +'--' +to signal the end of options +(that is, +all remaining arguments will be treated as files even if they start with +'-'). +

+The internal format of the binary files produced by +luac +is likely to change when a new version of Lua is released. +So, +save the source files of all Lua programs that you precompile. +

+

OPTIONS

+Options must be separate. +

+-l +produce a listing of the compiled bytecode for Lua's virtual machine. +Listing bytecodes is useful to learn about Lua's virtual machine. +If no files are given, then +luac +loads +luac.out +and lists its contents. +

+-o file +output to +file, +instead of the default +luac.out. +(You can use +'-' +for standard output, +but not on platforms that open standard output in text mode.) +The output file may be a source file because +all files are loaded before the output file is written. +Be careful not to overwrite precious files. +

+-p +load files but do not generate any output file. +Used mainly for syntax checking and for testing precompiled chunks: +corrupted files will probably generate errors when loaded. +Lua always performs a thorough integrity test on precompiled chunks. +Bytecode that passes this test is completely safe, +in the sense that it will not break the interpreter. +However, +there is no guarantee that such code does anything sensible. +(None can be given, because the halting problem is unsolvable.) +If no files are given, then +luac +loads +luac.out +and tests its contents. +No messages are displayed if the file passes the integrity test. +

+-s +strip debug information before writing the output file. +This saves some space in very large chunks, +but if errors occur when running a stripped chunk, +then the error messages may not contain the full information they usually do. +For instance, +line numbers and names of local variables are lost. +

+-v +show version information. +

FILES

+

+luac.out +default output file +

SEE ALSO

+lua(1) +
+http://www.lua.org/ +

DIAGNOSTICS

+Error messages should be self explanatory. +

AUTHORS

+L. H. de Figueiredo, +R. Ierusalimschy and +W. Celes + + + diff --git a/extern/lua-5.1.5/doc/manual.css b/extern/lua-5.1.5/doc/manual.css new file mode 100644 index 00000000..b49b3629 --- /dev/null +++ b/extern/lua-5.1.5/doc/manual.css @@ -0,0 +1,24 @@ +h3 code { + font-family: inherit ; + font-size: inherit ; +} + +pre, code { + font-size: 12pt ; +} + +span.apii { + float: right ; + font-family: inherit ; + font-style: normal ; + font-size: small ; + color: gray ; +} + +p+h1, ul+h1 { + padding-top: 0.4em ; + padding-bottom: 0.4em ; + padding-left: 30px ; + margin-left: -30px ; + background-color: #E0E0FF ; +} diff --git a/extern/lua-5.1.5/doc/manual.html b/extern/lua-5.1.5/doc/manual.html new file mode 100644 index 00000000..4e41683d --- /dev/null +++ b/extern/lua-5.1.5/doc/manual.html @@ -0,0 +1,8804 @@ + + + + +Lua 5.1 Reference Manual + + + + + + + +
+

+ +Lua 5.1 Reference Manual +

+ +by Roberto Ierusalimschy, Luiz Henrique de Figueiredo, Waldemar Celes +

+ +Copyright © 2006–2012 Lua.org, PUC-Rio. +Freely available under the terms of the +Lua license. + +


+

+ +contents +· +index +· +other versions + + +

+ + + + + + +

1 - Introduction

+ +

+Lua is an extension programming language designed to support +general procedural programming with data description +facilities. +It also offers good support for object-oriented programming, +functional programming, and data-driven programming. +Lua is intended to be used as a powerful, light-weight +scripting language for any program that needs one. +Lua is implemented as a library, written in clean C +(that is, in the common subset of ANSI C and C++). + + +

+Being an extension language, Lua has no notion of a "main" program: +it only works embedded in a host client, +called the embedding program or simply the host. +This host program can invoke functions to execute a piece of Lua code, +can write and read Lua variables, +and can register C functions to be called by Lua code. +Through the use of C functions, Lua can be augmented to cope with +a wide range of different domains, +thus creating customized programming languages sharing a syntactical framework. +The Lua distribution includes a sample host program called lua, +which uses the Lua library to offer a complete, stand-alone Lua interpreter. + + +

+Lua is free software, +and is provided as usual with no guarantees, +as stated in its license. +The implementation described in this manual is available +at Lua's official web site, www.lua.org. + + +

+Like any other reference manual, +this document is dry in places. +For a discussion of the decisions behind the design of Lua, +see the technical papers available at Lua's web site. +For a detailed introduction to programming in Lua, +see Roberto's book, Programming in Lua (Second Edition). + + + +

2 - The Language

+ +

+This section describes the lexis, the syntax, and the semantics of Lua. +In other words, +this section describes +which tokens are valid, +how they can be combined, +and what their combinations mean. + + +

+The language constructs will be explained using the usual extended BNF notation, +in which +{a} means 0 or more a's, and +[a] means an optional a. +Non-terminals are shown like non-terminal, +keywords are shown like kword, +and other terminal symbols are shown like `=´. +The complete syntax of Lua can be found in §8 +at the end of this manual. + + + +

2.1 - Lexical Conventions

+ +

+Names +(also called identifiers) +in Lua can be any string of letters, +digits, and underscores, +not beginning with a digit. +This coincides with the definition of names in most languages. +(The definition of letter depends on the current locale: +any character considered alphabetic by the current locale +can be used in an identifier.) +Identifiers are used to name variables and table fields. + + +

+The following keywords are reserved +and cannot be used as names: + + +

+     and       break     do        else      elseif
+     end       false     for       function  if
+     in        local     nil       not       or
+     repeat    return    then      true      until     while
+
+ +

+Lua is a case-sensitive language: +and is a reserved word, but And and AND +are two different, valid names. +As a convention, names starting with an underscore followed by +uppercase letters (such as _VERSION) +are reserved for internal global variables used by Lua. + + +

+The following strings denote other tokens: + +

+     +     -     *     /     %     ^     #
+     ==    ~=    <=    >=    <     >     =
+     (     )     {     }     [     ]
+     ;     :     ,     .     ..    ...
+
+ +

+Literal strings +can be delimited by matching single or double quotes, +and can contain the following C-like escape sequences: +'\a' (bell), +'\b' (backspace), +'\f' (form feed), +'\n' (newline), +'\r' (carriage return), +'\t' (horizontal tab), +'\v' (vertical tab), +'\\' (backslash), +'\"' (quotation mark [double quote]), +and '\'' (apostrophe [single quote]). +Moreover, a backslash followed by a real newline +results in a newline in the string. +A character in a string can also be specified by its numerical value +using the escape sequence \ddd, +where ddd is a sequence of up to three decimal digits. +(Note that if a numerical escape is to be followed by a digit, +it must be expressed using exactly three digits.) +Strings in Lua can contain any 8-bit value, including embedded zeros, +which can be specified as '\0'. + + +

+Literal strings can also be defined using a long format +enclosed by long brackets. +We define an opening long bracket of level n as an opening +square bracket followed by n equal signs followed by another +opening square bracket. +So, an opening long bracket of level 0 is written as [[, +an opening long bracket of level 1 is written as [=[, +and so on. +A closing long bracket is defined similarly; +for instance, a closing long bracket of level 4 is written as ]====]. +A long string starts with an opening long bracket of any level and +ends at the first closing long bracket of the same level. +Literals in this bracketed form can run for several lines, +do not interpret any escape sequences, +and ignore long brackets of any other level. +They can contain anything except a closing bracket of the proper level. + + +

+For convenience, +when the opening long bracket is immediately followed by a newline, +the newline is not included in the string. +As an example, in a system using ASCII +(in which 'a' is coded as 97, +newline is coded as 10, and '1' is coded as 49), +the five literal strings below denote the same string: + +

+     a = 'alo\n123"'
+     a = "alo\n123\""
+     a = '\97lo\10\04923"'
+     a = [[alo
+     123"]]
+     a = [==[
+     alo
+     123"]==]
+
+ +

+A numerical constant can be written with an optional decimal part +and an optional decimal exponent. +Lua also accepts integer hexadecimal constants, +by prefixing them with 0x. +Examples of valid numerical constants are + +

+     3   3.0   3.1416   314.16e-2   0.31416E1   0xff   0x56
+
+ +

+A comment starts with a double hyphen (--) +anywhere outside a string. +If the text immediately after -- is not an opening long bracket, +the comment is a short comment, +which runs until the end of the line. +Otherwise, it is a long comment, +which runs until the corresponding closing long bracket. +Long comments are frequently used to disable code temporarily. + + + + + +

2.2 - Values and Types

+ +

+Lua is a dynamically typed language. +This means that +variables do not have types; only values do. +There are no type definitions in the language. +All values carry their own type. + + +

+All values in Lua are first-class values. +This means that all values can be stored in variables, +passed as arguments to other functions, and returned as results. + + +

+There are eight basic types in Lua: +nil, boolean, number, +string, function, userdata, +thread, and table. +Nil is the type of the value nil, +whose main property is to be different from any other value; +it usually represents the absence of a useful value. +Boolean is the type of the values false and true. +Both nil and false make a condition false; +any other value makes it true. +Number represents real (double-precision floating-point) numbers. +(It is easy to build Lua interpreters that use other +internal representations for numbers, +such as single-precision float or long integers; +see file luaconf.h.) +String represents arrays of characters. + +Lua is 8-bit clean: +strings can contain any 8-bit character, +including embedded zeros ('\0') (see §2.1). + + +

+Lua can call (and manipulate) functions written in Lua and +functions written in C +(see §2.5.8). + + +

+The type userdata is provided to allow arbitrary C data to +be stored in Lua variables. +This type corresponds to a block of raw memory +and has no pre-defined operations in Lua, +except assignment and identity test. +However, by using metatables, +the programmer can define operations for userdata values +(see §2.8). +Userdata values cannot be created or modified in Lua, +only through the C API. +This guarantees the integrity of data owned by the host program. + + +

+The type thread represents independent threads of execution +and it is used to implement coroutines (see §2.11). +Do not confuse Lua threads with operating-system threads. +Lua supports coroutines on all systems, +even those that do not support threads. + + +

+The type table implements associative arrays, +that is, arrays that can be indexed not only with numbers, +but with any value (except nil). +Tables can be heterogeneous; +that is, they can contain values of all types (except nil). +Tables are the sole data structuring mechanism in Lua; +they can be used to represent ordinary arrays, +symbol tables, sets, records, graphs, trees, etc. +To represent records, Lua uses the field name as an index. +The language supports this representation by +providing a.name as syntactic sugar for a["name"]. +There are several convenient ways to create tables in Lua +(see §2.5.7). + + +

+Like indices, +the value of a table field can be of any type (except nil). +In particular, +because functions are first-class values, +table fields can contain functions. +Thus tables can also carry methods (see §2.5.9). + + +

+Tables, functions, threads, and (full) userdata values are objects: +variables do not actually contain these values, +only references to them. +Assignment, parameter passing, and function returns +always manipulate references to such values; +these operations do not imply any kind of copy. + + +

+The library function type returns a string describing the type +of a given value. + + + +

2.2.1 - Coercion

+ +

+Lua provides automatic conversion between +string and number values at run time. +Any arithmetic operation applied to a string tries to convert +this string to a number, following the usual conversion rules. +Conversely, whenever a number is used where a string is expected, +the number is converted to a string, in a reasonable format. +For complete control over how numbers are converted to strings, +use the format function from the string library +(see string.format). + + + + + + + +

2.3 - Variables

+ +

+Variables are places that store values. + +There are three kinds of variables in Lua: +global variables, local variables, and table fields. + + +

+A single name can denote a global variable or a local variable +(or a function's formal parameter, +which is a particular kind of local variable): + +

+	var ::= Name
+

+Name denotes identifiers, as defined in §2.1. + + +

+Any variable is assumed to be global unless explicitly declared +as a local (see §2.4.7). +Local variables are lexically scoped: +local variables can be freely accessed by functions +defined inside their scope (see §2.6). + + +

+Before the first assignment to a variable, its value is nil. + + +

+Square brackets are used to index a table: + +

+	var ::= prefixexp `[´ exp `]´
+

+The meaning of accesses to global variables +and table fields can be changed via metatables. +An access to an indexed variable t[i] is equivalent to +a call gettable_event(t,i). +(See §2.8 for a complete description of the +gettable_event function. +This function is not defined or callable in Lua. +We use it here only for explanatory purposes.) + + +

+The syntax var.Name is just syntactic sugar for +var["Name"]: + +

+	var ::= prefixexp `.´ Name
+
+ +

+All global variables live as fields in ordinary Lua tables, +called environment tables or simply +environments (see §2.9). +Each function has its own reference to an environment, +so that all global variables in this function +will refer to this environment table. +When a function is created, +it inherits the environment from the function that created it. +To get the environment table of a Lua function, +you call getfenv. +To replace it, +you call setfenv. +(You can only manipulate the environment of C functions +through the debug library; (see §5.9).) + + +

+An access to a global variable x +is equivalent to _env.x, +which in turn is equivalent to + +

+     gettable_event(_env, "x")
+

+where _env is the environment of the running function. +(See §2.8 for a complete description of the +gettable_event function. +This function is not defined or callable in Lua. +Similarly, the _env variable is not defined in Lua. +We use them here only for explanatory purposes.) + + + + + +

2.4 - Statements

+ +

+Lua supports an almost conventional set of statements, +similar to those in Pascal or C. +This set includes +assignments, control structures, function calls, +and variable declarations. + + + +

2.4.1 - Chunks

+ +

+The unit of execution of Lua is called a chunk. +A chunk is simply a sequence of statements, +which are executed sequentially. +Each statement can be optionally followed by a semicolon: + +

+	chunk ::= {stat [`;´]}
+

+There are no empty statements and thus ';;' is not legal. + + +

+Lua handles a chunk as the body of an anonymous function +with a variable number of arguments +(see §2.5.9). +As such, chunks can define local variables, +receive arguments, and return values. + + +

+A chunk can be stored in a file or in a string inside the host program. +To execute a chunk, +Lua first pre-compiles the chunk into instructions for a virtual machine, +and then it executes the compiled code +with an interpreter for the virtual machine. + + +

+Chunks can also be pre-compiled into binary form; +see program luac for details. +Programs in source and compiled forms are interchangeable; +Lua automatically detects the file type and acts accordingly. + + + + + + +

2.4.2 - Blocks

+A block is a list of statements; +syntactically, a block is the same as a chunk: + +

+	block ::= chunk
+
+ +

+A block can be explicitly delimited to produce a single statement: + +

+	stat ::= do block end
+

+Explicit blocks are useful +to control the scope of variable declarations. +Explicit blocks are also sometimes used to +add a return or break statement in the middle +of another block (see §2.4.4). + + + + + +

2.4.3 - Assignment

+ +

+Lua allows multiple assignments. +Therefore, the syntax for assignment +defines a list of variables on the left side +and a list of expressions on the right side. +The elements in both lists are separated by commas: + +

+	stat ::= varlist `=´ explist
+	varlist ::= var {`,´ var}
+	explist ::= exp {`,´ exp}
+

+Expressions are discussed in §2.5. + + +

+Before the assignment, +the list of values is adjusted to the length of +the list of variables. +If there are more values than needed, +the excess values are thrown away. +If there are fewer values than needed, +the list is extended with as many nil's as needed. +If the list of expressions ends with a function call, +then all values returned by that call enter the list of values, +before the adjustment +(except when the call is enclosed in parentheses; see §2.5). + + +

+The assignment statement first evaluates all its expressions +and only then are the assignments performed. +Thus the code + +

+     i = 3
+     i, a[i] = i+1, 20
+

+sets a[3] to 20, without affecting a[4] +because the i in a[i] is evaluated (to 3) +before it is assigned 4. +Similarly, the line + +

+     x, y = y, x
+

+exchanges the values of x and y, +and + +

+     x, y, z = y, z, x
+

+cyclically permutes the values of x, y, and z. + + +

+The meaning of assignments to global variables +and table fields can be changed via metatables. +An assignment to an indexed variable t[i] = val is equivalent to +settable_event(t,i,val). +(See §2.8 for a complete description of the +settable_event function. +This function is not defined or callable in Lua. +We use it here only for explanatory purposes.) + + +

+An assignment to a global variable x = val +is equivalent to the assignment +_env.x = val, +which in turn is equivalent to + +

+     settable_event(_env, "x", val)
+

+where _env is the environment of the running function. +(The _env variable is not defined in Lua. +We use it here only for explanatory purposes.) + + + + + +

2.4.4 - Control Structures

+The control structures +if, while, and repeat have the usual meaning and +familiar syntax: + + + + +

+	stat ::= while exp do block end
+	stat ::= repeat block until exp
+	stat ::= if exp then block {elseif exp then block} [else block] end
+

+Lua also has a for statement, in two flavors (see §2.4.5). + + +

+The condition expression of a +control structure can return any value. +Both false and nil are considered false. +All values different from nil and false are considered true +(in particular, the number 0 and the empty string are also true). + + +

+In the repeatuntil loop, +the inner block does not end at the until keyword, +but only after the condition. +So, the condition can refer to local variables +declared inside the loop block. + + +

+The return statement is used to return values +from a function or a chunk (which is just a function). + +Functions and chunks can return more than one value, +and so the syntax for the return statement is + +

+	stat ::= return [explist]
+
+ +

+The break statement is used to terminate the execution of a +while, repeat, or for loop, +skipping to the next statement after the loop: + + +

+	stat ::= break
+

+A break ends the innermost enclosing loop. + + +

+The return and break +statements can only be written as the last statement of a block. +If it is really necessary to return or break in the +middle of a block, +then an explicit inner block can be used, +as in the idioms +do return end and do break end, +because now return and break are the last statements in +their (inner) blocks. + + + + + +

2.4.5 - For Statement

+ +

+ +The for statement has two forms: +one numeric and one generic. + + +

+The numeric for loop repeats a block of code while a +control variable runs through an arithmetic progression. +It has the following syntax: + +

+	stat ::= for Name `=´ exp `,´ exp [`,´ exp] do block end
+

+The block is repeated for name starting at the value of +the first exp, until it passes the second exp by steps of the +third exp. +More precisely, a for statement like + +

+     for v = e1, e2, e3 do block end
+

+is equivalent to the code: + +

+     do
+       local var, limit, step = tonumber(e1), tonumber(e2), tonumber(e3)
+       if not (var and limit and step) then error() end
+       while (step > 0 and var <= limit) or (step <= 0 and var >= limit) do
+         local v = var
+         block
+         var = var + step
+       end
+     end
+

+Note the following: + +

+ +

+The generic for statement works over functions, +called iterators. +On each iteration, the iterator function is called to produce a new value, +stopping when this new value is nil. +The generic for loop has the following syntax: + +

+	stat ::= for namelist in explist do block end
+	namelist ::= Name {`,´ Name}
+

+A for statement like + +

+     for var_1, ···, var_n in explist do block end
+

+is equivalent to the code: + +

+     do
+       local f, s, var = explist
+       while true do
+         local var_1, ···, var_n = f(s, var)
+         var = var_1
+         if var == nil then break end
+         block
+       end
+     end
+

+Note the following: + +

+ + + + +

2.4.6 - Function Calls as Statements

+To allow possible side-effects, +function calls can be executed as statements: + +

+	stat ::= functioncall
+

+In this case, all returned values are thrown away. +Function calls are explained in §2.5.8. + + + + + +

2.4.7 - Local Declarations

+Local variables can be declared anywhere inside a block. +The declaration can include an initial assignment: + +

+	stat ::= local namelist [`=´ explist]
+

+If present, an initial assignment has the same semantics +of a multiple assignment (see §2.4.3). +Otherwise, all variables are initialized with nil. + + +

+A chunk is also a block (see §2.4.1), +and so local variables can be declared in a chunk outside any explicit block. +The scope of such local variables extends until the end of the chunk. + + +

+The visibility rules for local variables are explained in §2.6. + + + + + + + +

2.5 - Expressions

+ +

+The basic expressions in Lua are the following: + +

+	exp ::= prefixexp
+	exp ::= nil | false | true
+	exp ::= Number
+	exp ::= String
+	exp ::= function
+	exp ::= tableconstructor
+	exp ::= `...´
+	exp ::= exp binop exp
+	exp ::= unop exp
+	prefixexp ::= var | functioncall | `(´ exp `)´
+
+ +

+Numbers and literal strings are explained in §2.1; +variables are explained in §2.3; +function definitions are explained in §2.5.9; +function calls are explained in §2.5.8; +table constructors are explained in §2.5.7. +Vararg expressions, +denoted by three dots ('...'), can only be used when +directly inside a vararg function; +they are explained in §2.5.9. + + +

+Binary operators comprise arithmetic operators (see §2.5.1), +relational operators (see §2.5.2), logical operators (see §2.5.3), +and the concatenation operator (see §2.5.4). +Unary operators comprise the unary minus (see §2.5.1), +the unary not (see §2.5.3), +and the unary length operator (see §2.5.5). + + +

+Both function calls and vararg expressions can result in multiple values. +If an expression is used as a statement +(only possible for function calls (see §2.4.6)), +then its return list is adjusted to zero elements, +thus discarding all returned values. +If an expression is used as the last (or the only) element +of a list of expressions, +then no adjustment is made +(unless the call is enclosed in parentheses). +In all other contexts, +Lua adjusts the result list to one element, +discarding all values except the first one. + + +

+Here are some examples: + +

+     f()                -- adjusted to 0 results
+     g(f(), x)          -- f() is adjusted to 1 result
+     g(x, f())          -- g gets x plus all results from f()
+     a,b,c = f(), x     -- f() is adjusted to 1 result (c gets nil)
+     a,b = ...          -- a gets the first vararg parameter, b gets
+                        -- the second (both a and b can get nil if there
+                        -- is no corresponding vararg parameter)
+     
+     a,b,c = x, f()     -- f() is adjusted to 2 results
+     a,b,c = f()        -- f() is adjusted to 3 results
+     return f()         -- returns all results from f()
+     return ...         -- returns all received vararg parameters
+     return x,y,f()     -- returns x, y, and all results from f()
+     {f()}              -- creates a list with all results from f()
+     {...}              -- creates a list with all vararg parameters
+     {f(), nil}         -- f() is adjusted to 1 result
+
+ +

+Any expression enclosed in parentheses always results in only one value. +Thus, +(f(x,y,z)) is always a single value, +even if f returns several values. +(The value of (f(x,y,z)) is the first value returned by f +or nil if f does not return any values.) + + + +

2.5.1 - Arithmetic Operators

+Lua supports the usual arithmetic operators: +the binary + (addition), +- (subtraction), * (multiplication), +/ (division), % (modulo), and ^ (exponentiation); +and unary - (negation). +If the operands are numbers, or strings that can be converted to +numbers (see §2.2.1), +then all operations have the usual meaning. +Exponentiation works for any exponent. +For instance, x^(-0.5) computes the inverse of the square root of x. +Modulo is defined as + +

+     a % b == a - math.floor(a/b)*b
+

+That is, it is the remainder of a division that rounds +the quotient towards minus infinity. + + + + + +

2.5.2 - Relational Operators

+The relational operators in Lua are + +

+     ==    ~=    <     >     <=    >=
+

+These operators always result in false or true. + + +

+Equality (==) first compares the type of its operands. +If the types are different, then the result is false. +Otherwise, the values of the operands are compared. +Numbers and strings are compared in the usual way. +Objects (tables, userdata, threads, and functions) +are compared by reference: +two objects are considered equal only if they are the same object. +Every time you create a new object +(a table, userdata, thread, or function), +this new object is different from any previously existing object. + + +

+You can change the way that Lua compares tables and userdata +by using the "eq" metamethod (see §2.8). + + +

+The conversion rules of §2.2.1 +do not apply to equality comparisons. +Thus, "0"==0 evaluates to false, +and t[0] and t["0"] denote different +entries in a table. + + +

+The operator ~= is exactly the negation of equality (==). + + +

+The order operators work as follows. +If both arguments are numbers, then they are compared as such. +Otherwise, if both arguments are strings, +then their values are compared according to the current locale. +Otherwise, Lua tries to call the "lt" or the "le" +metamethod (see §2.8). +A comparison a > b is translated to b < a +and a >= b is translated to b <= a. + + + + + +

2.5.3 - Logical Operators

+The logical operators in Lua are +and, or, and not. +Like the control structures (see §2.4.4), +all logical operators consider both false and nil as false +and anything else as true. + + +

+The negation operator not always returns false or true. +The conjunction operator and returns its first argument +if this value is false or nil; +otherwise, and returns its second argument. +The disjunction operator or returns its first argument +if this value is different from nil and false; +otherwise, or returns its second argument. +Both and and or use short-cut evaluation; +that is, +the second operand is evaluated only if necessary. +Here are some examples: + +

+     10 or 20            --> 10
+     10 or error()       --> 10
+     nil or "a"          --> "a"
+     nil and 10          --> nil
+     false and error()   --> false
+     false and nil       --> false
+     false or nil        --> nil
+     10 and 20           --> 20
+

+(In this manual, +--> indicates the result of the preceding expression.) + + + + + +

2.5.4 - Concatenation

+The string concatenation operator in Lua is +denoted by two dots ('..'). +If both operands are strings or numbers, then they are converted to +strings according to the rules mentioned in §2.2.1. +Otherwise, the "concat" metamethod is called (see §2.8). + + + + + +

2.5.5 - The Length Operator

+ +

+The length operator is denoted by the unary operator #. +The length of a string is its number of bytes +(that is, the usual meaning of string length when each +character is one byte). + + +

+The length of a table t is defined to be any +integer index n +such that t[n] is not nil and t[n+1] is nil; +moreover, if t[1] is nil, n can be zero. +For a regular array, with non-nil values from 1 to a given n, +its length is exactly that n, +the index of its last value. +If the array has "holes" +(that is, nil values between other non-nil values), +then #t can be any of the indices that +directly precedes a nil value +(that is, it may consider any such nil value as the end of +the array). + + + + + +

2.5.6 - Precedence

+Operator precedence in Lua follows the table below, +from lower to higher priority: + +

+     or
+     and
+     <     >     <=    >=    ~=    ==
+     ..
+     +     -
+     *     /     %
+     not   #     - (unary)
+     ^
+

+As usual, +you can use parentheses to change the precedences of an expression. +The concatenation ('..') and exponentiation ('^') +operators are right associative. +All other binary operators are left associative. + + + + + +

2.5.7 - Table Constructors

+Table constructors are expressions that create tables. +Every time a constructor is evaluated, a new table is created. +A constructor can be used to create an empty table +or to create a table and initialize some of its fields. +The general syntax for constructors is + +

+	tableconstructor ::= `{´ [fieldlist] `}´
+	fieldlist ::= field {fieldsep field} [fieldsep]
+	field ::= `[´ exp `]´ `=´ exp | Name `=´ exp | exp
+	fieldsep ::= `,´ | `;´
+
+ +

+Each field of the form [exp1] = exp2 adds to the new table an entry +with key exp1 and value exp2. +A field of the form name = exp is equivalent to +["name"] = exp. +Finally, fields of the form exp are equivalent to +[i] = exp, where i are consecutive numerical integers, +starting with 1. +Fields in the other formats do not affect this counting. +For example, + +

+     a = { [f(1)] = g; "x", "y"; x = 1, f(x), [30] = 23; 45 }
+

+is equivalent to + +

+     do
+       local t = {}
+       t[f(1)] = g
+       t[1] = "x"         -- 1st exp
+       t[2] = "y"         -- 2nd exp
+       t.x = 1            -- t["x"] = 1
+       t[3] = f(x)        -- 3rd exp
+       t[30] = 23
+       t[4] = 45          -- 4th exp
+       a = t
+     end
+
+ +

+If the last field in the list has the form exp +and the expression is a function call or a vararg expression, +then all values returned by this expression enter the list consecutively +(see §2.5.8). +To avoid this, +enclose the function call or the vararg expression +in parentheses (see §2.5). + + +

+The field list can have an optional trailing separator, +as a convenience for machine-generated code. + + + + + +

2.5.8 - Function Calls

+A function call in Lua has the following syntax: + +

+	functioncall ::= prefixexp args
+

+In a function call, +first prefixexp and args are evaluated. +If the value of prefixexp has type function, +then this function is called +with the given arguments. +Otherwise, the prefixexp "call" metamethod is called, +having as first parameter the value of prefixexp, +followed by the original call arguments +(see §2.8). + + +

+The form + +

+	functioncall ::= prefixexp `:´ Name args
+

+can be used to call "methods". +A call v:name(args) +is syntactic sugar for v.name(v,args), +except that v is evaluated only once. + + +

+Arguments have the following syntax: + +

+	args ::= `(´ [explist] `)´
+	args ::= tableconstructor
+	args ::= String
+

+All argument expressions are evaluated before the call. +A call of the form f{fields} is +syntactic sugar for f({fields}); +that is, the argument list is a single new table. +A call of the form f'string' +(or f"string" or f[[string]]) +is syntactic sugar for f('string'); +that is, the argument list is a single literal string. + + +

+As an exception to the free-format syntax of Lua, +you cannot put a line break before the '(' in a function call. +This restriction avoids some ambiguities in the language. +If you write + +

+     a = f
+     (g).x(a)
+

+Lua would see that as a single statement, a = f(g).x(a). +So, if you want two statements, you must add a semi-colon between them. +If you actually want to call f, +you must remove the line break before (g). + + +

+A call of the form return functioncall is called +a tail call. +Lua implements proper tail calls +(or proper tail recursion): +in a tail call, +the called function reuses the stack entry of the calling function. +Therefore, there is no limit on the number of nested tail calls that +a program can execute. +However, a tail call erases any debug information about the +calling function. +Note that a tail call only happens with a particular syntax, +where the return has one single function call as argument; +this syntax makes the calling function return exactly +the returns of the called function. +So, none of the following examples are tail calls: + +

+     return (f(x))        -- results adjusted to 1
+     return 2 * f(x)
+     return x, f(x)       -- additional results
+     f(x); return         -- results discarded
+     return x or f(x)     -- results adjusted to 1
+
+ + + + +

2.5.9 - Function Definitions

+ +

+The syntax for function definition is + +

+	function ::= function funcbody
+	funcbody ::= `(´ [parlist] `)´ block end
+
+ +

+The following syntactic sugar simplifies function definitions: + +

+	stat ::= function funcname funcbody
+	stat ::= local function Name funcbody
+	funcname ::= Name {`.´ Name} [`:´ Name]
+

+The statement + +

+     function f () body end
+

+translates to + +

+     f = function () body end
+

+The statement + +

+     function t.a.b.c.f () body end
+

+translates to + +

+     t.a.b.c.f = function () body end
+

+The statement + +

+     local function f () body end
+

+translates to + +

+     local f; f = function () body end
+

+not to + +

+     local f = function () body end
+

+(This only makes a difference when the body of the function +contains references to f.) + + +

+A function definition is an executable expression, +whose value has type function. +When Lua pre-compiles a chunk, +all its function bodies are pre-compiled too. +Then, whenever Lua executes the function definition, +the function is instantiated (or closed). +This function instance (or closure) +is the final value of the expression. +Different instances of the same function +can refer to different external local variables +and can have different environment tables. + + +

+Parameters act as local variables that are +initialized with the argument values: + +

+	parlist ::= namelist [`,´ `...´] | `...´
+

+When a function is called, +the list of arguments is adjusted to +the length of the list of parameters, +unless the function is a variadic or vararg function, +which is +indicated by three dots ('...') at the end of its parameter list. +A vararg function does not adjust its argument list; +instead, it collects all extra arguments and supplies them +to the function through a vararg expression, +which is also written as three dots. +The value of this expression is a list of all actual extra arguments, +similar to a function with multiple results. +If a vararg expression is used inside another expression +or in the middle of a list of expressions, +then its return list is adjusted to one element. +If the expression is used as the last element of a list of expressions, +then no adjustment is made +(unless that last expression is enclosed in parentheses). + + +

+As an example, consider the following definitions: + +

+     function f(a, b) end
+     function g(a, b, ...) end
+     function r() return 1,2,3 end
+

+Then, we have the following mapping from arguments to parameters and +to the vararg expression: + +

+     CALL            PARAMETERS
+     
+     f(3)             a=3, b=nil
+     f(3, 4)          a=3, b=4
+     f(3, 4, 5)       a=3, b=4
+     f(r(), 10)       a=1, b=10
+     f(r())           a=1, b=2
+     
+     g(3)             a=3, b=nil, ... -->  (nothing)
+     g(3, 4)          a=3, b=4,   ... -->  (nothing)
+     g(3, 4, 5, 8)    a=3, b=4,   ... -->  5  8
+     g(5, r())        a=5, b=1,   ... -->  2  3
+
+ +

+Results are returned using the return statement (see §2.4.4). +If control reaches the end of a function +without encountering a return statement, +then the function returns with no results. + + +

+The colon syntax +is used for defining methods, +that is, functions that have an implicit extra parameter self. +Thus, the statement + +

+     function t.a.b.c:f (params) body end
+

+is syntactic sugar for + +

+     t.a.b.c.f = function (self, params) body end
+
+ + + + + + +

2.6 - Visibility Rules

+ +

+ +Lua is a lexically scoped language. +The scope of variables begins at the first statement after +their declaration and lasts until the end of the innermost block that +includes the declaration. +Consider the following example: + +

+     x = 10                -- global variable
+     do                    -- new block
+       local x = x         -- new 'x', with value 10
+       print(x)            --> 10
+       x = x+1
+       do                  -- another block
+         local x = x+1     -- another 'x'
+         print(x)          --> 12
+       end
+       print(x)            --> 11
+     end
+     print(x)              --> 10  (the global one)
+
+ +

+Notice that, in a declaration like local x = x, +the new x being declared is not in scope yet, +and so the second x refers to the outside variable. + + +

+Because of the lexical scoping rules, +local variables can be freely accessed by functions +defined inside their scope. +A local variable used by an inner function is called +an upvalue, or external local variable, +inside the inner function. + + +

+Notice that each execution of a local statement +defines new local variables. +Consider the following example: + +

+     a = {}
+     local x = 20
+     for i=1,10 do
+       local y = 0
+       a[i] = function () y=y+1; return x+y end
+     end
+

+The loop creates ten closures +(that is, ten instances of the anonymous function). +Each of these closures uses a different y variable, +while all of them share the same x. + + + + + +

2.7 - Error Handling

+ +

+Because Lua is an embedded extension language, +all Lua actions start from C code in the host program +calling a function from the Lua library (see lua_pcall). +Whenever an error occurs during Lua compilation or execution, +control returns to C, +which can take appropriate measures +(such as printing an error message). + + +

+Lua code can explicitly generate an error by calling the +error function. +If you need to catch errors in Lua, +you can use the pcall function. + + + + + +

2.8 - Metatables

+ +

+Every value in Lua can have a metatable. +This metatable is an ordinary Lua table +that defines the behavior of the original value +under certain special operations. +You can change several aspects of the behavior +of operations over a value by setting specific fields in its metatable. +For instance, when a non-numeric value is the operand of an addition, +Lua checks for a function in the field "__add" in its metatable. +If it finds one, +Lua calls this function to perform the addition. + + +

+We call the keys in a metatable events +and the values metamethods. +In the previous example, the event is "add" +and the metamethod is the function that performs the addition. + + +

+You can query the metatable of any value +through the getmetatable function. + + +

+You can replace the metatable of tables +through the setmetatable +function. +You cannot change the metatable of other types from Lua +(except by using the debug library); +you must use the C API for that. + + +

+Tables and full userdata have individual metatables +(although multiple tables and userdata can share their metatables). +Values of all other types share one single metatable per type; +that is, there is one single metatable for all numbers, +one for all strings, etc. + + +

+A metatable controls how an object behaves in arithmetic operations, +order comparisons, concatenation, length operation, and indexing. +A metatable also can define a function to be called when a userdata +is garbage collected. +For each of these operations Lua associates a specific key +called an event. +When Lua performs one of these operations over a value, +it checks whether this value has a metatable with the corresponding event. +If so, the value associated with that key (the metamethod) +controls how Lua will perform the operation. + + +

+Metatables control the operations listed next. +Each operation is identified by its corresponding name. +The key for each operation is a string with its name prefixed by +two underscores, '__'; +for instance, the key for operation "add" is the +string "__add". +The semantics of these operations is better explained by a Lua function +describing how the interpreter executes the operation. + + +

+The code shown here in Lua is only illustrative; +the real behavior is hard coded in the interpreter +and it is much more efficient than this simulation. +All functions used in these descriptions +(rawget, tonumber, etc.) +are described in §5.1. +In particular, to retrieve the metamethod of a given object, +we use the expression + +

+     metatable(obj)[event]
+

+This should be read as + +

+     rawget(getmetatable(obj) or {}, event)
+

+ +That is, the access to a metamethod does not invoke other metamethods, +and the access to objects with no metatables does not fail +(it simply results in nil). + + + +

+ + + + +

2.9 - Environments

+ +

+Besides metatables, +objects of types thread, function, and userdata +have another table associated with them, +called their environment. +Like metatables, environments are regular tables and +multiple objects can share the same environment. + + +

+Threads are created sharing the environment of the creating thread. +Userdata and C functions are created sharing the environment +of the creating C function. +Non-nested Lua functions +(created by loadfile, loadstring or load) +are created sharing the environment of the creating thread. +Nested Lua functions are created sharing the environment of +the creating Lua function. + + +

+Environments associated with userdata have no meaning for Lua. +It is only a convenience feature for programmers to associate a table to +a userdata. + + +

+Environments associated with threads are called +global environments. +They are used as the default environment for threads and +non-nested Lua functions created by the thread +and can be directly accessed by C code (see §3.3). + + +

+The environment associated with a C function can be directly +accessed by C code (see §3.3). +It is used as the default environment for other C functions +and userdata created by the function. + + +

+Environments associated with Lua functions are used to resolve +all accesses to global variables within the function (see §2.3). +They are used as the default environment for nested Lua functions +created by the function. + + +

+You can change the environment of a Lua function or the +running thread by calling setfenv. +You can get the environment of a Lua function or the running thread +by calling getfenv. +To manipulate the environment of other objects +(userdata, C functions, other threads) you must +use the C API. + + + + + +

2.10 - Garbage Collection

+ +

+Lua performs automatic memory management. +This means that +you have to worry neither about allocating memory for new objects +nor about freeing it when the objects are no longer needed. +Lua manages memory automatically by running +a garbage collector from time to time +to collect all dead objects +(that is, objects that are no longer accessible from Lua). +All memory used by Lua is subject to automatic management: +tables, userdata, functions, threads, strings, etc. + + +

+Lua implements an incremental mark-and-sweep collector. +It uses two numbers to control its garbage-collection cycles: +the garbage-collector pause and +the garbage-collector step multiplier. +Both use percentage points as units +(so that a value of 100 means an internal value of 1). + + +

+The garbage-collector pause +controls how long the collector waits before starting a new cycle. +Larger values make the collector less aggressive. +Values smaller than 100 mean the collector will not wait to +start a new cycle. +A value of 200 means that the collector waits for the total memory in use +to double before starting a new cycle. + + +

+The step multiplier +controls the relative speed of the collector relative to +memory allocation. +Larger values make the collector more aggressive but also increase +the size of each incremental step. +Values smaller than 100 make the collector too slow and +can result in the collector never finishing a cycle. +The default, 200, means that the collector runs at "twice" +the speed of memory allocation. + + +

+You can change these numbers by calling lua_gc in C +or collectgarbage in Lua. +With these functions you can also control +the collector directly (e.g., stop and restart it). + + + +

2.10.1 - Garbage-Collection Metamethods

+ +

+Using the C API, +you can set garbage-collector metamethods for userdata (see §2.8). +These metamethods are also called finalizers. +Finalizers allow you to coordinate Lua's garbage collection +with external resource management +(such as closing files, network or database connections, +or freeing your own memory). + + +

+Garbage userdata with a field __gc in their metatables are not +collected immediately by the garbage collector. +Instead, Lua puts them in a list. +After the collection, +Lua does the equivalent of the following function +for each userdata in that list: + +

+     function gc_event (udata)
+       local h = metatable(udata).__gc
+       if h then
+         h(udata)
+       end
+     end
+
+ +

+At the end of each garbage-collection cycle, +the finalizers for userdata are called in reverse +order of their creation, +among those collected in that cycle. +That is, the first finalizer to be called is the one associated +with the userdata created last in the program. +The userdata itself is freed only in the next garbage-collection cycle. + + + + + +

2.10.2 - Weak Tables

+ +

+A weak table is a table whose elements are +weak references. +A weak reference is ignored by the garbage collector. +In other words, +if the only references to an object are weak references, +then the garbage collector will collect this object. + + +

+A weak table can have weak keys, weak values, or both. +A table with weak keys allows the collection of its keys, +but prevents the collection of its values. +A table with both weak keys and weak values allows the collection of +both keys and values. +In any case, if either the key or the value is collected, +the whole pair is removed from the table. +The weakness of a table is controlled by the +__mode field of its metatable. +If the __mode field is a string containing the character 'k', +the keys in the table are weak. +If __mode contains 'v', +the values in the table are weak. + + +

+After you use a table as a metatable, +you should not change the value of its __mode field. +Otherwise, the weak behavior of the tables controlled by this +metatable is undefined. + + + + + + + +

2.11 - Coroutines

+ +

+Lua supports coroutines, +also called collaborative multithreading. +A coroutine in Lua represents an independent thread of execution. +Unlike threads in multithread systems, however, +a coroutine only suspends its execution by explicitly calling +a yield function. + + +

+You create a coroutine with a call to coroutine.create. +Its sole argument is a function +that is the main function of the coroutine. +The create function only creates a new coroutine and +returns a handle to it (an object of type thread); +it does not start the coroutine execution. + + +

+When you first call coroutine.resume, +passing as its first argument +a thread returned by coroutine.create, +the coroutine starts its execution, +at the first line of its main function. +Extra arguments passed to coroutine.resume are passed on +to the coroutine main function. +After the coroutine starts running, +it runs until it terminates or yields. + + +

+A coroutine can terminate its execution in two ways: +normally, when its main function returns +(explicitly or implicitly, after the last instruction); +and abnormally, if there is an unprotected error. +In the first case, coroutine.resume returns true, +plus any values returned by the coroutine main function. +In case of errors, coroutine.resume returns false +plus an error message. + + +

+A coroutine yields by calling coroutine.yield. +When a coroutine yields, +the corresponding coroutine.resume returns immediately, +even if the yield happens inside nested function calls +(that is, not in the main function, +but in a function directly or indirectly called by the main function). +In the case of a yield, coroutine.resume also returns true, +plus any values passed to coroutine.yield. +The next time you resume the same coroutine, +it continues its execution from the point where it yielded, +with the call to coroutine.yield returning any extra +arguments passed to coroutine.resume. + + +

+Like coroutine.create, +the coroutine.wrap function also creates a coroutine, +but instead of returning the coroutine itself, +it returns a function that, when called, resumes the coroutine. +Any arguments passed to this function +go as extra arguments to coroutine.resume. +coroutine.wrap returns all the values returned by coroutine.resume, +except the first one (the boolean error code). +Unlike coroutine.resume, +coroutine.wrap does not catch errors; +any error is propagated to the caller. + + +

+As an example, +consider the following code: + +

+     function foo (a)
+       print("foo", a)
+       return coroutine.yield(2*a)
+     end
+     
+     co = coroutine.create(function (a,b)
+           print("co-body", a, b)
+           local r = foo(a+1)
+           print("co-body", r)
+           local r, s = coroutine.yield(a+b, a-b)
+           print("co-body", r, s)
+           return b, "end"
+     end)
+            
+     print("main", coroutine.resume(co, 1, 10))
+     print("main", coroutine.resume(co, "r"))
+     print("main", coroutine.resume(co, "x", "y"))
+     print("main", coroutine.resume(co, "x", "y"))
+

+When you run it, it produces the following output: + +

+     co-body 1       10
+     foo     2
+     
+     main    true    4
+     co-body r
+     main    true    11      -9
+     co-body x       y
+     main    true    10      end
+     main    false   cannot resume dead coroutine
+
+ + + + +

3 - The Application Program Interface

+ +

+ +This section describes the C API for Lua, that is, +the set of C functions available to the host program to communicate +with Lua. +All API functions and related types and constants +are declared in the header file lua.h. + + +

+Even when we use the term "function", +any facility in the API may be provided as a macro instead. +All such macros use each of their arguments exactly once +(except for the first argument, which is always a Lua state), +and so do not generate any hidden side-effects. + + +

+As in most C libraries, +the Lua API functions do not check their arguments for validity or consistency. +However, you can change this behavior by compiling Lua +with a proper definition for the macro luai_apicheck, +in file luaconf.h. + + + +

3.1 - The Stack

+ +

+Lua uses a virtual stack to pass values to and from C. +Each element in this stack represents a Lua value +(nil, number, string, etc.). + + +

+Whenever Lua calls C, the called function gets a new stack, +which is independent of previous stacks and of stacks of +C functions that are still active. +This stack initially contains any arguments to the C function +and it is where the C function pushes its results +to be returned to the caller (see lua_CFunction). + + +

+For convenience, +most query operations in the API do not follow a strict stack discipline. +Instead, they can refer to any element in the stack +by using an index: +A positive index represents an absolute stack position +(starting at 1); +a negative index represents an offset relative to the top of the stack. +More specifically, if the stack has n elements, +then index 1 represents the first element +(that is, the element that was pushed onto the stack first) +and +index n represents the last element; +index -1 also represents the last element +(that is, the element at the top) +and index -n represents the first element. +We say that an index is valid +if it lies between 1 and the stack top +(that is, if 1 ≤ abs(index) ≤ top). + + + + + + +

3.2 - Stack Size

+ +

+When you interact with Lua API, +you are responsible for ensuring consistency. +In particular, +you are responsible for controlling stack overflow. +You can use the function lua_checkstack +to grow the stack size. + + +

+Whenever Lua calls C, +it ensures that at least LUA_MINSTACK stack positions are available. +LUA_MINSTACK is defined as 20, +so that usually you do not have to worry about stack space +unless your code has loops pushing elements onto the stack. + + +

+Most query functions accept as indices any value inside the +available stack space, that is, indices up to the maximum stack size +you have set through lua_checkstack. +Such indices are called acceptable indices. +More formally, we define an acceptable index +as follows: + +

+     (index < 0 && abs(index) <= top) ||
+     (index > 0 && index <= stackspace)
+

+Note that 0 is never an acceptable index. + + + + + +

3.3 - Pseudo-Indices

+ +

+Unless otherwise noted, +any function that accepts valid indices can also be called with +pseudo-indices, +which represent some Lua values that are accessible to C code +but which are not in the stack. +Pseudo-indices are used to access the thread environment, +the function environment, +the registry, +and the upvalues of a C function (see §3.4). + + +

+The thread environment (where global variables live) is +always at pseudo-index LUA_GLOBALSINDEX. +The environment of the running C function is always +at pseudo-index LUA_ENVIRONINDEX. + + +

+To access and change the value of global variables, +you can use regular table operations over an environment table. +For instance, to access the value of a global variable, do + +

+     lua_getfield(L, LUA_GLOBALSINDEX, varname);
+
+ + + + +

3.4 - C Closures

+ +

+When a C function is created, +it is possible to associate some values with it, +thus creating a C closure; +these values are called upvalues and are +accessible to the function whenever it is called +(see lua_pushcclosure). + + +

+Whenever a C function is called, +its upvalues are located at specific pseudo-indices. +These pseudo-indices are produced by the macro +lua_upvalueindex. +The first value associated with a function is at position +lua_upvalueindex(1), and so on. +Any access to lua_upvalueindex(n), +where n is greater than the number of upvalues of the +current function (but not greater than 256), +produces an acceptable (but invalid) index. + + + + + +

3.5 - Registry

+ +

+Lua provides a registry, +a pre-defined table that can be used by any C code to +store whatever Lua value it needs to store. +This table is always located at pseudo-index +LUA_REGISTRYINDEX. +Any C library can store data into this table, +but it should take care to choose keys different from those used +by other libraries, to avoid collisions. +Typically, you should use as key a string containing your library name +or a light userdata with the address of a C object in your code. + + +

+The integer keys in the registry are used by the reference mechanism, +implemented by the auxiliary library, +and therefore should not be used for other purposes. + + + + + +

3.6 - Error Handling in C

+ +

+Internally, Lua uses the C longjmp facility to handle errors. +(You can also choose to use exceptions if you use C++; +see file luaconf.h.) +When Lua faces any error +(such as memory allocation errors, type errors, syntax errors, +and runtime errors) +it raises an error; +that is, it does a long jump. +A protected environment uses setjmp +to set a recover point; +any error jumps to the most recent active recover point. + + +

+Most functions in the API can throw an error, +for instance due to a memory allocation error. +The documentation for each function indicates whether +it can throw errors. + + +

+Inside a C function you can throw an error by calling lua_error. + + + + + +

3.7 - Functions and Types

+ +

+Here we list all functions and types from the C API in +alphabetical order. +Each function has an indicator like this: +[-o, +p, x] + + +

+The first field, o, +is how many elements the function pops from the stack. +The second field, p, +is how many elements the function pushes onto the stack. +(Any function always pushes its results after popping its arguments.) +A field in the form x|y means the function can push (or pop) +x or y elements, +depending on the situation; +an interrogation mark '?' means that +we cannot know how many elements the function pops/pushes +by looking only at its arguments +(e.g., they may depend on what is on the stack). +The third field, x, +tells whether the function may throw errors: +'-' means the function never throws any error; +'m' means the function may throw an error +only due to not enough memory; +'e' means the function may throw other kinds of errors; +'v' means the function may throw an error on purpose. + + + +


lua_Alloc

+
typedef void * (*lua_Alloc) (void *ud,
+                             void *ptr,
+                             size_t osize,
+                             size_t nsize);
+ +

+The type of the memory-allocation function used by Lua states. +The allocator function must provide a +functionality similar to realloc, +but not exactly the same. +Its arguments are +ud, an opaque pointer passed to lua_newstate; +ptr, a pointer to the block being allocated/reallocated/freed; +osize, the original size of the block; +nsize, the new size of the block. +ptr is NULL if and only if osize is zero. +When nsize is zero, the allocator must return NULL; +if osize is not zero, +it should free the block pointed to by ptr. +When nsize is not zero, the allocator returns NULL +if and only if it cannot fill the request. +When nsize is not zero and osize is zero, +the allocator should behave like malloc. +When nsize and osize are not zero, +the allocator behaves like realloc. +Lua assumes that the allocator never fails when +osize >= nsize. + + +

+Here is a simple implementation for the allocator function. +It is used in the auxiliary library by luaL_newstate. + +

+     static void *l_alloc (void *ud, void *ptr, size_t osize,
+                                                size_t nsize) {
+       (void)ud;  (void)osize;  /* not used */
+       if (nsize == 0) {
+         free(ptr);
+         return NULL;
+       }
+       else
+         return realloc(ptr, nsize);
+     }
+

+This code assumes +that free(NULL) has no effect and that +realloc(NULL, size) is equivalent to malloc(size). +ANSI C ensures both behaviors. + + + + + +


lua_atpanic

+[-0, +0, -] +

lua_CFunction lua_atpanic (lua_State *L, lua_CFunction panicf);
+ +

+Sets a new panic function and returns the old one. + + +

+If an error happens outside any protected environment, +Lua calls a panic function +and then calls exit(EXIT_FAILURE), +thus exiting the host application. +Your panic function can avoid this exit by +never returning (e.g., doing a long jump). + + +

+The panic function can access the error message at the top of the stack. + + + + + +


lua_call

+[-(nargs + 1), +nresults, e] +

void lua_call (lua_State *L, int nargs, int nresults);
+ +

+Calls a function. + + +

+To call a function you must use the following protocol: +first, the function to be called is pushed onto the stack; +then, the arguments to the function are pushed +in direct order; +that is, the first argument is pushed first. +Finally you call lua_call; +nargs is the number of arguments that you pushed onto the stack. +All arguments and the function value are popped from the stack +when the function is called. +The function results are pushed onto the stack when the function returns. +The number of results is adjusted to nresults, +unless nresults is LUA_MULTRET. +In this case, all results from the function are pushed. +Lua takes care that the returned values fit into the stack space. +The function results are pushed onto the stack in direct order +(the first result is pushed first), +so that after the call the last result is on the top of the stack. + + +

+Any error inside the called function is propagated upwards +(with a longjmp). + + +

+The following example shows how the host program can do the +equivalent to this Lua code: + +

+     a = f("how", t.x, 14)
+

+Here it is in C: + +

+     lua_getfield(L, LUA_GLOBALSINDEX, "f"); /* function to be called */
+     lua_pushstring(L, "how");                        /* 1st argument */
+     lua_getfield(L, LUA_GLOBALSINDEX, "t");   /* table to be indexed */
+     lua_getfield(L, -1, "x");        /* push result of t.x (2nd arg) */
+     lua_remove(L, -2);                  /* remove 't' from the stack */
+     lua_pushinteger(L, 14);                          /* 3rd argument */
+     lua_call(L, 3, 1);     /* call 'f' with 3 arguments and 1 result */
+     lua_setfield(L, LUA_GLOBALSINDEX, "a");        /* set global 'a' */
+

+Note that the code above is "balanced": +at its end, the stack is back to its original configuration. +This is considered good programming practice. + + + + + +


lua_CFunction

+
typedef int (*lua_CFunction) (lua_State *L);
+ +

+Type for C functions. + + +

+In order to communicate properly with Lua, +a C function must use the following protocol, +which defines the way parameters and results are passed: +a C function receives its arguments from Lua in its stack +in direct order (the first argument is pushed first). +So, when the function starts, +lua_gettop(L) returns the number of arguments received by the function. +The first argument (if any) is at index 1 +and its last argument is at index lua_gettop(L). +To return values to Lua, a C function just pushes them onto the stack, +in direct order (the first result is pushed first), +and returns the number of results. +Any other value in the stack below the results will be properly +discarded by Lua. +Like a Lua function, a C function called by Lua can also return +many results. + + +

+As an example, the following function receives a variable number +of numerical arguments and returns their average and sum: + +

+     static int foo (lua_State *L) {
+       int n = lua_gettop(L);    /* number of arguments */
+       lua_Number sum = 0;
+       int i;
+       for (i = 1; i <= n; i++) {
+         if (!lua_isnumber(L, i)) {
+           lua_pushstring(L, "incorrect argument");
+           lua_error(L);
+         }
+         sum += lua_tonumber(L, i);
+       }
+       lua_pushnumber(L, sum/n);        /* first result */
+       lua_pushnumber(L, sum);         /* second result */
+       return 2;                   /* number of results */
+     }
+
+ + + + +

lua_checkstack

+[-0, +0, m] +

int lua_checkstack (lua_State *L, int extra);
+ +

+Ensures that there are at least extra free stack slots in the stack. +It returns false if it cannot grow the stack to that size. +This function never shrinks the stack; +if the stack is already larger than the new size, +it is left unchanged. + + + + + +


lua_close

+[-0, +0, -] +

void lua_close (lua_State *L);
+ +

+Destroys all objects in the given Lua state +(calling the corresponding garbage-collection metamethods, if any) +and frees all dynamic memory used by this state. +On several platforms, you may not need to call this function, +because all resources are naturally released when the host program ends. +On the other hand, long-running programs, +such as a daemon or a web server, +might need to release states as soon as they are not needed, +to avoid growing too large. + + + + + +


lua_concat

+[-n, +1, e] +

void lua_concat (lua_State *L, int n);
+ +

+Concatenates the n values at the top of the stack, +pops them, and leaves the result at the top. +If n is 1, the result is the single value on the stack +(that is, the function does nothing); +if n is 0, the result is the empty string. +Concatenation is performed following the usual semantics of Lua +(see §2.5.4). + + + + + +


lua_cpcall

+[-0, +(0|1), -] +

int lua_cpcall (lua_State *L, lua_CFunction func, void *ud);
+ +

+Calls the C function func in protected mode. +func starts with only one element in its stack, +a light userdata containing ud. +In case of errors, +lua_cpcall returns the same error codes as lua_pcall, +plus the error object on the top of the stack; +otherwise, it returns zero, and does not change the stack. +All values returned by func are discarded. + + + + + +


lua_createtable

+[-0, +1, m] +

void lua_createtable (lua_State *L, int narr, int nrec);
+ +

+Creates a new empty table and pushes it onto the stack. +The new table has space pre-allocated +for narr array elements and nrec non-array elements. +This pre-allocation is useful when you know exactly how many elements +the table will have. +Otherwise you can use the function lua_newtable. + + + + + +


lua_dump

+[-0, +0, m] +

int lua_dump (lua_State *L, lua_Writer writer, void *data);
+ +

+Dumps a function as a binary chunk. +Receives a Lua function on the top of the stack +and produces a binary chunk that, +if loaded again, +results in a function equivalent to the one dumped. +As it produces parts of the chunk, +lua_dump calls function writer (see lua_Writer) +with the given data +to write them. + + +

+The value returned is the error code returned by the last +call to the writer; +0 means no errors. + + +

+This function does not pop the Lua function from the stack. + + + + + +


lua_equal

+[-0, +0, e] +

int lua_equal (lua_State *L, int index1, int index2);
+ +

+Returns 1 if the two values in acceptable indices index1 and +index2 are equal, +following the semantics of the Lua == operator +(that is, may call metamethods). +Otherwise returns 0. +Also returns 0 if any of the indices is non valid. + + + + + +


lua_error

+[-1, +0, v] +

int lua_error (lua_State *L);
+ +

+Generates a Lua error. +The error message (which can actually be a Lua value of any type) +must be on the stack top. +This function does a long jump, +and therefore never returns. +(see luaL_error). + + + + + +


lua_gc

+[-0, +0, e] +

int lua_gc (lua_State *L, int what, int data);
+ +

+Controls the garbage collector. + + +

+This function performs several tasks, +according to the value of the parameter what: + +

+ + + + +

lua_getallocf

+[-0, +0, -] +

lua_Alloc lua_getallocf (lua_State *L, void **ud);
+ +

+Returns the memory-allocation function of a given state. +If ud is not NULL, Lua stores in *ud the +opaque pointer passed to lua_newstate. + + + + + +


lua_getfenv

+[-0, +1, -] +

void lua_getfenv (lua_State *L, int index);
+ +

+Pushes onto the stack the environment table of +the value at the given index. + + + + + +


lua_getfield

+[-0, +1, e] +

void lua_getfield (lua_State *L, int index, const char *k);
+ +

+Pushes onto the stack the value t[k], +where t is the value at the given valid index. +As in Lua, this function may trigger a metamethod +for the "index" event (see §2.8). + + + + + +


lua_getglobal

+[-0, +1, e] +

void lua_getglobal (lua_State *L, const char *name);
+ +

+Pushes onto the stack the value of the global name. +It is defined as a macro: + +

+     #define lua_getglobal(L,s)  lua_getfield(L, LUA_GLOBALSINDEX, s)
+
+ + + + +

lua_getmetatable

+[-0, +(0|1), -] +

int lua_getmetatable (lua_State *L, int index);
+ +

+Pushes onto the stack the metatable of the value at the given +acceptable index. +If the index is not valid, +or if the value does not have a metatable, +the function returns 0 and pushes nothing on the stack. + + + + + +


lua_gettable

+[-1, +1, e] +

void lua_gettable (lua_State *L, int index);
+ +

+Pushes onto the stack the value t[k], +where t is the value at the given valid index +and k is the value at the top of the stack. + + +

+This function pops the key from the stack +(putting the resulting value in its place). +As in Lua, this function may trigger a metamethod +for the "index" event (see §2.8). + + + + + +


lua_gettop

+[-0, +0, -] +

int lua_gettop (lua_State *L);
+ +

+Returns the index of the top element in the stack. +Because indices start at 1, +this result is equal to the number of elements in the stack +(and so 0 means an empty stack). + + + + + +


lua_insert

+[-1, +1, -] +

void lua_insert (lua_State *L, int index);
+ +

+Moves the top element into the given valid index, +shifting up the elements above this index to open space. +Cannot be called with a pseudo-index, +because a pseudo-index is not an actual stack position. + + + + + +


lua_Integer

+
typedef ptrdiff_t lua_Integer;
+ +

+The type used by the Lua API to represent integral values. + + +

+By default it is a ptrdiff_t, +which is usually the largest signed integral type the machine handles +"comfortably". + + + + + +


lua_isboolean

+[-0, +0, -] +

int lua_isboolean (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index has type boolean, +and 0 otherwise. + + + + + +


lua_iscfunction

+[-0, +0, -] +

int lua_iscfunction (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a C function, +and 0 otherwise. + + + + + +


lua_isfunction

+[-0, +0, -] +

int lua_isfunction (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a function +(either C or Lua), and 0 otherwise. + + + + + +


lua_islightuserdata

+[-0, +0, -] +

int lua_islightuserdata (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a light userdata, +and 0 otherwise. + + + + + +


lua_isnil

+[-0, +0, -] +

int lua_isnil (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is nil, +and 0 otherwise. + + + + + +


lua_isnone

+[-0, +0, -] +

int lua_isnone (lua_State *L, int index);
+ +

+Returns 1 if the given acceptable index is not valid +(that is, it refers to an element outside the current stack), +and 0 otherwise. + + + + + +


lua_isnoneornil

+[-0, +0, -] +

int lua_isnoneornil (lua_State *L, int index);
+ +

+Returns 1 if the given acceptable index is not valid +(that is, it refers to an element outside the current stack) +or if the value at this index is nil, +and 0 otherwise. + + + + + +


lua_isnumber

+[-0, +0, -] +

int lua_isnumber (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a number +or a string convertible to a number, +and 0 otherwise. + + + + + +


lua_isstring

+[-0, +0, -] +

int lua_isstring (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a string +or a number (which is always convertible to a string), +and 0 otherwise. + + + + + +


lua_istable

+[-0, +0, -] +

int lua_istable (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a table, +and 0 otherwise. + + + + + +


lua_isthread

+[-0, +0, -] +

int lua_isthread (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a thread, +and 0 otherwise. + + + + + +


lua_isuserdata

+[-0, +0, -] +

int lua_isuserdata (lua_State *L, int index);
+ +

+Returns 1 if the value at the given acceptable index is a userdata +(either full or light), and 0 otherwise. + + + + + +


lua_lessthan

+[-0, +0, e] +

int lua_lessthan (lua_State *L, int index1, int index2);
+ +

+Returns 1 if the value at acceptable index index1 is smaller +than the value at acceptable index index2, +following the semantics of the Lua < operator +(that is, may call metamethods). +Otherwise returns 0. +Also returns 0 if any of the indices is non valid. + + + + + +


lua_load

+[-0, +1, -] +

int lua_load (lua_State *L,
+              lua_Reader reader,
+              void *data,
+              const char *chunkname);
+ +

+Loads a Lua chunk. +If there are no errors, +lua_load pushes the compiled chunk as a Lua +function on top of the stack. +Otherwise, it pushes an error message. +The return values of lua_load are: + +

+ +

+This function only loads a chunk; +it does not run it. + + +

+lua_load automatically detects whether the chunk is text or binary, +and loads it accordingly (see program luac). + + +

+The lua_load function uses a user-supplied reader function +to read the chunk (see lua_Reader). +The data argument is an opaque value passed to the reader function. + + +

+The chunkname argument gives a name to the chunk, +which is used for error messages and in debug information (see §3.8). + + + + + +


lua_newstate

+[-0, +0, -] +

lua_State *lua_newstate (lua_Alloc f, void *ud);
+ +

+Creates a new, independent state. +Returns NULL if cannot create the state +(due to lack of memory). +The argument f is the allocator function; +Lua does all memory allocation for this state through this function. +The second argument, ud, is an opaque pointer that Lua +simply passes to the allocator in every call. + + + + + +


lua_newtable

+[-0, +1, m] +

void lua_newtable (lua_State *L);
+ +

+Creates a new empty table and pushes it onto the stack. +It is equivalent to lua_createtable(L, 0, 0). + + + + + +


lua_newthread

+[-0, +1, m] +

lua_State *lua_newthread (lua_State *L);
+ +

+Creates a new thread, pushes it on the stack, +and returns a pointer to a lua_State that represents this new thread. +The new state returned by this function shares with the original state +all global objects (such as tables), +but has an independent execution stack. + + +

+There is no explicit function to close or to destroy a thread. +Threads are subject to garbage collection, +like any Lua object. + + + + + +


lua_newuserdata

+[-0, +1, m] +

void *lua_newuserdata (lua_State *L, size_t size);
+ +

+This function allocates a new block of memory with the given size, +pushes onto the stack a new full userdata with the block address, +and returns this address. + + +

+Userdata represent C values in Lua. +A full userdata represents a block of memory. +It is an object (like a table): +you must create it, it can have its own metatable, +and you can detect when it is being collected. +A full userdata is only equal to itself (under raw equality). + + +

+When Lua collects a full userdata with a gc metamethod, +Lua calls the metamethod and marks the userdata as finalized. +When this userdata is collected again then +Lua frees its corresponding memory. + + + + + +


lua_next

+[-1, +(2|0), e] +

int lua_next (lua_State *L, int index);
+ +

+Pops a key from the stack, +and pushes a key-value pair from the table at the given index +(the "next" pair after the given key). +If there are no more elements in the table, +then lua_next returns 0 (and pushes nothing). + + +

+A typical traversal looks like this: + +

+     /* table is in the stack at index 't' */
+     lua_pushnil(L);  /* first key */
+     while (lua_next(L, t) != 0) {
+       /* uses 'key' (at index -2) and 'value' (at index -1) */
+       printf("%s - %s\n",
+              lua_typename(L, lua_type(L, -2)),
+              lua_typename(L, lua_type(L, -1)));
+       /* removes 'value'; keeps 'key' for next iteration */
+       lua_pop(L, 1);
+     }
+
+ +

+While traversing a table, +do not call lua_tolstring directly on a key, +unless you know that the key is actually a string. +Recall that lua_tolstring changes +the value at the given index; +this confuses the next call to lua_next. + + + + + +


lua_Number

+
typedef double lua_Number;
+ +

+The type of numbers in Lua. +By default, it is double, but that can be changed in luaconf.h. + + +

+Through the configuration file you can change +Lua to operate with another type for numbers (e.g., float or long). + + + + + +


lua_objlen

+[-0, +0, -] +

size_t lua_objlen (lua_State *L, int index);
+ +

+Returns the "length" of the value at the given acceptable index: +for strings, this is the string length; +for tables, this is the result of the length operator ('#'); +for userdata, this is the size of the block of memory allocated +for the userdata; +for other values, it is 0. + + + + + +


lua_pcall

+[-(nargs + 1), +(nresults|1), -] +

int lua_pcall (lua_State *L, int nargs, int nresults, int errfunc);
+ +

+Calls a function in protected mode. + + +

+Both nargs and nresults have the same meaning as +in lua_call. +If there are no errors during the call, +lua_pcall behaves exactly like lua_call. +However, if there is any error, +lua_pcall catches it, +pushes a single value on the stack (the error message), +and returns an error code. +Like lua_call, +lua_pcall always removes the function +and its arguments from the stack. + + +

+If errfunc is 0, +then the error message returned on the stack +is exactly the original error message. +Otherwise, errfunc is the stack index of an +error handler function. +(In the current implementation, this index cannot be a pseudo-index.) +In case of runtime errors, +this function will be called with the error message +and its return value will be the message returned on the stack by lua_pcall. + + +

+Typically, the error handler function is used to add more debug +information to the error message, such as a stack traceback. +Such information cannot be gathered after the return of lua_pcall, +since by then the stack has unwound. + + +

+The lua_pcall function returns 0 in case of success +or one of the following error codes +(defined in lua.h): + +

+ + + + +

lua_pop

+[-n, +0, -] +

void lua_pop (lua_State *L, int n);
+ +

+Pops n elements from the stack. + + + + + +


lua_pushboolean

+[-0, +1, -] +

void lua_pushboolean (lua_State *L, int b);
+ +

+Pushes a boolean value with value b onto the stack. + + + + + +


lua_pushcclosure

+[-n, +1, m] +

void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n);
+ +

+Pushes a new C closure onto the stack. + + +

+When a C function is created, +it is possible to associate some values with it, +thus creating a C closure (see §3.4); +these values are then accessible to the function whenever it is called. +To associate values with a C function, +first these values should be pushed onto the stack +(when there are multiple values, the first value is pushed first). +Then lua_pushcclosure +is called to create and push the C function onto the stack, +with the argument n telling how many values should be +associated with the function. +lua_pushcclosure also pops these values from the stack. + + +

+The maximum value for n is 255. + + + + + +


lua_pushcfunction

+[-0, +1, m] +

void lua_pushcfunction (lua_State *L, lua_CFunction f);
+ +

+Pushes a C function onto the stack. +This function receives a pointer to a C function +and pushes onto the stack a Lua value of type function that, +when called, invokes the corresponding C function. + + +

+Any function to be registered in Lua must +follow the correct protocol to receive its parameters +and return its results (see lua_CFunction). + + +

+lua_pushcfunction is defined as a macro: + +

+     #define lua_pushcfunction(L,f)  lua_pushcclosure(L,f,0)
+
+ + + + +

lua_pushfstring

+[-0, +1, m] +

const char *lua_pushfstring (lua_State *L, const char *fmt, ...);
+ +

+Pushes onto the stack a formatted string +and returns a pointer to this string. +It is similar to the C function sprintf, +but has some important differences: + +

+ + + + +

lua_pushinteger

+[-0, +1, -] +

void lua_pushinteger (lua_State *L, lua_Integer n);
+ +

+Pushes a number with value n onto the stack. + + + + + +


lua_pushlightuserdata

+[-0, +1, -] +

void lua_pushlightuserdata (lua_State *L, void *p);
+ +

+Pushes a light userdata onto the stack. + + +

+Userdata represent C values in Lua. +A light userdata represents a pointer. +It is a value (like a number): +you do not create it, it has no individual metatable, +and it is not collected (as it was never created). +A light userdata is equal to "any" +light userdata with the same C address. + + + + + +


lua_pushliteral

+[-0, +1, m] +

void lua_pushliteral (lua_State *L, const char *s);
+ +

+This macro is equivalent to lua_pushlstring, +but can be used only when s is a literal string. +In these cases, it automatically provides the string length. + + + + + +


lua_pushlstring

+[-0, +1, m] +

void lua_pushlstring (lua_State *L, const char *s, size_t len);
+ +

+Pushes the string pointed to by s with size len +onto the stack. +Lua makes (or reuses) an internal copy of the given string, +so the memory at s can be freed or reused immediately after +the function returns. +The string can contain embedded zeros. + + + + + +


lua_pushnil

+[-0, +1, -] +

void lua_pushnil (lua_State *L);
+ +

+Pushes a nil value onto the stack. + + + + + +


lua_pushnumber

+[-0, +1, -] +

void lua_pushnumber (lua_State *L, lua_Number n);
+ +

+Pushes a number with value n onto the stack. + + + + + +


lua_pushstring

+[-0, +1, m] +

void lua_pushstring (lua_State *L, const char *s);
+ +

+Pushes the zero-terminated string pointed to by s +onto the stack. +Lua makes (or reuses) an internal copy of the given string, +so the memory at s can be freed or reused immediately after +the function returns. +The string cannot contain embedded zeros; +it is assumed to end at the first zero. + + + + + +


lua_pushthread

+[-0, +1, -] +

int lua_pushthread (lua_State *L);
+ +

+Pushes the thread represented by L onto the stack. +Returns 1 if this thread is the main thread of its state. + + + + + +


lua_pushvalue

+[-0, +1, -] +

void lua_pushvalue (lua_State *L, int index);
+ +

+Pushes a copy of the element at the given valid index +onto the stack. + + + + + +


lua_pushvfstring

+[-0, +1, m] +

const char *lua_pushvfstring (lua_State *L,
+                              const char *fmt,
+                              va_list argp);
+ +

+Equivalent to lua_pushfstring, except that it receives a va_list +instead of a variable number of arguments. + + + + + +


lua_rawequal

+[-0, +0, -] +

int lua_rawequal (lua_State *L, int index1, int index2);
+ +

+Returns 1 if the two values in acceptable indices index1 and +index2 are primitively equal +(that is, without calling metamethods). +Otherwise returns 0. +Also returns 0 if any of the indices are non valid. + + + + + +


lua_rawget

+[-1, +1, -] +

void lua_rawget (lua_State *L, int index);
+ +

+Similar to lua_gettable, but does a raw access +(i.e., without metamethods). + + + + + +


lua_rawgeti

+[-0, +1, -] +

void lua_rawgeti (lua_State *L, int index, int n);
+ +

+Pushes onto the stack the value t[n], +where t is the value at the given valid index. +The access is raw; +that is, it does not invoke metamethods. + + + + + +


lua_rawset

+[-2, +0, m] +

void lua_rawset (lua_State *L, int index);
+ +

+Similar to lua_settable, but does a raw assignment +(i.e., without metamethods). + + + + + +


lua_rawseti

+[-1, +0, m] +

void lua_rawseti (lua_State *L, int index, int n);
+ +

+Does the equivalent of t[n] = v, +where t is the value at the given valid index +and v is the value at the top of the stack. + + +

+This function pops the value from the stack. +The assignment is raw; +that is, it does not invoke metamethods. + + + + + +


lua_Reader

+
typedef const char * (*lua_Reader) (lua_State *L,
+                                    void *data,
+                                    size_t *size);
+ +

+The reader function used by lua_load. +Every time it needs another piece of the chunk, +lua_load calls the reader, +passing along its data parameter. +The reader must return a pointer to a block of memory +with a new piece of the chunk +and set size to the block size. +The block must exist until the reader function is called again. +To signal the end of the chunk, +the reader must return NULL or set size to zero. +The reader function may return pieces of any size greater than zero. + + + + + +


lua_register

+[-0, +0, e] +

void lua_register (lua_State *L,
+                   const char *name,
+                   lua_CFunction f);
+ +

+Sets the C function f as the new value of global name. +It is defined as a macro: + +

+     #define lua_register(L,n,f) \
+            (lua_pushcfunction(L, f), lua_setglobal(L, n))
+
+ + + + +

lua_remove

+[-1, +0, -] +

void lua_remove (lua_State *L, int index);
+ +

+Removes the element at the given valid index, +shifting down the elements above this index to fill the gap. +Cannot be called with a pseudo-index, +because a pseudo-index is not an actual stack position. + + + + + +


lua_replace

+[-1, +0, -] +

void lua_replace (lua_State *L, int index);
+ +

+Moves the top element into the given position (and pops it), +without shifting any element +(therefore replacing the value at the given position). + + + + + +


lua_resume

+[-?, +?, -] +

int lua_resume (lua_State *L, int narg);
+ +

+Starts and resumes a coroutine in a given thread. + + +

+To start a coroutine, you first create a new thread +(see lua_newthread); +then you push onto its stack the main function plus any arguments; +then you call lua_resume, +with narg being the number of arguments. +This call returns when the coroutine suspends or finishes its execution. +When it returns, the stack contains all values passed to lua_yield, +or all values returned by the body function. +lua_resume returns +LUA_YIELD if the coroutine yields, +0 if the coroutine finishes its execution +without errors, +or an error code in case of errors (see lua_pcall). +In case of errors, +the stack is not unwound, +so you can use the debug API over it. +The error message is on the top of the stack. +To restart a coroutine, you put on its stack only the values to +be passed as results from yield, +and then call lua_resume. + + + + + +


lua_setallocf

+[-0, +0, -] +

void lua_setallocf (lua_State *L, lua_Alloc f, void *ud);
+ +

+Changes the allocator function of a given state to f +with user data ud. + + + + + +


lua_setfenv

+[-1, +0, -] +

int lua_setfenv (lua_State *L, int index);
+ +

+Pops a table from the stack and sets it as +the new environment for the value at the given index. +If the value at the given index is +neither a function nor a thread nor a userdata, +lua_setfenv returns 0. +Otherwise it returns 1. + + + + + +


lua_setfield

+[-1, +0, e] +

void lua_setfield (lua_State *L, int index, const char *k);
+ +

+Does the equivalent to t[k] = v, +where t is the value at the given valid index +and v is the value at the top of the stack. + + +

+This function pops the value from the stack. +As in Lua, this function may trigger a metamethod +for the "newindex" event (see §2.8). + + + + + +


lua_setglobal

+[-1, +0, e] +

void lua_setglobal (lua_State *L, const char *name);
+ +

+Pops a value from the stack and +sets it as the new value of global name. +It is defined as a macro: + +

+     #define lua_setglobal(L,s)   lua_setfield(L, LUA_GLOBALSINDEX, s)
+
+ + + + +

lua_setmetatable

+[-1, +0, -] +

int lua_setmetatable (lua_State *L, int index);
+ +

+Pops a table from the stack and +sets it as the new metatable for the value at the given +acceptable index. + + + + + +


lua_settable

+[-2, +0, e] +

void lua_settable (lua_State *L, int index);
+ +

+Does the equivalent to t[k] = v, +where t is the value at the given valid index, +v is the value at the top of the stack, +and k is the value just below the top. + + +

+This function pops both the key and the value from the stack. +As in Lua, this function may trigger a metamethod +for the "newindex" event (see §2.8). + + + + + +


lua_settop

+[-?, +?, -] +

void lua_settop (lua_State *L, int index);
+ +

+Accepts any acceptable index, or 0, +and sets the stack top to this index. +If the new top is larger than the old one, +then the new elements are filled with nil. +If index is 0, then all stack elements are removed. + + + + + +


lua_State

+
typedef struct lua_State lua_State;
+ +

+Opaque structure that keeps the whole state of a Lua interpreter. +The Lua library is fully reentrant: +it has no global variables. +All information about a state is kept in this structure. + + +

+A pointer to this state must be passed as the first argument to +every function in the library, except to lua_newstate, +which creates a Lua state from scratch. + + + + + +


lua_status

+[-0, +0, -] +

int lua_status (lua_State *L);
+ +

+Returns the status of the thread L. + + +

+The status can be 0 for a normal thread, +an error code if the thread finished its execution with an error, +or LUA_YIELD if the thread is suspended. + + + + + +


lua_toboolean

+[-0, +0, -] +

int lua_toboolean (lua_State *L, int index);
+ +

+Converts the Lua value at the given acceptable index to a C boolean +value (0 or 1). +Like all tests in Lua, +lua_toboolean returns 1 for any Lua value +different from false and nil; +otherwise it returns 0. +It also returns 0 when called with a non-valid index. +(If you want to accept only actual boolean values, +use lua_isboolean to test the value's type.) + + + + + +


lua_tocfunction

+[-0, +0, -] +

lua_CFunction lua_tocfunction (lua_State *L, int index);
+ +

+Converts a value at the given acceptable index to a C function. +That value must be a C function; +otherwise, returns NULL. + + + + + +


lua_tointeger

+[-0, +0, -] +

lua_Integer lua_tointeger (lua_State *L, int index);
+ +

+Converts the Lua value at the given acceptable index +to the signed integral type lua_Integer. +The Lua value must be a number or a string convertible to a number +(see §2.2.1); +otherwise, lua_tointeger returns 0. + + +

+If the number is not an integer, +it is truncated in some non-specified way. + + + + + +


lua_tolstring

+[-0, +0, m] +

const char *lua_tolstring (lua_State *L, int index, size_t *len);
+ +

+Converts the Lua value at the given acceptable index to a C string. +If len is not NULL, +it also sets *len with the string length. +The Lua value must be a string or a number; +otherwise, the function returns NULL. +If the value is a number, +then lua_tolstring also +changes the actual value in the stack to a string. +(This change confuses lua_next +when lua_tolstring is applied to keys during a table traversal.) + + +

+lua_tolstring returns a fully aligned pointer +to a string inside the Lua state. +This string always has a zero ('\0') +after its last character (as in C), +but can contain other zeros in its body. +Because Lua has garbage collection, +there is no guarantee that the pointer returned by lua_tolstring +will be valid after the corresponding value is removed from the stack. + + + + + +


lua_tonumber

+[-0, +0, -] +

lua_Number lua_tonumber (lua_State *L, int index);
+ +

+Converts the Lua value at the given acceptable index +to the C type lua_Number (see lua_Number). +The Lua value must be a number or a string convertible to a number +(see §2.2.1); +otherwise, lua_tonumber returns 0. + + + + + +


lua_topointer

+[-0, +0, -] +

const void *lua_topointer (lua_State *L, int index);
+ +

+Converts the value at the given acceptable index to a generic +C pointer (void*). +The value can be a userdata, a table, a thread, or a function; +otherwise, lua_topointer returns NULL. +Different objects will give different pointers. +There is no way to convert the pointer back to its original value. + + +

+Typically this function is used only for debug information. + + + + + +


lua_tostring

+[-0, +0, m] +

const char *lua_tostring (lua_State *L, int index);
+ +

+Equivalent to lua_tolstring with len equal to NULL. + + + + + +


lua_tothread

+[-0, +0, -] +

lua_State *lua_tothread (lua_State *L, int index);
+ +

+Converts the value at the given acceptable index to a Lua thread +(represented as lua_State*). +This value must be a thread; +otherwise, the function returns NULL. + + + + + +


lua_touserdata

+[-0, +0, -] +

void *lua_touserdata (lua_State *L, int index);
+ +

+If the value at the given acceptable index is a full userdata, +returns its block address. +If the value is a light userdata, +returns its pointer. +Otherwise, returns NULL. + + + + + +


lua_type

+[-0, +0, -] +

int lua_type (lua_State *L, int index);
+ +

+Returns the type of the value in the given acceptable index, +or LUA_TNONE for a non-valid index +(that is, an index to an "empty" stack position). +The types returned by lua_type are coded by the following constants +defined in lua.h: +LUA_TNIL, +LUA_TNUMBER, +LUA_TBOOLEAN, +LUA_TSTRING, +LUA_TTABLE, +LUA_TFUNCTION, +LUA_TUSERDATA, +LUA_TTHREAD, +and +LUA_TLIGHTUSERDATA. + + + + + +


lua_typename

+[-0, +0, -] +

const char *lua_typename  (lua_State *L, int tp);
+ +

+Returns the name of the type encoded by the value tp, +which must be one the values returned by lua_type. + + + + + +


lua_Writer

+
typedef int (*lua_Writer) (lua_State *L,
+                           const void* p,
+                           size_t sz,
+                           void* ud);
+ +

+The type of the writer function used by lua_dump. +Every time it produces another piece of chunk, +lua_dump calls the writer, +passing along the buffer to be written (p), +its size (sz), +and the data parameter supplied to lua_dump. + + +

+The writer returns an error code: +0 means no errors; +any other value means an error and stops lua_dump from +calling the writer again. + + + + + +


lua_xmove

+[-?, +?, -] +

void lua_xmove (lua_State *from, lua_State *to, int n);
+ +

+Exchange values between different threads of the same global state. + + +

+This function pops n values from the stack from, +and pushes them onto the stack to. + + + + + +


lua_yield

+[-?, +?, -] +

int lua_yield  (lua_State *L, int nresults);
+ +

+Yields a coroutine. + + +

+This function should only be called as the +return expression of a C function, as follows: + +

+     return lua_yield (L, nresults);
+

+When a C function calls lua_yield in that way, +the running coroutine suspends its execution, +and the call to lua_resume that started this coroutine returns. +The parameter nresults is the number of values from the stack +that are passed as results to lua_resume. + + + + + + + +

3.8 - The Debug Interface

+ +

+Lua has no built-in debugging facilities. +Instead, it offers a special interface +by means of functions and hooks. +This interface allows the construction of different +kinds of debuggers, profilers, and other tools +that need "inside information" from the interpreter. + + + +


lua_Debug

+
typedef struct lua_Debug {
+  int event;
+  const char *name;           /* (n) */
+  const char *namewhat;       /* (n) */
+  const char *what;           /* (S) */
+  const char *source;         /* (S) */
+  int currentline;            /* (l) */
+  int nups;                   /* (u) number of upvalues */
+  int linedefined;            /* (S) */
+  int lastlinedefined;        /* (S) */
+  char short_src[LUA_IDSIZE]; /* (S) */
+  /* private part */
+  other fields
+} lua_Debug;
+ +

+A structure used to carry different pieces of +information about an active function. +lua_getstack fills only the private part +of this structure, for later use. +To fill the other fields of lua_Debug with useful information, +call lua_getinfo. + + +

+The fields of lua_Debug have the following meaning: + +

+ + + + +

lua_gethook

+[-0, +0, -] +

lua_Hook lua_gethook (lua_State *L);
+ +

+Returns the current hook function. + + + + + +


lua_gethookcount

+[-0, +0, -] +

int lua_gethookcount (lua_State *L);
+ +

+Returns the current hook count. + + + + + +


lua_gethookmask

+[-0, +0, -] +

int lua_gethookmask (lua_State *L);
+ +

+Returns the current hook mask. + + + + + +


lua_getinfo

+[-(0|1), +(0|1|2), m] +

int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar);
+ +

+Returns information about a specific function or function invocation. + + +

+To get information about a function invocation, +the parameter ar must be a valid activation record that was +filled by a previous call to lua_getstack or +given as argument to a hook (see lua_Hook). + + +

+To get information about a function you push it onto the stack +and start the what string with the character '>'. +(In that case, +lua_getinfo pops the function in the top of the stack.) +For instance, to know in which line a function f was defined, +you can write the following code: + +

+     lua_Debug ar;
+     lua_getfield(L, LUA_GLOBALSINDEX, "f");  /* get global 'f' */
+     lua_getinfo(L, ">S", &ar);
+     printf("%d\n", ar.linedefined);
+
+ +

+Each character in the string what +selects some fields of the structure ar to be filled or +a value to be pushed on the stack: + +

+ +

+This function returns 0 on error +(for instance, an invalid option in what). + + + + + +


lua_getlocal

+[-0, +(0|1), -] +

const char *lua_getlocal (lua_State *L, lua_Debug *ar, int n);
+ +

+Gets information about a local variable of a given activation record. +The parameter ar must be a valid activation record that was +filled by a previous call to lua_getstack or +given as argument to a hook (see lua_Hook). +The index n selects which local variable to inspect +(1 is the first parameter or active local variable, and so on, +until the last active local variable). +lua_getlocal pushes the variable's value onto the stack +and returns its name. + + +

+Variable names starting with '(' (open parentheses) +represent internal variables +(loop control variables, temporaries, and C function locals). + + +

+Returns NULL (and pushes nothing) +when the index is greater than +the number of active local variables. + + + + + +


lua_getstack

+[-0, +0, -] +

int lua_getstack (lua_State *L, int level, lua_Debug *ar);
+ +

+Get information about the interpreter runtime stack. + + +

+This function fills parts of a lua_Debug structure with +an identification of the activation record +of the function executing at a given level. +Level 0 is the current running function, +whereas level n+1 is the function that has called level n. +When there are no errors, lua_getstack returns 1; +when called with a level greater than the stack depth, +it returns 0. + + + + + +


lua_getupvalue

+[-0, +(0|1), -] +

const char *lua_getupvalue (lua_State *L, int funcindex, int n);
+ +

+Gets information about a closure's upvalue. +(For Lua functions, +upvalues are the external local variables that the function uses, +and that are consequently included in its closure.) +lua_getupvalue gets the index n of an upvalue, +pushes the upvalue's value onto the stack, +and returns its name. +funcindex points to the closure in the stack. +(Upvalues have no particular order, +as they are active through the whole function. +So, they are numbered in an arbitrary order.) + + +

+Returns NULL (and pushes nothing) +when the index is greater than the number of upvalues. +For C functions, this function uses the empty string "" +as a name for all upvalues. + + + + + +


lua_Hook

+
typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar);
+ +

+Type for debugging hook functions. + + +

+Whenever a hook is called, its ar argument has its field +event set to the specific event that triggered the hook. +Lua identifies these events with the following constants: +LUA_HOOKCALL, LUA_HOOKRET, +LUA_HOOKTAILRET, LUA_HOOKLINE, +and LUA_HOOKCOUNT. +Moreover, for line events, the field currentline is also set. +To get the value of any other field in ar, +the hook must call lua_getinfo. +For return events, event can be LUA_HOOKRET, +the normal value, or LUA_HOOKTAILRET. +In the latter case, Lua is simulating a return from +a function that did a tail call; +in this case, it is useless to call lua_getinfo. + + +

+While Lua is running a hook, it disables other calls to hooks. +Therefore, if a hook calls back Lua to execute a function or a chunk, +this execution occurs without any calls to hooks. + + + + + +


lua_sethook

+[-0, +0, -] +

int lua_sethook (lua_State *L, lua_Hook f, int mask, int count);
+ +

+Sets the debugging hook function. + + +

+Argument f is the hook function. +mask specifies on which events the hook will be called: +it is formed by a bitwise or of the constants +LUA_MASKCALL, +LUA_MASKRET, +LUA_MASKLINE, +and LUA_MASKCOUNT. +The count argument is only meaningful when the mask +includes LUA_MASKCOUNT. +For each event, the hook is called as explained below: + +

+ +

+A hook is disabled by setting mask to zero. + + + + + +


lua_setlocal

+[-(0|1), +0, -] +

const char *lua_setlocal (lua_State *L, lua_Debug *ar, int n);
+ +

+Sets the value of a local variable of a given activation record. +Parameters ar and n are as in lua_getlocal +(see lua_getlocal). +lua_setlocal assigns the value at the top of the stack +to the variable and returns its name. +It also pops the value from the stack. + + +

+Returns NULL (and pops nothing) +when the index is greater than +the number of active local variables. + + + + + +


lua_setupvalue

+[-(0|1), +0, -] +

const char *lua_setupvalue (lua_State *L, int funcindex, int n);
+ +

+Sets the value of a closure's upvalue. +It assigns the value at the top of the stack +to the upvalue and returns its name. +It also pops the value from the stack. +Parameters funcindex and n are as in the lua_getupvalue +(see lua_getupvalue). + + +

+Returns NULL (and pops nothing) +when the index is greater than the number of upvalues. + + + + + + + +

4 - The Auxiliary Library

+ +

+ +The auxiliary library provides several convenient functions +to interface C with Lua. +While the basic API provides the primitive functions for all +interactions between C and Lua, +the auxiliary library provides higher-level functions for some +common tasks. + + +

+All functions from the auxiliary library +are defined in header file lauxlib.h and +have a prefix luaL_. + + +

+All functions in the auxiliary library are built on +top of the basic API, +and so they provide nothing that cannot be done with this API. + + +

+Several functions in the auxiliary library are used to +check C function arguments. +Their names are always luaL_check* or luaL_opt*. +All of these functions throw an error if the check is not satisfied. +Because the error message is formatted for arguments +(e.g., "bad argument #1"), +you should not use these functions for other stack values. + + + +

4.1 - Functions and Types

+ +

+Here we list all functions and types from the auxiliary library +in alphabetical order. + + + +


luaL_addchar

+[-0, +0, m] +

void luaL_addchar (luaL_Buffer *B, char c);
+ +

+Adds the character c to the buffer B +(see luaL_Buffer). + + + + + +


luaL_addlstring

+[-0, +0, m] +

void luaL_addlstring (luaL_Buffer *B, const char *s, size_t l);
+ +

+Adds the string pointed to by s with length l to +the buffer B +(see luaL_Buffer). +The string may contain embedded zeros. + + + + + +


luaL_addsize

+[-0, +0, m] +

void luaL_addsize (luaL_Buffer *B, size_t n);
+ +

+Adds to the buffer B (see luaL_Buffer) +a string of length n previously copied to the +buffer area (see luaL_prepbuffer). + + + + + +


luaL_addstring

+[-0, +0, m] +

void luaL_addstring (luaL_Buffer *B, const char *s);
+ +

+Adds the zero-terminated string pointed to by s +to the buffer B +(see luaL_Buffer). +The string may not contain embedded zeros. + + + + + +


luaL_addvalue

+[-1, +0, m] +

void luaL_addvalue (luaL_Buffer *B);
+ +

+Adds the value at the top of the stack +to the buffer B +(see luaL_Buffer). +Pops the value. + + +

+This is the only function on string buffers that can (and must) +be called with an extra element on the stack, +which is the value to be added to the buffer. + + + + + +


luaL_argcheck

+[-0, +0, v] +

void luaL_argcheck (lua_State *L,
+                    int cond,
+                    int narg,
+                    const char *extramsg);
+ +

+Checks whether cond is true. +If not, raises an error with the following message, +where func is retrieved from the call stack: + +

+     bad argument #<narg> to <func> (<extramsg>)
+
+ + + + +

luaL_argerror

+[-0, +0, v] +

int luaL_argerror (lua_State *L, int narg, const char *extramsg);
+ +

+Raises an error with the following message, +where func is retrieved from the call stack: + +

+     bad argument #<narg> to <func> (<extramsg>)
+
+ +

+This function never returns, +but it is an idiom to use it in C functions +as return luaL_argerror(args). + + + + + +


luaL_Buffer

+
typedef struct luaL_Buffer luaL_Buffer;
+ +

+Type for a string buffer. + + +

+A string buffer allows C code to build Lua strings piecemeal. +Its pattern of use is as follows: + +

+ +

+During its normal operation, +a string buffer uses a variable number of stack slots. +So, while using a buffer, you cannot assume that you know where +the top of the stack is. +You can use the stack between successive calls to buffer operations +as long as that use is balanced; +that is, +when you call a buffer operation, +the stack is at the same level +it was immediately after the previous buffer operation. +(The only exception to this rule is luaL_addvalue.) +After calling luaL_pushresult the stack is back to its +level when the buffer was initialized, +plus the final string on its top. + + + + + +


luaL_buffinit

+[-0, +0, -] +

void luaL_buffinit (lua_State *L, luaL_Buffer *B);
+ +

+Initializes a buffer B. +This function does not allocate any space; +the buffer must be declared as a variable +(see luaL_Buffer). + + + + + +


luaL_callmeta

+[-0, +(0|1), e] +

int luaL_callmeta (lua_State *L, int obj, const char *e);
+ +

+Calls a metamethod. + + +

+If the object at index obj has a metatable and this +metatable has a field e, +this function calls this field and passes the object as its only argument. +In this case this function returns 1 and pushes onto the +stack the value returned by the call. +If there is no metatable or no metamethod, +this function returns 0 (without pushing any value on the stack). + + + + + +


luaL_checkany

+[-0, +0, v] +

void luaL_checkany (lua_State *L, int narg);
+ +

+Checks whether the function has an argument +of any type (including nil) at position narg. + + + + + +


luaL_checkint

+[-0, +0, v] +

int luaL_checkint (lua_State *L, int narg);
+ +

+Checks whether the function argument narg is a number +and returns this number cast to an int. + + + + + +


luaL_checkinteger

+[-0, +0, v] +

lua_Integer luaL_checkinteger (lua_State *L, int narg);
+ +

+Checks whether the function argument narg is a number +and returns this number cast to a lua_Integer. + + + + + +


luaL_checklong

+[-0, +0, v] +

long luaL_checklong (lua_State *L, int narg);
+ +

+Checks whether the function argument narg is a number +and returns this number cast to a long. + + + + + +


luaL_checklstring

+[-0, +0, v] +

const char *luaL_checklstring (lua_State *L, int narg, size_t *l);
+ +

+Checks whether the function argument narg is a string +and returns this string; +if l is not NULL fills *l +with the string's length. + + +

+This function uses lua_tolstring to get its result, +so all conversions and caveats of that function apply here. + + + + + +


luaL_checknumber

+[-0, +0, v] +

lua_Number luaL_checknumber (lua_State *L, int narg);
+ +

+Checks whether the function argument narg is a number +and returns this number. + + + + + +


luaL_checkoption

+[-0, +0, v] +

int luaL_checkoption (lua_State *L,
+                      int narg,
+                      const char *def,
+                      const char *const lst[]);
+ +

+Checks whether the function argument narg is a string and +searches for this string in the array lst +(which must be NULL-terminated). +Returns the index in the array where the string was found. +Raises an error if the argument is not a string or +if the string cannot be found. + + +

+If def is not NULL, +the function uses def as a default value when +there is no argument narg or if this argument is nil. + + +

+This is a useful function for mapping strings to C enums. +(The usual convention in Lua libraries is +to use strings instead of numbers to select options.) + + + + + +


luaL_checkstack

+[-0, +0, v] +

void luaL_checkstack (lua_State *L, int sz, const char *msg);
+ +

+Grows the stack size to top + sz elements, +raising an error if the stack cannot grow to that size. +msg is an additional text to go into the error message. + + + + + +


luaL_checkstring

+[-0, +0, v] +

const char *luaL_checkstring (lua_State *L, int narg);
+ +

+Checks whether the function argument narg is a string +and returns this string. + + +

+This function uses lua_tolstring to get its result, +so all conversions and caveats of that function apply here. + + + + + +


luaL_checktype

+[-0, +0, v] +

void luaL_checktype (lua_State *L, int narg, int t);
+ +

+Checks whether the function argument narg has type t. +See lua_type for the encoding of types for t. + + + + + +


luaL_checkudata

+[-0, +0, v] +

void *luaL_checkudata (lua_State *L, int narg, const char *tname);
+ +

+Checks whether the function argument narg is a userdata +of the type tname (see luaL_newmetatable). + + + + + +


luaL_dofile

+[-0, +?, m] +

int luaL_dofile (lua_State *L, const char *filename);
+ +

+Loads and runs the given file. +It is defined as the following macro: + +

+     (luaL_loadfile(L, filename) || lua_pcall(L, 0, LUA_MULTRET, 0))
+

+It returns 0 if there are no errors +or 1 in case of errors. + + + + + +


luaL_dostring

+[-0, +?, m] +

int luaL_dostring (lua_State *L, const char *str);
+ +

+Loads and runs the given string. +It is defined as the following macro: + +

+     (luaL_loadstring(L, str) || lua_pcall(L, 0, LUA_MULTRET, 0))
+

+It returns 0 if there are no errors +or 1 in case of errors. + + + + + +


luaL_error

+[-0, +0, v] +

int luaL_error (lua_State *L, const char *fmt, ...);
+ +

+Raises an error. +The error message format is given by fmt +plus any extra arguments, +following the same rules of lua_pushfstring. +It also adds at the beginning of the message the file name and +the line number where the error occurred, +if this information is available. + + +

+This function never returns, +but it is an idiom to use it in C functions +as return luaL_error(args). + + + + + +


luaL_getmetafield

+[-0, +(0|1), m] +

int luaL_getmetafield (lua_State *L, int obj, const char *e);
+ +

+Pushes onto the stack the field e from the metatable +of the object at index obj. +If the object does not have a metatable, +or if the metatable does not have this field, +returns 0 and pushes nothing. + + + + + +


luaL_getmetatable

+[-0, +1, -] +

void luaL_getmetatable (lua_State *L, const char *tname);
+ +

+Pushes onto the stack the metatable associated with name tname +in the registry (see luaL_newmetatable). + + + + + +


luaL_gsub

+[-0, +1, m] +

const char *luaL_gsub (lua_State *L,
+                       const char *s,
+                       const char *p,
+                       const char *r);
+ +

+Creates a copy of string s by replacing +any occurrence of the string p +with the string r. +Pushes the resulting string on the stack and returns it. + + + + + +


luaL_loadbuffer

+[-0, +1, m] +

int luaL_loadbuffer (lua_State *L,
+                     const char *buff,
+                     size_t sz,
+                     const char *name);
+ +

+Loads a buffer as a Lua chunk. +This function uses lua_load to load the chunk in the +buffer pointed to by buff with size sz. + + +

+This function returns the same results as lua_load. +name is the chunk name, +used for debug information and error messages. + + + + + +


luaL_loadfile

+[-0, +1, m] +

int luaL_loadfile (lua_State *L, const char *filename);
+ +

+Loads a file as a Lua chunk. +This function uses lua_load to load the chunk in the file +named filename. +If filename is NULL, +then it loads from the standard input. +The first line in the file is ignored if it starts with a #. + + +

+This function returns the same results as lua_load, +but it has an extra error code LUA_ERRFILE +if it cannot open/read the file. + + +

+As lua_load, this function only loads the chunk; +it does not run it. + + + + + +


luaL_loadstring

+[-0, +1, m] +

int luaL_loadstring (lua_State *L, const char *s);
+ +

+Loads a string as a Lua chunk. +This function uses lua_load to load the chunk in +the zero-terminated string s. + + +

+This function returns the same results as lua_load. + + +

+Also as lua_load, this function only loads the chunk; +it does not run it. + + + + + +


luaL_newmetatable

+[-0, +1, m] +

int luaL_newmetatable (lua_State *L, const char *tname);
+ +

+If the registry already has the key tname, +returns 0. +Otherwise, +creates a new table to be used as a metatable for userdata, +adds it to the registry with key tname, +and returns 1. + + +

+In both cases pushes onto the stack the final value associated +with tname in the registry. + + + + + +


luaL_newstate

+[-0, +0, -] +

lua_State *luaL_newstate (void);
+ +

+Creates a new Lua state. +It calls lua_newstate with an +allocator based on the standard C realloc function +and then sets a panic function (see lua_atpanic) that prints +an error message to the standard error output in case of fatal +errors. + + +

+Returns the new state, +or NULL if there is a memory allocation error. + + + + + +


luaL_openlibs

+[-0, +0, m] +

void luaL_openlibs (lua_State *L);
+ +

+Opens all standard Lua libraries into the given state. + + + + + +


luaL_optint

+[-0, +0, v] +

int luaL_optint (lua_State *L, int narg, int d);
+ +

+If the function argument narg is a number, +returns this number cast to an int. +If this argument is absent or is nil, +returns d. +Otherwise, raises an error. + + + + + +


luaL_optinteger

+[-0, +0, v] +

lua_Integer luaL_optinteger (lua_State *L,
+                             int narg,
+                             lua_Integer d);
+ +

+If the function argument narg is a number, +returns this number cast to a lua_Integer. +If this argument is absent or is nil, +returns d. +Otherwise, raises an error. + + + + + +


luaL_optlong

+[-0, +0, v] +

long luaL_optlong (lua_State *L, int narg, long d);
+ +

+If the function argument narg is a number, +returns this number cast to a long. +If this argument is absent or is nil, +returns d. +Otherwise, raises an error. + + + + + +


luaL_optlstring

+[-0, +0, v] +

const char *luaL_optlstring (lua_State *L,
+                             int narg,
+                             const char *d,
+                             size_t *l);
+ +

+If the function argument narg is a string, +returns this string. +If this argument is absent or is nil, +returns d. +Otherwise, raises an error. + + +

+If l is not NULL, +fills the position *l with the results's length. + + + + + +


luaL_optnumber

+[-0, +0, v] +

lua_Number luaL_optnumber (lua_State *L, int narg, lua_Number d);
+ +

+If the function argument narg is a number, +returns this number. +If this argument is absent or is nil, +returns d. +Otherwise, raises an error. + + + + + +


luaL_optstring

+[-0, +0, v] +

const char *luaL_optstring (lua_State *L,
+                            int narg,
+                            const char *d);
+ +

+If the function argument narg is a string, +returns this string. +If this argument is absent or is nil, +returns d. +Otherwise, raises an error. + + + + + +


luaL_prepbuffer

+[-0, +0, -] +

char *luaL_prepbuffer (luaL_Buffer *B);
+ +

+Returns an address to a space of size LUAL_BUFFERSIZE +where you can copy a string to be added to buffer B +(see luaL_Buffer). +After copying the string into this space you must call +luaL_addsize with the size of the string to actually add +it to the buffer. + + + + + +


luaL_pushresult

+[-?, +1, m] +

void luaL_pushresult (luaL_Buffer *B);
+ +

+Finishes the use of buffer B leaving the final string on +the top of the stack. + + + + + +


luaL_ref

+[-1, +0, m] +

int luaL_ref (lua_State *L, int t);
+ +

+Creates and returns a reference, +in the table at index t, +for the object at the top of the stack (and pops the object). + + +

+A reference is a unique integer key. +As long as you do not manually add integer keys into table t, +luaL_ref ensures the uniqueness of the key it returns. +You can retrieve an object referred by reference r +by calling lua_rawgeti(L, t, r). +Function luaL_unref frees a reference and its associated object. + + +

+If the object at the top of the stack is nil, +luaL_ref returns the constant LUA_REFNIL. +The constant LUA_NOREF is guaranteed to be different +from any reference returned by luaL_ref. + + + + + +


luaL_Reg

+
typedef struct luaL_Reg {
+  const char *name;
+  lua_CFunction func;
+} luaL_Reg;
+ +

+Type for arrays of functions to be registered by +luaL_register. +name is the function name and func is a pointer to +the function. +Any array of luaL_Reg must end with an sentinel entry +in which both name and func are NULL. + + + + + +


luaL_register

+[-(0|1), +1, m] +

void luaL_register (lua_State *L,
+                    const char *libname,
+                    const luaL_Reg *l);
+ +

+Opens a library. + + +

+When called with libname equal to NULL, +it simply registers all functions in the list l +(see luaL_Reg) into the table on the top of the stack. + + +

+When called with a non-null libname, +luaL_register creates a new table t, +sets it as the value of the global variable libname, +sets it as the value of package.loaded[libname], +and registers on it all functions in the list l. +If there is a table in package.loaded[libname] or in +variable libname, +reuses this table instead of creating a new one. + + +

+In any case the function leaves the table +on the top of the stack. + + + + + +


luaL_typename

+[-0, +0, -] +

const char *luaL_typename (lua_State *L, int index);
+ +

+Returns the name of the type of the value at the given index. + + + + + +


luaL_typerror

+[-0, +0, v] +

int luaL_typerror (lua_State *L, int narg, const char *tname);
+ +

+Generates an error with a message like the following: + +

+     location: bad argument narg to 'func' (tname expected, got rt)
+

+where location is produced by luaL_where, +func is the name of the current function, +and rt is the type name of the actual argument. + + + + + +


luaL_unref

+[-0, +0, -] +

void luaL_unref (lua_State *L, int t, int ref);
+ +

+Releases reference ref from the table at index t +(see luaL_ref). +The entry is removed from the table, +so that the referred object can be collected. +The reference ref is also freed to be used again. + + +

+If ref is LUA_NOREF or LUA_REFNIL, +luaL_unref does nothing. + + + + + +


luaL_where

+[-0, +1, m] +

void luaL_where (lua_State *L, int lvl);
+ +

+Pushes onto the stack a string identifying the current position +of the control at level lvl in the call stack. +Typically this string has the following format: + +

+     chunkname:currentline:
+

+Level 0 is the running function, +level 1 is the function that called the running function, +etc. + + +

+This function is used to build a prefix for error messages. + + + + + + + +

5 - Standard Libraries

+ +

+The standard Lua libraries provide useful functions +that are implemented directly through the C API. +Some of these functions provide essential services to the language +(e.g., type and getmetatable); +others provide access to "outside" services (e.g., I/O); +and others could be implemented in Lua itself, +but are quite useful or have critical performance requirements that +deserve an implementation in C (e.g., table.sort). + + +

+All libraries are implemented through the official C API +and are provided as separate C modules. +Currently, Lua has the following standard libraries: + +

+Except for the basic and package libraries, +each library provides all its functions as fields of a global table +or as methods of its objects. + + +

+To have access to these libraries, +the C host program should call the luaL_openlibs function, +which opens all standard libraries. +Alternatively, +it can open them individually by calling +luaopen_base (for the basic library), +luaopen_package (for the package library), +luaopen_string (for the string library), +luaopen_table (for the table library), +luaopen_math (for the mathematical library), +luaopen_io (for the I/O library), +luaopen_os (for the Operating System library), +and luaopen_debug (for the debug library). +These functions are declared in lualib.h +and should not be called directly: +you must call them like any other Lua C function, +e.g., by using lua_call. + + + +

5.1 - Basic Functions

+ +

+The basic library provides some core functions to Lua. +If you do not include this library in your application, +you should check carefully whether you need to provide +implementations for some of its facilities. + + +

+


assert (v [, message])

+Issues an error when +the value of its argument v is false (i.e., nil or false); +otherwise, returns all its arguments. +message is an error message; +when absent, it defaults to "assertion failed!" + + + + +

+


collectgarbage ([opt [, arg]])

+ + +

+This function is a generic interface to the garbage collector. +It performs different functions according to its first argument, opt: + +

+ + + +

+


dofile ([filename])

+Opens the named file and executes its contents as a Lua chunk. +When called without arguments, +dofile executes the contents of the standard input (stdin). +Returns all values returned by the chunk. +In case of errors, dofile propagates the error +to its caller (that is, dofile does not run in protected mode). + + + + +

+


error (message [, level])

+Terminates the last protected function called +and returns message as the error message. +Function error never returns. + + +

+Usually, error adds some information about the error position +at the beginning of the message. +The level argument specifies how to get the error position. +With level 1 (the default), the error position is where the +error function was called. +Level 2 points the error to where the function +that called error was called; and so on. +Passing a level 0 avoids the addition of error position information +to the message. + + + + +

+


_G

+A global variable (not a function) that +holds the global environment (that is, _G._G = _G). +Lua itself does not use this variable; +changing its value does not affect any environment, +nor vice-versa. +(Use setfenv to change environments.) + + + + +

+


getfenv ([f])

+Returns the current environment in use by the function. +f can be a Lua function or a number +that specifies the function at that stack level: +Level 1 is the function calling getfenv. +If the given function is not a Lua function, +or if f is 0, +getfenv returns the global environment. +The default for f is 1. + + + + +

+


getmetatable (object)

+ + +

+If object does not have a metatable, returns nil. +Otherwise, +if the object's metatable has a "__metatable" field, +returns the associated value. +Otherwise, returns the metatable of the given object. + + + + +

+


ipairs (t)

+ + +

+Returns three values: an iterator function, the table t, and 0, +so that the construction + +

+     for i,v in ipairs(t) do body end
+

+will iterate over the pairs (1,t[1]), (2,t[2]), ···, +up to the first integer key absent from the table. + + + + +

+


load (func [, chunkname])

+ + +

+Loads a chunk using function func to get its pieces. +Each call to func must return a string that concatenates +with previous results. +A return of an empty string, nil, or no value signals the end of the chunk. + + +

+If there are no errors, +returns the compiled chunk as a function; +otherwise, returns nil plus the error message. +The environment of the returned function is the global environment. + + +

+chunkname is used as the chunk name for error messages +and debug information. +When absent, +it defaults to "=(load)". + + + + +

+


loadfile ([filename])

+ + +

+Similar to load, +but gets the chunk from file filename +or from the standard input, +if no file name is given. + + + + +

+


loadstring (string [, chunkname])

+ + +

+Similar to load, +but gets the chunk from the given string. + + +

+To load and run a given string, use the idiom + +

+     assert(loadstring(s))()
+
+ +

+When absent, +chunkname defaults to the given string. + + + + +

+


next (table [, index])

+ + +

+Allows a program to traverse all fields of a table. +Its first argument is a table and its second argument +is an index in this table. +next returns the next index of the table +and its associated value. +When called with nil as its second argument, +next returns an initial index +and its associated value. +When called with the last index, +or with nil in an empty table, +next returns nil. +If the second argument is absent, then it is interpreted as nil. +In particular, +you can use next(t) to check whether a table is empty. + + +

+The order in which the indices are enumerated is not specified, +even for numeric indices. +(To traverse a table in numeric order, +use a numerical for or the ipairs function.) + + +

+The behavior of next is undefined if, +during the traversal, +you assign any value to a non-existent field in the table. +You may however modify existing fields. +In particular, you may clear existing fields. + + + + +

+


pairs (t)

+ + +

+Returns three values: the next function, the table t, and nil, +so that the construction + +

+     for k,v in pairs(t) do body end
+

+will iterate over all key–value pairs of table t. + + +

+See function next for the caveats of modifying +the table during its traversal. + + + + +

+


pcall (f, arg1, ···)

+ + +

+Calls function f with +the given arguments in protected mode. +This means that any error inside f is not propagated; +instead, pcall catches the error +and returns a status code. +Its first result is the status code (a boolean), +which is true if the call succeeds without errors. +In such case, pcall also returns all results from the call, +after this first result. +In case of any error, pcall returns false plus the error message. + + + + +

+


print (···)

+Receives any number of arguments, +and prints their values to stdout, +using the tostring function to convert them to strings. +print is not intended for formatted output, +but only as a quick way to show a value, +typically for debugging. +For formatted output, use string.format. + + + + +

+


rawequal (v1, v2)

+Checks whether v1 is equal to v2, +without invoking any metamethod. +Returns a boolean. + + + + +

+


rawget (table, index)

+Gets the real value of table[index], +without invoking any metamethod. +table must be a table; +index may be any value. + + + + +

+


rawset (table, index, value)

+Sets the real value of table[index] to value, +without invoking any metamethod. +table must be a table, +index any value different from nil, +and value any Lua value. + + +

+This function returns table. + + + + +

+


select (index, ···)

+ + +

+If index is a number, +returns all arguments after argument number index. +Otherwise, index must be the string "#", +and select returns the total number of extra arguments it received. + + + + +

+


setfenv (f, table)

+ + +

+Sets the environment to be used by the given function. +f can be a Lua function or a number +that specifies the function at that stack level: +Level 1 is the function calling setfenv. +setfenv returns the given function. + + +

+As a special case, when f is 0 setfenv changes +the environment of the running thread. +In this case, setfenv returns no values. + + + + +

+


setmetatable (table, metatable)

+ + +

+Sets the metatable for the given table. +(You cannot change the metatable of other types from Lua, only from C.) +If metatable is nil, +removes the metatable of the given table. +If the original metatable has a "__metatable" field, +raises an error. + + +

+This function returns table. + + + + +

+


tonumber (e [, base])

+Tries to convert its argument to a number. +If the argument is already a number or a string convertible +to a number, then tonumber returns this number; +otherwise, it returns nil. + + +

+An optional argument specifies the base to interpret the numeral. +The base may be any integer between 2 and 36, inclusive. +In bases above 10, the letter 'A' (in either upper or lower case) +represents 10, 'B' represents 11, and so forth, +with 'Z' representing 35. +In base 10 (the default), the number can have a decimal part, +as well as an optional exponent part (see §2.1). +In other bases, only unsigned integers are accepted. + + + + +

+


tostring (e)

+Receives an argument of any type and +converts it to a string in a reasonable format. +For complete control of how numbers are converted, +use string.format. + + +

+If the metatable of e has a "__tostring" field, +then tostring calls the corresponding value +with e as argument, +and uses the result of the call as its result. + + + + +

+


type (v)

+Returns the type of its only argument, coded as a string. +The possible results of this function are +"nil" (a string, not the value nil), +"number", +"string", +"boolean", +"table", +"function", +"thread", +and "userdata". + + + + +

+


unpack (list [, i [, j]])

+Returns the elements from the given table. +This function is equivalent to + +
+     return list[i], list[i+1], ···, list[j]
+

+except that the above code can be written only for a fixed number +of elements. +By default, i is 1 and j is the length of the list, +as defined by the length operator (see §2.5.5). + + + + +

+


_VERSION

+A global variable (not a function) that +holds a string containing the current interpreter version. +The current contents of this variable is "Lua 5.1". + + + + +

+


xpcall (f, err)

+ + +

+This function is similar to pcall, +except that you can set a new error handler. + + +

+xpcall calls function f in protected mode, +using err as the error handler. +Any error inside f is not propagated; +instead, xpcall catches the error, +calls the err function with the original error object, +and returns a status code. +Its first result is the status code (a boolean), +which is true if the call succeeds without errors. +In this case, xpcall also returns all results from the call, +after this first result. +In case of any error, +xpcall returns false plus the result from err. + + + + + + + +

5.2 - Coroutine Manipulation

+ +

+The operations related to coroutines comprise a sub-library of +the basic library and come inside the table coroutine. +See §2.11 for a general description of coroutines. + + +

+


coroutine.create (f)

+ + +

+Creates a new coroutine, with body f. +f must be a Lua function. +Returns this new coroutine, +an object with type "thread". + + + + +

+


coroutine.resume (co [, val1, ···])

+ + +

+Starts or continues the execution of coroutine co. +The first time you resume a coroutine, +it starts running its body. +The values val1, ··· are passed +as the arguments to the body function. +If the coroutine has yielded, +resume restarts it; +the values val1, ··· are passed +as the results from the yield. + + +

+If the coroutine runs without any errors, +resume returns true plus any values passed to yield +(if the coroutine yields) or any values returned by the body function +(if the coroutine terminates). +If there is any error, +resume returns false plus the error message. + + + + +

+


coroutine.running ()

+ + +

+Returns the running coroutine, +or nil when called by the main thread. + + + + +

+


coroutine.status (co)

+ + +

+Returns the status of coroutine co, as a string: +"running", +if the coroutine is running (that is, it called status); +"suspended", if the coroutine is suspended in a call to yield, +or if it has not started running yet; +"normal" if the coroutine is active but not running +(that is, it has resumed another coroutine); +and "dead" if the coroutine has finished its body function, +or if it has stopped with an error. + + + + +

+


coroutine.wrap (f)

+ + +

+Creates a new coroutine, with body f. +f must be a Lua function. +Returns a function that resumes the coroutine each time it is called. +Any arguments passed to the function behave as the +extra arguments to resume. +Returns the same values returned by resume, +except the first boolean. +In case of error, propagates the error. + + + + +

+


coroutine.yield (···)

+ + +

+Suspends the execution of the calling coroutine. +The coroutine cannot be running a C function, +a metamethod, or an iterator. +Any arguments to yield are passed as extra results to resume. + + + + + + + +

5.3 - Modules

+ +

+The package library provides basic +facilities for loading and building modules in Lua. +It exports two of its functions directly in the global environment: +require and module. +Everything else is exported in a table package. + + +

+


module (name [, ···])

+ + +

+Creates a module. +If there is a table in package.loaded[name], +this table is the module. +Otherwise, if there is a global table t with the given name, +this table is the module. +Otherwise creates a new table t and +sets it as the value of the global name and +the value of package.loaded[name]. +This function also initializes t._NAME with the given name, +t._M with the module (t itself), +and t._PACKAGE with the package name +(the full module name minus last component; see below). +Finally, module sets t as the new environment +of the current function and the new value of package.loaded[name], +so that require returns t. + + +

+If name is a compound name +(that is, one with components separated by dots), +module creates (or reuses, if they already exist) +tables for each component. +For instance, if name is a.b.c, +then module stores the module table in field c of +field b of global a. + + +

+This function can receive optional options after +the module name, +where each option is a function to be applied over the module. + + + + +

+


require (modname)

+ + +

+Loads the given module. +The function starts by looking into the package.loaded table +to determine whether modname is already loaded. +If it is, then require returns the value stored +at package.loaded[modname]. +Otherwise, it tries to find a loader for the module. + + +

+To find a loader, +require is guided by the package.loaders array. +By changing this array, +we can change how require looks for a module. +The following explanation is based on the default configuration +for package.loaders. + + +

+First require queries package.preload[modname]. +If it has a value, +this value (which should be a function) is the loader. +Otherwise require searches for a Lua loader using the +path stored in package.path. +If that also fails, it searches for a C loader using the +path stored in package.cpath. +If that also fails, +it tries an all-in-one loader (see package.loaders). + + +

+Once a loader is found, +require calls the loader with a single argument, modname. +If the loader returns any value, +require assigns the returned value to package.loaded[modname]. +If the loader returns no value and +has not assigned any value to package.loaded[modname], +then require assigns true to this entry. +In any case, require returns the +final value of package.loaded[modname]. + + +

+If there is any error loading or running the module, +or if it cannot find any loader for the module, +then require signals an error. + + + + +

+


package.cpath

+ + +

+The path used by require to search for a C loader. + + +

+Lua initializes the C path package.cpath in the same way +it initializes the Lua path package.path, +using the environment variable LUA_CPATH +or a default path defined in luaconf.h. + + + + +

+ +


package.loaded

+ + +

+A table used by require to control which +modules are already loaded. +When you require a module modname and +package.loaded[modname] is not false, +require simply returns the value stored there. + + + + +

+


package.loaders

+ + +

+A table used by require to control how to load modules. + + +

+Each entry in this table is a searcher function. +When looking for a module, +require calls each of these searchers in ascending order, +with the module name (the argument given to require) as its +sole parameter. +The function can return another function (the module loader) +or a string explaining why it did not find that module +(or nil if it has nothing to say). +Lua initializes this table with four functions. + + +

+The first searcher simply looks for a loader in the +package.preload table. + + +

+The second searcher looks for a loader as a Lua library, +using the path stored at package.path. +A path is a sequence of templates separated by semicolons. +For each template, +the searcher will change each interrogation +mark in the template by filename, +which is the module name with each dot replaced by a +"directory separator" (such as "/" in Unix); +then it will try to open the resulting file name. +So, for instance, if the Lua path is the string + +

+     "./?.lua;./?.lc;/usr/local/?/init.lua"
+

+the search for a Lua file for module foo +will try to open the files +./foo.lua, ./foo.lc, and +/usr/local/foo/init.lua, in that order. + + +

+The third searcher looks for a loader as a C library, +using the path given by the variable package.cpath. +For instance, +if the C path is the string + +

+     "./?.so;./?.dll;/usr/local/?/init.so"
+

+the searcher for module foo +will try to open the files ./foo.so, ./foo.dll, +and /usr/local/foo/init.so, in that order. +Once it finds a C library, +this searcher first uses a dynamic link facility to link the +application with the library. +Then it tries to find a C function inside the library to +be used as the loader. +The name of this C function is the string "luaopen_" +concatenated with a copy of the module name where each dot +is replaced by an underscore. +Moreover, if the module name has a hyphen, +its prefix up to (and including) the first hyphen is removed. +For instance, if the module name is a.v1-b.c, +the function name will be luaopen_b_c. + + +

+The fourth searcher tries an all-in-one loader. +It searches the C path for a library for +the root name of the given module. +For instance, when requiring a.b.c, +it will search for a C library for a. +If found, it looks into it for an open function for +the submodule; +in our example, that would be luaopen_a_b_c. +With this facility, a package can pack several C submodules +into one single library, +with each submodule keeping its original open function. + + + + +

+


package.loadlib (libname, funcname)

+ + +

+Dynamically links the host program with the C library libname. +Inside this library, looks for a function funcname +and returns this function as a C function. +(So, funcname must follow the protocol (see lua_CFunction)). + + +

+This is a low-level function. +It completely bypasses the package and module system. +Unlike require, +it does not perform any path searching and +does not automatically adds extensions. +libname must be the complete file name of the C library, +including if necessary a path and extension. +funcname must be the exact name exported by the C library +(which may depend on the C compiler and linker used). + + +

+This function is not supported by ANSI C. +As such, it is only available on some platforms +(Windows, Linux, Mac OS X, Solaris, BSD, +plus other Unix systems that support the dlfcn standard). + + + + +

+


package.path

+ + +

+The path used by require to search for a Lua loader. + + +

+At start-up, Lua initializes this variable with +the value of the environment variable LUA_PATH or +with a default path defined in luaconf.h, +if the environment variable is not defined. +Any ";;" in the value of the environment variable +is replaced by the default path. + + + + +

+


package.preload

+ + +

+A table to store loaders for specific modules +(see require). + + + + +

+


package.seeall (module)

+ + +

+Sets a metatable for module with +its __index field referring to the global environment, +so that this module inherits values +from the global environment. +To be used as an option to function module. + + + + + + + +

5.4 - String Manipulation

+ +

+This library provides generic functions for string manipulation, +such as finding and extracting substrings, and pattern matching. +When indexing a string in Lua, the first character is at position 1 +(not at 0, as in C). +Indices are allowed to be negative and are interpreted as indexing backwards, +from the end of the string. +Thus, the last character is at position -1, and so on. + + +

+The string library provides all its functions inside the table +string. +It also sets a metatable for strings +where the __index field points to the string table. +Therefore, you can use the string functions in object-oriented style. +For instance, string.byte(s, i) +can be written as s:byte(i). + + +

+The string library assumes one-byte character encodings. + + +

+


string.byte (s [, i [, j]])

+Returns the internal numerical codes of the characters s[i], +s[i+1], ···, s[j]. +The default value for i is 1; +the default value for j is i. + + +

+Note that numerical codes are not necessarily portable across platforms. + + + + +

+


string.char (···)

+Receives zero or more integers. +Returns a string with length equal to the number of arguments, +in which each character has the internal numerical code equal +to its corresponding argument. + + +

+Note that numerical codes are not necessarily portable across platforms. + + + + +

+


string.dump (function)

+ + +

+Returns a string containing a binary representation of the given function, +so that a later loadstring on this string returns +a copy of the function. +function must be a Lua function without upvalues. + + + + +

+


string.find (s, pattern [, init [, plain]])

+Looks for the first match of +pattern in the string s. +If it finds a match, then find returns the indices of s +where this occurrence starts and ends; +otherwise, it returns nil. +A third, optional numerical argument init specifies +where to start the search; +its default value is 1 and can be negative. +A value of true as a fourth, optional argument plain +turns off the pattern matching facilities, +so the function does a plain "find substring" operation, +with no characters in pattern being considered "magic". +Note that if plain is given, then init must be given as well. + + +

+If the pattern has captures, +then in a successful match +the captured values are also returned, +after the two indices. + + + + +

+


string.format (formatstring, ···)

+Returns a formatted version of its variable number of arguments +following the description given in its first argument (which must be a string). +The format string follows the same rules as the printf family of +standard C functions. +The only differences are that the options/modifiers +*, l, L, n, p, +and h are not supported +and that there is an extra option, q. +The q option formats a string in a form suitable to be safely read +back by the Lua interpreter: +the string is written between double quotes, +and all double quotes, newlines, embedded zeros, +and backslashes in the string +are correctly escaped when written. +For instance, the call + +
+     string.format('%q', 'a string with "quotes" and \n new line')
+

+will produce the string: + +

+     "a string with \"quotes\" and \
+      new line"
+
+ +

+The options c, d, E, e, f, +g, G, i, o, u, X, and x all +expect a number as argument, +whereas q and s expect a string. + + +

+This function does not accept string values +containing embedded zeros, +except as arguments to the q option. + + + + +

+


string.gmatch (s, pattern)

+Returns an iterator function that, +each time it is called, +returns the next captures from pattern over string s. +If pattern specifies no captures, +then the whole match is produced in each call. + + +

+As an example, the following loop + +

+     s = "hello world from Lua"
+     for w in string.gmatch(s, "%a+") do
+       print(w)
+     end
+

+will iterate over all the words from string s, +printing one per line. +The next example collects all pairs key=value from the +given string into a table: + +

+     t = {}
+     s = "from=world, to=Lua"
+     for k, v in string.gmatch(s, "(%w+)=(%w+)") do
+       t[k] = v
+     end
+
+ +

+For this function, a '^' at the start of a pattern does not +work as an anchor, as this would prevent the iteration. + + + + +

+


string.gsub (s, pattern, repl [, n])

+Returns a copy of s +in which all (or the first n, if given) +occurrences of the pattern have been +replaced by a replacement string specified by repl, +which can be a string, a table, or a function. +gsub also returns, as its second value, +the total number of matches that occurred. + + +

+If repl is a string, then its value is used for replacement. +The character % works as an escape character: +any sequence in repl of the form %n, +with n between 1 and 9, +stands for the value of the n-th captured substring (see below). +The sequence %0 stands for the whole match. +The sequence %% stands for a single %. + + +

+If repl is a table, then the table is queried for every match, +using the first capture as the key; +if the pattern specifies no captures, +then the whole match is used as the key. + + +

+If repl is a function, then this function is called every time a +match occurs, with all captured substrings passed as arguments, +in order; +if the pattern specifies no captures, +then the whole match is passed as a sole argument. + + +

+If the value returned by the table query or by the function call +is a string or a number, +then it is used as the replacement string; +otherwise, if it is false or nil, +then there is no replacement +(that is, the original match is kept in the string). + + +

+Here are some examples: + +

+     x = string.gsub("hello world", "(%w+)", "%1 %1")
+     --> x="hello hello world world"
+     
+     x = string.gsub("hello world", "%w+", "%0 %0", 1)
+     --> x="hello hello world"
+     
+     x = string.gsub("hello world from Lua", "(%w+)%s*(%w+)", "%2 %1")
+     --> x="world hello Lua from"
+     
+     x = string.gsub("home = $HOME, user = $USER", "%$(%w+)", os.getenv)
+     --> x="home = /home/roberto, user = roberto"
+     
+     x = string.gsub("4+5 = $return 4+5$", "%$(.-)%$", function (s)
+           return loadstring(s)()
+         end)
+     --> x="4+5 = 9"
+     
+     local t = {name="lua", version="5.1"}
+     x = string.gsub("$name-$version.tar.gz", "%$(%w+)", t)
+     --> x="lua-5.1.tar.gz"
+
+ + + +

+


string.len (s)

+Receives a string and returns its length. +The empty string "" has length 0. +Embedded zeros are counted, +so "a\000bc\000" has length 5. + + + + +

+


string.lower (s)

+Receives a string and returns a copy of this string with all +uppercase letters changed to lowercase. +All other characters are left unchanged. +The definition of what an uppercase letter is depends on the current locale. + + + + +

+


string.match (s, pattern [, init])

+Looks for the first match of +pattern in the string s. +If it finds one, then match returns +the captures from the pattern; +otherwise it returns nil. +If pattern specifies no captures, +then the whole match is returned. +A third, optional numerical argument init specifies +where to start the search; +its default value is 1 and can be negative. + + + + +

+


string.rep (s, n)

+Returns a string that is the concatenation of n copies of +the string s. + + + + +

+


string.reverse (s)

+Returns a string that is the string s reversed. + + + + +

+


string.sub (s, i [, j])

+Returns the substring of s that +starts at i and continues until j; +i and j can be negative. +If j is absent, then it is assumed to be equal to -1 +(which is the same as the string length). +In particular, +the call string.sub(s,1,j) returns a prefix of s +with length j, +and string.sub(s, -i) returns a suffix of s +with length i. + + + + +

+


string.upper (s)

+Receives a string and returns a copy of this string with all +lowercase letters changed to uppercase. +All other characters are left unchanged. +The definition of what a lowercase letter is depends on the current locale. + + + +

5.4.1 - Patterns

+ + +

Character Class:

+A character class is used to represent a set of characters. +The following combinations are allowed in describing a character class: + +

+For all classes represented by single letters (%a, %c, etc.), +the corresponding uppercase letter represents the complement of the class. +For instance, %S represents all non-space characters. + + +

+The definitions of letter, space, and other character groups +depend on the current locale. +In particular, the class [a-z] may not be equivalent to %l. + + + + + +

Pattern Item:

+A pattern item can be + +

+ + + + +

Pattern:

+A pattern is a sequence of pattern items. +A '^' at the beginning of a pattern anchors the match at the +beginning of the subject string. +A '$' at the end of a pattern anchors the match at the +end of the subject string. +At other positions, +'^' and '$' have no special meaning and represent themselves. + + + + + +

Captures:

+A pattern can contain sub-patterns enclosed in parentheses; +they describe captures. +When a match succeeds, the substrings of the subject string +that match captures are stored (captured) for future use. +Captures are numbered according to their left parentheses. +For instance, in the pattern "(a*(.)%w(%s*))", +the part of the string matching "a*(.)%w(%s*)" is +stored as the first capture (and therefore has number 1); +the character matching "." is captured with number 2, +and the part matching "%s*" has number 3. + + +

+As a special case, the empty capture () captures +the current string position (a number). +For instance, if we apply the pattern "()aa()" on the +string "flaaap", there will be two captures: 3 and 5. + + +

+A pattern cannot contain embedded zeros. Use %z instead. + + + + + + + + + + + +

5.5 - Table Manipulation

+This library provides generic functions for table manipulation. +It provides all its functions inside the table table. + + +

+Most functions in the table library assume that the table +represents an array or a list. +For these functions, when we talk about the "length" of a table +we mean the result of the length operator. + + +

+


table.concat (table [, sep [, i [, j]]])

+Given an array where all elements are strings or numbers, +returns table[i]..sep..table[i+1] ··· sep..table[j]. +The default value for sep is the empty string, +the default for i is 1, +and the default for j is the length of the table. +If i is greater than j, returns the empty string. + + + + +

+


table.insert (table, [pos,] value)

+ + +

+Inserts element value at position pos in table, +shifting up other elements to open space, if necessary. +The default value for pos is n+1, +where n is the length of the table (see §2.5.5), +so that a call table.insert(t,x) inserts x at the end +of table t. + + + + +

+


table.maxn (table)

+ + +

+Returns the largest positive numerical index of the given table, +or zero if the table has no positive numerical indices. +(To do its job this function does a linear traversal of +the whole table.) + + + + +

+


table.remove (table [, pos])

+ + +

+Removes from table the element at position pos, +shifting down other elements to close the space, if necessary. +Returns the value of the removed element. +The default value for pos is n, +where n is the length of the table, +so that a call table.remove(t) removes the last element +of table t. + + + + +

+


table.sort (table [, comp])

+Sorts table elements in a given order, in-place, +from table[1] to table[n], +where n is the length of the table. +If comp is given, +then it must be a function that receives two table elements, +and returns true +when the first is less than the second +(so that not comp(a[i+1],a[i]) will be true after the sort). +If comp is not given, +then the standard Lua operator < is used instead. + + +

+The sort algorithm is not stable; +that is, elements considered equal by the given order +may have their relative positions changed by the sort. + + + + + + + +

5.6 - Mathematical Functions

+ +

+This library is an interface to the standard C math library. +It provides all its functions inside the table math. + + +

+


math.abs (x)

+ + +

+Returns the absolute value of x. + + + + +

+


math.acos (x)

+ + +

+Returns the arc cosine of x (in radians). + + + + +

+


math.asin (x)

+ + +

+Returns the arc sine of x (in radians). + + + + +

+


math.atan (x)

+ + +

+Returns the arc tangent of x (in radians). + + + + +

+


math.atan2 (y, x)

+ + +

+Returns the arc tangent of y/x (in radians), +but uses the signs of both parameters to find the +quadrant of the result. +(It also handles correctly the case of x being zero.) + + + + +

+


math.ceil (x)

+ + +

+Returns the smallest integer larger than or equal to x. + + + + +

+


math.cos (x)

+ + +

+Returns the cosine of x (assumed to be in radians). + + + + +

+


math.cosh (x)

+ + +

+Returns the hyperbolic cosine of x. + + + + +

+


math.deg (x)

+ + +

+Returns the angle x (given in radians) in degrees. + + + + +

+


math.exp (x)

+ + +

+Returns the value ex. + + + + +

+


math.floor (x)

+ + +

+Returns the largest integer smaller than or equal to x. + + + + +

+


math.fmod (x, y)

+ + +

+Returns the remainder of the division of x by y +that rounds the quotient towards zero. + + + + +

+


math.frexp (x)

+ + +

+Returns m and e such that x = m2e, +e is an integer and the absolute value of m is +in the range [0.5, 1) +(or zero when x is zero). + + + + +

+


math.huge

+ + +

+The value HUGE_VAL, +a value larger than or equal to any other numerical value. + + + + +

+


math.ldexp (m, e)

+ + +

+Returns m2e (e should be an integer). + + + + +

+


math.log (x)

+ + +

+Returns the natural logarithm of x. + + + + +

+


math.log10 (x)

+ + +

+Returns the base-10 logarithm of x. + + + + +

+


math.max (x, ···)

+ + +

+Returns the maximum value among its arguments. + + + + +

+


math.min (x, ···)

+ + +

+Returns the minimum value among its arguments. + + + + +

+


math.modf (x)

+ + +

+Returns two numbers, +the integral part of x and the fractional part of x. + + + + +

+


math.pi

+ + +

+The value of pi. + + + + +

+


math.pow (x, y)

+ + +

+Returns xy. +(You can also use the expression x^y to compute this value.) + + + + +

+


math.rad (x)

+ + +

+Returns the angle x (given in degrees) in radians. + + + + +

+


math.random ([m [, n]])

+ + +

+This function is an interface to the simple +pseudo-random generator function rand provided by ANSI C. +(No guarantees can be given for its statistical properties.) + + +

+When called without arguments, +returns a uniform pseudo-random real number +in the range [0,1). +When called with an integer number m, +math.random returns +a uniform pseudo-random integer in the range [1, m]. +When called with two integer numbers m and n, +math.random returns a uniform pseudo-random +integer in the range [m, n]. + + + + +

+


math.randomseed (x)

+ + +

+Sets x as the "seed" +for the pseudo-random generator: +equal seeds produce equal sequences of numbers. + + + + +

+


math.sin (x)

+ + +

+Returns the sine of x (assumed to be in radians). + + + + +

+


math.sinh (x)

+ + +

+Returns the hyperbolic sine of x. + + + + +

+


math.sqrt (x)

+ + +

+Returns the square root of x. +(You can also use the expression x^0.5 to compute this value.) + + + + +

+


math.tan (x)

+ + +

+Returns the tangent of x (assumed to be in radians). + + + + +

+


math.tanh (x)

+ + +

+Returns the hyperbolic tangent of x. + + + + + + + +

5.7 - Input and Output Facilities

+ +

+The I/O library provides two different styles for file manipulation. +The first one uses implicit file descriptors; +that is, there are operations to set a default input file and a +default output file, +and all input/output operations are over these default files. +The second style uses explicit file descriptors. + + +

+When using implicit file descriptors, +all operations are supplied by table io. +When using explicit file descriptors, +the operation io.open returns a file descriptor +and then all operations are supplied as methods of the file descriptor. + + +

+The table io also provides +three predefined file descriptors with their usual meanings from C: +io.stdin, io.stdout, and io.stderr. +The I/O library never closes these files. + + +

+Unless otherwise stated, +all I/O functions return nil on failure +(plus an error message as a second result and +a system-dependent error code as a third result) +and some value different from nil on success. + + +

+


io.close ([file])

+ + +

+Equivalent to file:close(). +Without a file, closes the default output file. + + + + +

+


io.flush ()

+ + +

+Equivalent to file:flush over the default output file. + + + + +

+


io.input ([file])

+ + +

+When called with a file name, it opens the named file (in text mode), +and sets its handle as the default input file. +When called with a file handle, +it simply sets this file handle as the default input file. +When called without parameters, +it returns the current default input file. + + +

+In case of errors this function raises the error, +instead of returning an error code. + + + + +

+


io.lines ([filename])

+ + +

+Opens the given file name in read mode +and returns an iterator function that, +each time it is called, +returns a new line from the file. +Therefore, the construction + +

+     for line in io.lines(filename) do body end
+

+will iterate over all lines of the file. +When the iterator function detects the end of file, +it returns nil (to finish the loop) and automatically closes the file. + + +

+The call io.lines() (with no file name) is equivalent +to io.input():lines(); +that is, it iterates over the lines of the default input file. +In this case it does not close the file when the loop ends. + + + + +

+


io.open (filename [, mode])

+ + +

+This function opens a file, +in the mode specified in the string mode. +It returns a new file handle, +or, in case of errors, nil plus an error message. + + +

+The mode string can be any of the following: + +

+The mode string can also have a 'b' at the end, +which is needed in some systems to open the file in binary mode. +This string is exactly what is used in the +standard C function fopen. + + + + +

+


io.output ([file])

+ + +

+Similar to io.input, but operates over the default output file. + + + + +

+


io.popen (prog [, mode])

+ + +

+Starts program prog in a separated process and returns +a file handle that you can use to read data from this program +(if mode is "r", the default) +or to write data to this program +(if mode is "w"). + + +

+This function is system dependent and is not available +on all platforms. + + + + +

+


io.read (···)

+ + +

+Equivalent to io.input():read. + + + + +

+


io.tmpfile ()

+ + +

+Returns a handle for a temporary file. +This file is opened in update mode +and it is automatically removed when the program ends. + + + + +

+


io.type (obj)

+ + +

+Checks whether obj is a valid file handle. +Returns the string "file" if obj is an open file handle, +"closed file" if obj is a closed file handle, +or nil if obj is not a file handle. + + + + +

+


io.write (···)

+ + +

+Equivalent to io.output():write. + + + + +

+


file:close ()

+ + +

+Closes file. +Note that files are automatically closed when +their handles are garbage collected, +but that takes an unpredictable amount of time to happen. + + + + +

+


file:flush ()

+ + +

+Saves any written data to file. + + + + +

+


file:lines ()

+ + +

+Returns an iterator function that, +each time it is called, +returns a new line from the file. +Therefore, the construction + +

+     for line in file:lines() do body end
+

+will iterate over all lines of the file. +(Unlike io.lines, this function does not close the file +when the loop ends.) + + + + +

+


file:read (···)

+ + +

+Reads the file file, +according to the given formats, which specify what to read. +For each format, +the function returns a string (or a number) with the characters read, +or nil if it cannot read data with the specified format. +When called without formats, +it uses a default format that reads the entire next line +(see below). + + +

+The available formats are + +

+ + + +

+


file:seek ([whence] [, offset])

+ + +

+Sets and gets the file position, +measured from the beginning of the file, +to the position given by offset plus a base +specified by the string whence, as follows: + +

+In case of success, function seek returns the final file position, +measured in bytes from the beginning of the file. +If this function fails, it returns nil, +plus a string describing the error. + + +

+The default value for whence is "cur", +and for offset is 0. +Therefore, the call file:seek() returns the current +file position, without changing it; +the call file:seek("set") sets the position to the +beginning of the file (and returns 0); +and the call file:seek("end") sets the position to the +end of the file, and returns its size. + + + + +

+


file:setvbuf (mode [, size])

+ + +

+Sets the buffering mode for an output file. +There are three available modes: + +

+For the last two cases, size +specifies the size of the buffer, in bytes. +The default is an appropriate size. + + + + +

+


file:write (···)

+ + +

+Writes the value of each of its arguments to +the file. +The arguments must be strings or numbers. +To write other values, +use tostring or string.format before write. + + + + + + + +

5.8 - Operating System Facilities

+ +

+This library is implemented through table os. + + +

+


os.clock ()

+ + +

+Returns an approximation of the amount in seconds of CPU time +used by the program. + + + + +

+


os.date ([format [, time]])

+ + +

+Returns a string or a table containing date and time, +formatted according to the given string format. + + +

+If the time argument is present, +this is the time to be formatted +(see the os.time function for a description of this value). +Otherwise, date formats the current time. + + +

+If format starts with '!', +then the date is formatted in Coordinated Universal Time. +After this optional character, +if format is the string "*t", +then date returns a table with the following fields: +year (four digits), month (1--12), day (1--31), +hour (0--23), min (0--59), sec (0--61), +wday (weekday, Sunday is 1), +yday (day of the year), +and isdst (daylight saving flag, a boolean). + + +

+If format is not "*t", +then date returns the date as a string, +formatted according to the same rules as the C function strftime. + + +

+When called without arguments, +date returns a reasonable date and time representation that depends on +the host system and on the current locale +(that is, os.date() is equivalent to os.date("%c")). + + + + +

+


os.difftime (t2, t1)

+ + +

+Returns the number of seconds from time t1 to time t2. +In POSIX, Windows, and some other systems, +this value is exactly t2-t1. + + + + +

+


os.execute ([command])

+ + +

+This function is equivalent to the C function system. +It passes command to be executed by an operating system shell. +It returns a status code, which is system-dependent. +If command is absent, then it returns nonzero if a shell is available +and zero otherwise. + + + + +

+


os.exit ([code])

+ + +

+Calls the C function exit, +with an optional code, +to terminate the host program. +The default value for code is the success code. + + + + +

+


os.getenv (varname)

+ + +

+Returns the value of the process environment variable varname, +or nil if the variable is not defined. + + + + +

+


os.remove (filename)

+ + +

+Deletes the file or directory with the given name. +Directories must be empty to be removed. +If this function fails, it returns nil, +plus a string describing the error. + + + + +

+


os.rename (oldname, newname)

+ + +

+Renames file or directory named oldname to newname. +If this function fails, it returns nil, +plus a string describing the error. + + + + +

+


os.setlocale (locale [, category])

+ + +

+Sets the current locale of the program. +locale is a string specifying a locale; +category is an optional string describing which category to change: +"all", "collate", "ctype", +"monetary", "numeric", or "time"; +the default category is "all". +The function returns the name of the new locale, +or nil if the request cannot be honored. + + +

+If locale is the empty string, +the current locale is set to an implementation-defined native locale. +If locale is the string "C", +the current locale is set to the standard C locale. + + +

+When called with nil as the first argument, +this function only returns the name of the current locale +for the given category. + + + + +

+


os.time ([table])

+ + +

+Returns the current time when called without arguments, +or a time representing the date and time specified by the given table. +This table must have fields year, month, and day, +and may have fields hour, min, sec, and isdst +(for a description of these fields, see the os.date function). + + +

+The returned value is a number, whose meaning depends on your system. +In POSIX, Windows, and some other systems, this number counts the number +of seconds since some given start time (the "epoch"). +In other systems, the meaning is not specified, +and the number returned by time can be used only as an argument to +date and difftime. + + + + +

+


os.tmpname ()

+ + +

+Returns a string with a file name that can +be used for a temporary file. +The file must be explicitly opened before its use +and explicitly removed when no longer needed. + + +

+On some systems (POSIX), +this function also creates a file with that name, +to avoid security risks. +(Someone else might create the file with wrong permissions +in the time between getting the name and creating the file.) +You still have to open the file to use it +and to remove it (even if you do not use it). + + +

+When possible, +you may prefer to use io.tmpfile, +which automatically removes the file when the program ends. + + + + + + + +

5.9 - The Debug Library

+ +

+This library provides +the functionality of the debug interface to Lua programs. +You should exert care when using this library. +The functions provided here should be used exclusively for debugging +and similar tasks, such as profiling. +Please resist the temptation to use them as a +usual programming tool: +they can be very slow. +Moreover, several of these functions +violate some assumptions about Lua code +(e.g., that variables local to a function +cannot be accessed from outside or +that userdata metatables cannot be changed by Lua code) +and therefore can compromise otherwise secure code. + + +

+All functions in this library are provided +inside the debug table. +All functions that operate over a thread +have an optional first argument which is the +thread to operate over. +The default is always the current thread. + + +

+


debug.debug ()

+ + +

+Enters an interactive mode with the user, +running each string that the user enters. +Using simple commands and other debug facilities, +the user can inspect global and local variables, +change their values, evaluate expressions, and so on. +A line containing only the word cont finishes this function, +so that the caller continues its execution. + + +

+Note that commands for debug.debug are not lexically nested +within any function, and so have no direct access to local variables. + + + + +

+


debug.getfenv (o)

+Returns the environment of object o. + + + + +

+


debug.gethook ([thread])

+ + +

+Returns the current hook settings of the thread, as three values: +the current hook function, the current hook mask, +and the current hook count +(as set by the debug.sethook function). + + + + +

+


debug.getinfo ([thread,] function [, what])

+ + +

+Returns a table with information about a function. +You can give the function directly, +or you can give a number as the value of function, +which means the function running at level function of the call stack +of the given thread: +level 0 is the current function (getinfo itself); +level 1 is the function that called getinfo; +and so on. +If function is a number larger than the number of active functions, +then getinfo returns nil. + + +

+The returned table can contain all the fields returned by lua_getinfo, +with the string what describing which fields to fill in. +The default for what is to get all information available, +except the table of valid lines. +If present, +the option 'f' +adds a field named func with the function itself. +If present, +the option 'L' +adds a field named activelines with the table of +valid lines. + + +

+For instance, the expression debug.getinfo(1,"n").name returns +a table with a name for the current function, +if a reasonable name can be found, +and the expression debug.getinfo(print) +returns a table with all available information +about the print function. + + + + +

+


debug.getlocal ([thread,] level, local)

+ + +

+This function returns the name and the value of the local variable +with index local of the function at level level of the stack. +(The first parameter or local variable has index 1, and so on, +until the last active local variable.) +The function returns nil if there is no local +variable with the given index, +and raises an error when called with a level out of range. +(You can call debug.getinfo to check whether the level is valid.) + + +

+Variable names starting with '(' (open parentheses) +represent internal variables +(loop control variables, temporaries, and C function locals). + + + + +

+


debug.getmetatable (object)

+ + +

+Returns the metatable of the given object +or nil if it does not have a metatable. + + + + +

+


debug.getregistry ()

+ + +

+Returns the registry table (see §3.5). + + + + +

+


debug.getupvalue (func, up)

+ + +

+This function returns the name and the value of the upvalue +with index up of the function func. +The function returns nil if there is no upvalue with the given index. + + + + +

+


debug.setfenv (object, table)

+ + +

+Sets the environment of the given object to the given table. +Returns object. + + + + +

+


debug.sethook ([thread,] hook, mask [, count])

+ + +

+Sets the given function as a hook. +The string mask and the number count describe +when the hook will be called. +The string mask may have the following characters, +with the given meaning: + +

+With a count different from zero, +the hook is called after every count instructions. + + +

+When called without arguments, +debug.sethook turns off the hook. + + +

+When the hook is called, its first parameter is a string +describing the event that has triggered its call: +"call", "return" (or "tail return", +when simulating a return from a tail call), +"line", and "count". +For line events, +the hook also gets the new line number as its second parameter. +Inside a hook, +you can call getinfo with level 2 to get more information about +the running function +(level 0 is the getinfo function, +and level 1 is the hook function), +unless the event is "tail return". +In this case, Lua is only simulating the return, +and a call to getinfo will return invalid data. + + + + +

+


debug.setlocal ([thread,] level, local, value)

+ + +

+This function assigns the value value to the local variable +with index local of the function at level level of the stack. +The function returns nil if there is no local +variable with the given index, +and raises an error when called with a level out of range. +(You can call getinfo to check whether the level is valid.) +Otherwise, it returns the name of the local variable. + + + + +

+


debug.setmetatable (object, table)

+ + +

+Sets the metatable for the given object to the given table +(which can be nil). + + + + +

+


debug.setupvalue (func, up, value)

+ + +

+This function assigns the value value to the upvalue +with index up of the function func. +The function returns nil if there is no upvalue +with the given index. +Otherwise, it returns the name of the upvalue. + + + + +

+


debug.traceback ([thread,] [message [, level]])

+ + +

+Returns a string with a traceback of the call stack. +An optional message string is appended +at the beginning of the traceback. +An optional level number tells at which level +to start the traceback +(default is 1, the function calling traceback). + + + + + + + +

6 - Lua Stand-alone

+ +

+Although Lua has been designed as an extension language, +to be embedded in a host C program, +it is also frequently used as a stand-alone language. +An interpreter for Lua as a stand-alone language, +called simply lua, +is provided with the standard distribution. +The stand-alone interpreter includes +all standard libraries, including the debug library. +Its usage is: + +

+     lua [options] [script [args]]
+

+The options are: + +

+After handling its options, lua runs the given script, +passing to it the given args as string arguments. +When called without arguments, +lua behaves as lua -v -i +when the standard input (stdin) is a terminal, +and as lua - otherwise. + + +

+Before running any argument, +the interpreter checks for an environment variable LUA_INIT. +If its format is @filename, +then lua executes the file. +Otherwise, lua executes the string itself. + + +

+All options are handled in order, except -i. +For instance, an invocation like + +

+     $ lua -e'a=1' -e 'print(a)' script.lua
+

+will first set a to 1, then print the value of a (which is '1'), +and finally run the file script.lua with no arguments. +(Here $ is the shell prompt. Your prompt may be different.) + + +

+Before starting to run the script, +lua collects all arguments in the command line +in a global table called arg. +The script name is stored at index 0, +the first argument after the script name goes to index 1, +and so on. +Any arguments before the script name +(that is, the interpreter name plus the options) +go to negative indices. +For instance, in the call + +

+     $ lua -la b.lua t1 t2
+

+the interpreter first runs the file a.lua, +then creates a table + +

+     arg = { [-2] = "lua", [-1] = "-la",
+             [0] = "b.lua",
+             [1] = "t1", [2] = "t2" }
+

+and finally runs the file b.lua. +The script is called with arg[1], arg[2], ··· +as arguments; +it can also access these arguments with the vararg expression '...'. + + +

+In interactive mode, +if you write an incomplete statement, +the interpreter waits for its completion +by issuing a different prompt. + + +

+If the global variable _PROMPT contains a string, +then its value is used as the prompt. +Similarly, if the global variable _PROMPT2 contains a string, +its value is used as the secondary prompt +(issued during incomplete statements). +Therefore, both prompts can be changed directly on the command line +or in any Lua programs by assigning to _PROMPT. +See the next example: + +

+     $ lua -e"_PROMPT='myprompt> '" -i
+

+(The outer pair of quotes is for the shell, +the inner pair is for Lua.) +Note the use of -i to enter interactive mode; +otherwise, +the program would just end silently +right after the assignment to _PROMPT. + + +

+To allow the use of Lua as a +script interpreter in Unix systems, +the stand-alone interpreter skips +the first line of a chunk if it starts with #. +Therefore, Lua scripts can be made into executable programs +by using chmod +x and the #! form, +as in + +

+     #!/usr/local/bin/lua
+

+(Of course, +the location of the Lua interpreter may be different in your machine. +If lua is in your PATH, +then + +

+     #!/usr/bin/env lua
+

+is a more portable solution.) + + + +

7 - Incompatibilities with the Previous Version

+ +

+Here we list the incompatibilities that you may find when moving a program +from Lua 5.0 to Lua 5.1. +You can avoid most of the incompatibilities compiling Lua with +appropriate options (see file luaconf.h). +However, +all these compatibility options will be removed in the next version of Lua. + + + +

7.1 - Changes in the Language

+ + + + + +

7.2 - Changes in the Libraries

+ + + + + +

7.3 - Changes in the API

+ + + + + +

8 - The Complete Syntax of Lua

+ +

+Here is the complete syntax of Lua in extended BNF. +(It does not describe operator precedences.) + + + + +

+
+	chunk ::= {stat [`;´]} [laststat [`;´]]
+
+	block ::= chunk
+
+	stat ::=  varlist `=´ explist | 
+		 functioncall | 
+		 do block end | 
+		 while exp do block end | 
+		 repeat block until exp | 
+		 if exp then block {elseif exp then block} [else block] end | 
+		 for Name `=´ exp `,´ exp [`,´ exp] do block end | 
+		 for namelist in explist do block end | 
+		 function funcname funcbody | 
+		 local function Name funcbody | 
+		 local namelist [`=´ explist] 
+
+	laststat ::= return [explist] | break
+
+	funcname ::= Name {`.´ Name} [`:´ Name]
+
+	varlist ::= var {`,´ var}
+
+	var ::=  Name | prefixexp `[´ exp `]´ | prefixexp `.´ Name 
+
+	namelist ::= Name {`,´ Name}
+
+	explist ::= {exp `,´} exp
+
+	exp ::=  nil | false | true | Number | String | `...´ | function | 
+		 prefixexp | tableconstructor | exp binop exp | unop exp 
+
+	prefixexp ::= var | functioncall | `(´ exp `)´
+
+	functioncall ::=  prefixexp args | prefixexp `:´ Name args 
+
+	args ::=  `(´ [explist] `)´ | tableconstructor | String 
+
+	function ::= function funcbody
+
+	funcbody ::= `(´ [parlist] `)´ block end
+
+	parlist ::= namelist [`,´ `...´] | `...´
+
+	tableconstructor ::= `{´ [fieldlist] `}´
+
+	fieldlist ::= field {fieldsep field} [fieldsep]
+
+	field ::= `[´ exp `]´ `=´ exp | Name `=´ exp | exp
+
+	fieldsep ::= `,´ | `;´
+
+	binop ::= `+´ | `-´ | `*´ | `/´ | `^´ | `%´ | `..´ | 
+		 `<´ | `<=´ | `>´ | `>=´ | `==´ | `~=´ | 
+		 and | or
+
+	unop ::= `-´ | not | `#´
+
+
+ +

+ + + + + + + +


+ +Last update: +Mon Feb 13 18:54:19 BRST 2012 + + + + + diff --git a/extern/lua-5.1.5/doc/readme.html b/extern/lua-5.1.5/doc/readme.html new file mode 100644 index 00000000..3ed6a818 --- /dev/null +++ b/extern/lua-5.1.5/doc/readme.html @@ -0,0 +1,40 @@ + + +Lua documentation + + + + + +
+

+Lua +Documentation +

+ +This is the documentation included in the source distribution of Lua 5.1.5. + + + +Lua's +official web site +contains updated documentation, +especially the +reference manual. +

+ +


+ +Last update: +Fri Feb 3 09:44:42 BRST 2012 + + + + diff --git a/extern/lua-5.1.5/etc/Makefile b/extern/lua-5.1.5/etc/Makefile new file mode 100644 index 00000000..6d00008d --- /dev/null +++ b/extern/lua-5.1.5/etc/Makefile @@ -0,0 +1,44 @@ +# makefile for Lua etc + +TOP= .. +LIB= $(TOP)/src +INC= $(TOP)/src +BIN= $(TOP)/src +SRC= $(TOP)/src +TST= $(TOP)/test + +CC= gcc +CFLAGS= -O2 -Wall -I$(INC) $(MYCFLAGS) +MYCFLAGS= +MYLDFLAGS= -Wl,-E +MYLIBS= -lm +#MYLIBS= -lm -Wl,-E -ldl -lreadline -lhistory -lncurses +RM= rm -f + +default: + @echo 'Please choose a target: min noparser one strict clean' + +min: min.c + $(CC) $(CFLAGS) $@.c -L$(LIB) -llua $(MYLIBS) + echo 'print"Hello there!"' | ./a.out + +noparser: noparser.o + $(CC) noparser.o $(SRC)/lua.o -L$(LIB) -llua $(MYLIBS) + $(BIN)/luac $(TST)/hello.lua + -./a.out luac.out + -./a.out -e'a=1' + +one: + $(CC) $(CFLAGS) all.c $(MYLIBS) + ./a.out $(TST)/hello.lua + +strict: + -$(BIN)/lua -e 'print(a);b=2' + -$(BIN)/lua -lstrict -e 'print(a)' + -$(BIN)/lua -e 'function f() b=2 end f()' + -$(BIN)/lua -lstrict -e 'function f() b=2 end f()' + +clean: + $(RM) a.out core core.* *.o luac.out + +.PHONY: default min noparser one strict clean diff --git a/extern/lua-5.1.5/etc/README b/extern/lua-5.1.5/etc/README new file mode 100644 index 00000000..5149fc91 --- /dev/null +++ b/extern/lua-5.1.5/etc/README @@ -0,0 +1,37 @@ +This directory contains some useful files and code. +Unlike the code in ../src, everything here is in the public domain. + +If any of the makes fail, you're probably not using the same libraries +used to build Lua. Set MYLIBS in Makefile accordingly. + +all.c + Full Lua interpreter in a single file. + Do "make one" for a demo. + +lua.hpp + Lua header files for C++ using 'extern "C"'. + +lua.ico + A Lua icon for Windows (and web sites: save as favicon.ico). + Drawn by hand by Markus Gritsch . + +lua.pc + pkg-config data for Lua + +luavs.bat + Script to build Lua under "Visual Studio .NET Command Prompt". + Run it from the toplevel as etc\luavs.bat. + +min.c + A minimal Lua interpreter. + Good for learning and for starting your own. + Do "make min" for a demo. + +noparser.c + Linking with noparser.o avoids loading the parsing modules in lualib.a. + Do "make noparser" for a demo. + +strict.lua + Traps uses of undeclared global variables. + Do "make strict" for a demo. + diff --git a/extern/lua-5.1.5/etc/all.c b/extern/lua-5.1.5/etc/all.c new file mode 100644 index 00000000..dab68fac --- /dev/null +++ b/extern/lua-5.1.5/etc/all.c @@ -0,0 +1,38 @@ +/* +* all.c -- Lua core, libraries and interpreter in a single file +*/ + +#define luaall_c + +#include "lapi.c" +#include "lcode.c" +#include "ldebug.c" +#include "ldo.c" +#include "ldump.c" +#include "lfunc.c" +#include "lgc.c" +#include "llex.c" +#include "lmem.c" +#include "lobject.c" +#include "lopcodes.c" +#include "lparser.c" +#include "lstate.c" +#include "lstring.c" +#include "ltable.c" +#include "ltm.c" +#include "lundump.c" +#include "lvm.c" +#include "lzio.c" + +#include "lauxlib.c" +#include "lbaselib.c" +#include "ldblib.c" +#include "liolib.c" +#include "linit.c" +#include "lmathlib.c" +#include "loadlib.c" +#include "loslib.c" +#include "lstrlib.c" +#include "ltablib.c" + +#include "lua.c" diff --git a/extern/lua-5.1.5/etc/lua.hpp b/extern/lua-5.1.5/etc/lua.hpp new file mode 100644 index 00000000..ec417f59 --- /dev/null +++ b/extern/lua-5.1.5/etc/lua.hpp @@ -0,0 +1,9 @@ +// lua.hpp +// Lua header files for C++ +// <> not supplied automatically because Lua also compiles as C++ + +extern "C" { +#include "lua.h" +#include "lualib.h" +#include "lauxlib.h" +} diff --git a/extern/lua-5.1.5/etc/lua.ico b/extern/lua-5.1.5/etc/lua.ico new file mode 100644 index 00000000..ccbabc4e Binary files /dev/null and b/extern/lua-5.1.5/etc/lua.ico differ diff --git a/extern/lua-5.1.5/etc/lua.pc b/extern/lua-5.1.5/etc/lua.pc new file mode 100644 index 00000000..07e2852b --- /dev/null +++ b/extern/lua-5.1.5/etc/lua.pc @@ -0,0 +1,31 @@ +# lua.pc -- pkg-config data for Lua + +# vars from install Makefile + +# grep '^V=' ../Makefile +V= 5.1 +# grep '^R=' ../Makefile +R= 5.1.5 + +# grep '^INSTALL_.*=' ../Makefile | sed 's/INSTALL_TOP/prefix/' +prefix= /usr/local +INSTALL_BIN= ${prefix}/bin +INSTALL_INC= ${prefix}/include +INSTALL_LIB= ${prefix}/lib +INSTALL_MAN= ${prefix}/man/man1 +INSTALL_LMOD= ${prefix}/share/lua/${V} +INSTALL_CMOD= ${prefix}/lib/lua/${V} + +# canonical vars +exec_prefix=${prefix} +libdir=${exec_prefix}/lib +includedir=${prefix}/include + +Name: Lua +Description: An Extensible Extension Language +Version: ${R} +Requires: +Libs: -L${libdir} -llua -lm +Cflags: -I${includedir} + +# (end of lua.pc) diff --git a/extern/lua-5.1.5/etc/luavs.bat b/extern/lua-5.1.5/etc/luavs.bat new file mode 100644 index 00000000..08c2bedd --- /dev/null +++ b/extern/lua-5.1.5/etc/luavs.bat @@ -0,0 +1,28 @@ +@rem Script to build Lua under "Visual Studio .NET Command Prompt". +@rem Do not run from this directory; run it from the toplevel: etc\luavs.bat . +@rem It creates lua51.dll, lua51.lib, lua.exe, and luac.exe in src. +@rem (contributed by David Manura and Mike Pall) + +@setlocal +@set MYCOMPILE=cl /nologo /MD /O2 /W3 /c /D_CRT_SECURE_NO_DEPRECATE +@set MYLINK=link /nologo +@set MYMT=mt /nologo + +cd src +%MYCOMPILE% /DLUA_BUILD_AS_DLL l*.c +del lua.obj luac.obj +%MYLINK% /DLL /out:lua51.dll l*.obj +if exist lua51.dll.manifest^ + %MYMT% -manifest lua51.dll.manifest -outputresource:lua51.dll;2 +%MYCOMPILE% /DLUA_BUILD_AS_DLL lua.c +%MYLINK% /out:lua.exe lua.obj lua51.lib +if exist lua.exe.manifest^ + %MYMT% -manifest lua.exe.manifest -outputresource:lua.exe +%MYCOMPILE% l*.c print.c +del lua.obj linit.obj lbaselib.obj ldblib.obj liolib.obj lmathlib.obj^ + loslib.obj ltablib.obj lstrlib.obj loadlib.obj +%MYLINK% /out:luac.exe *.obj +if exist luac.exe.manifest^ + %MYMT% -manifest luac.exe.manifest -outputresource:luac.exe +del *.obj *.manifest +cd .. diff --git a/extern/lua-5.1.5/etc/min.c b/extern/lua-5.1.5/etc/min.c new file mode 100644 index 00000000..6a85a4d1 --- /dev/null +++ b/extern/lua-5.1.5/etc/min.c @@ -0,0 +1,39 @@ +/* +* min.c -- a minimal Lua interpreter +* loads stdin only with minimal error handling. +* no interaction, and no standard library, only a "print" function. +*/ + +#include + +#include "lua.h" +#include "lauxlib.h" + +static int print(lua_State *L) +{ + int n=lua_gettop(L); + int i; + for (i=1; i<=n; i++) + { + if (i>1) printf("\t"); + if (lua_isstring(L,i)) + printf("%s",lua_tostring(L,i)); + else if (lua_isnil(L,i)) + printf("%s","nil"); + else if (lua_isboolean(L,i)) + printf("%s",lua_toboolean(L,i) ? "true" : "false"); + else + printf("%s:%p",luaL_typename(L,i),lua_topointer(L,i)); + } + printf("\n"); + return 0; +} + +int main(void) +{ + lua_State *L=lua_open(); + lua_register(L,"print",print); + if (luaL_dofile(L,NULL)!=0) fprintf(stderr,"%s\n",lua_tostring(L,-1)); + lua_close(L); + return 0; +} diff --git a/extern/lua-5.1.5/etc/noparser.c b/extern/lua-5.1.5/etc/noparser.c new file mode 100644 index 00000000..13ba5462 --- /dev/null +++ b/extern/lua-5.1.5/etc/noparser.c @@ -0,0 +1,50 @@ +/* +* The code below can be used to make a Lua core that does not contain the +* parsing modules (lcode, llex, lparser), which represent 35% of the total core. +* You'll only be able to load binary files and strings, precompiled with luac. +* (Of course, you'll have to build luac with the original parsing modules!) +* +* To use this module, simply compile it ("make noparser" does that) and list +* its object file before the Lua libraries. The linker should then not load +* the parsing modules. To try it, do "make luab". +* +* If you also want to avoid the dump module (ldump.o), define NODUMP. +* #define NODUMP +*/ + +#define LUA_CORE + +#include "llex.h" +#include "lparser.h" +#include "lzio.h" + +LUAI_FUNC void luaX_init (lua_State *L) { + UNUSED(L); +} + +LUAI_FUNC Proto *luaY_parser (lua_State *L, ZIO *z, Mbuffer *buff, const char *name) { + UNUSED(z); + UNUSED(buff); + UNUSED(name); + lua_pushliteral(L,"parser not loaded"); + lua_error(L); + return NULL; +} + +#ifdef NODUMP +#include "lundump.h" + +LUAI_FUNC int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip) { + UNUSED(f); + UNUSED(w); + UNUSED(data); + UNUSED(strip); +#if 1 + UNUSED(L); + return 0; +#else + lua_pushliteral(L,"dumper not loaded"); + lua_error(L); +#endif +} +#endif diff --git a/extern/lua-5.1.5/etc/strict.lua b/extern/lua-5.1.5/etc/strict.lua new file mode 100644 index 00000000..604619dd --- /dev/null +++ b/extern/lua-5.1.5/etc/strict.lua @@ -0,0 +1,41 @@ +-- +-- strict.lua +-- checks uses of undeclared global variables +-- All global variables must be 'declared' through a regular assignment +-- (even assigning nil will do) in a main chunk before being used +-- anywhere or assigned to inside a function. +-- + +local getinfo, error, rawset, rawget = debug.getinfo, error, rawset, rawget + +local mt = getmetatable(_G) +if mt == nil then + mt = {} + setmetatable(_G, mt) +end + +mt.__declared = {} + +local function what () + local d = getinfo(3, "S") + return d and d.what or "C" +end + +mt.__newindex = function (t, n, v) + if not mt.__declared[n] then + local w = what() + if w ~= "main" and w ~= "C" then + error("assign to undeclared variable '"..n.."'", 2) + end + mt.__declared[n] = true + end + rawset(t, n, v) +end + +mt.__index = function (t, n) + if not mt.__declared[n] and what() ~= "C" then + error("variable '"..n.."' is not declared", 2) + end + return rawget(t, n) +end + diff --git a/extern/lua-5.1.5/src/Makefile b/extern/lua-5.1.5/src/Makefile new file mode 100644 index 00000000..e0d4c9fa --- /dev/null +++ b/extern/lua-5.1.5/src/Makefile @@ -0,0 +1,182 @@ +# makefile for building Lua +# see ../INSTALL for installation instructions +# see ../Makefile and luaconf.h for further customization + +# == CHANGE THE SETTINGS BELOW TO SUIT YOUR ENVIRONMENT ======================= + +# Your platform. See PLATS for possible values. +PLAT= none + +CC= gcc +CFLAGS= -O2 -Wall $(MYCFLAGS) +AR= ar rcu +RANLIB= ranlib +RM= rm -f +LIBS= -lm $(MYLIBS) + +MYCFLAGS= +MYLDFLAGS= +MYLIBS= + +# == END OF USER SETTINGS. NO NEED TO CHANGE ANYTHING BELOW THIS LINE ========= + +PLATS= aix ansi bsd freebsd generic linux macosx mingw posix solaris + +LUA_A= liblua.a +CORE_O= lapi.o lcode.o ldebug.o ldo.o ldump.o lfunc.o lgc.o llex.o lmem.o \ + lobject.o lopcodes.o lparser.o lstate.o lstring.o ltable.o ltm.o \ + lundump.o lvm.o lzio.o +LIB_O= lauxlib.o lbaselib.o ldblib.o liolib.o lmathlib.o loslib.o ltablib.o \ + lstrlib.o loadlib.o linit.o + +LUA_T= lua +LUA_O= lua.o + +LUAC_T= luac +LUAC_O= luac.o print.o + +ALL_O= $(CORE_O) $(LIB_O) $(LUA_O) $(LUAC_O) +ALL_T= $(LUA_A) $(LUA_T) $(LUAC_T) +ALL_A= $(LUA_A) + +default: $(PLAT) + +all: $(ALL_T) + +o: $(ALL_O) + +a: $(ALL_A) + +$(LUA_A): $(CORE_O) $(LIB_O) + $(AR) $@ $(CORE_O) $(LIB_O) # DLL needs all object files + $(RANLIB) $@ + +$(LUA_T): $(LUA_O) $(LUA_A) + $(CC) -o $@ $(MYLDFLAGS) $(LUA_O) $(LUA_A) $(LIBS) + +$(LUAC_T): $(LUAC_O) $(LUA_A) + $(CC) -o $@ $(MYLDFLAGS) $(LUAC_O) $(LUA_A) $(LIBS) + +clean: + $(RM) $(ALL_T) $(ALL_O) + +depend: + @$(CC) $(CFLAGS) -MM l*.c print.c + +echo: + @echo "PLAT = $(PLAT)" + @echo "CC = $(CC)" + @echo "CFLAGS = $(CFLAGS)" + @echo "AR = $(AR)" + @echo "RANLIB = $(RANLIB)" + @echo "RM = $(RM)" + @echo "MYCFLAGS = $(MYCFLAGS)" + @echo "MYLDFLAGS = $(MYLDFLAGS)" + @echo "MYLIBS = $(MYLIBS)" + +# convenience targets for popular platforms + +none: + @echo "Please choose a platform:" + @echo " $(PLATS)" + +aix: + $(MAKE) all CC="xlc" CFLAGS="-O2 -DLUA_USE_POSIX -DLUA_USE_DLOPEN" MYLIBS="-ldl" MYLDFLAGS="-brtl -bexpall" + +ansi: + $(MAKE) all MYCFLAGS=-DLUA_ANSI + +bsd: + $(MAKE) all MYCFLAGS="-DLUA_USE_POSIX -DLUA_USE_DLOPEN" MYLIBS="-Wl,-E" + +freebsd: + $(MAKE) all MYCFLAGS="-DLUA_USE_LINUX" MYLIBS="-Wl,-E -lreadline" + +generic: + $(MAKE) all MYCFLAGS= + +linux: + $(MAKE) all MYCFLAGS=-DLUA_USE_LINUX MYLIBS="-Wl,-E -ldl -lreadline -lhistory -lncurses" + +macosx: + $(MAKE) all MYCFLAGS=-DLUA_USE_LINUX MYLIBS="-lreadline" +# use this on Mac OS X 10.3- +# $(MAKE) all MYCFLAGS=-DLUA_USE_MACOSX + +mingw: + $(MAKE) "LUA_A=lua51.dll" "LUA_T=lua.exe" \ + "AR=$(CC) -shared -o" "RANLIB=strip --strip-unneeded" \ + "MYCFLAGS=-DLUA_BUILD_AS_DLL" "MYLIBS=" "MYLDFLAGS=-s" lua.exe + $(MAKE) "LUAC_T=luac.exe" luac.exe + +posix: + $(MAKE) all MYCFLAGS=-DLUA_USE_POSIX + +solaris: + $(MAKE) all MYCFLAGS="-DLUA_USE_POSIX -DLUA_USE_DLOPEN" MYLIBS="-ldl" + +# list targets that do not create files (but not all makes understand .PHONY) +.PHONY: all $(PLATS) default o a clean depend echo none + +# DO NOT DELETE + +lapi.o: lapi.c lua.h luaconf.h lapi.h lobject.h llimits.h ldebug.h \ + lstate.h ltm.h lzio.h lmem.h ldo.h lfunc.h lgc.h lstring.h ltable.h \ + lundump.h lvm.h +lauxlib.o: lauxlib.c lua.h luaconf.h lauxlib.h +lbaselib.o: lbaselib.c lua.h luaconf.h lauxlib.h lualib.h +lcode.o: lcode.c lua.h luaconf.h lcode.h llex.h lobject.h llimits.h \ + lzio.h lmem.h lopcodes.h lparser.h ldebug.h lstate.h ltm.h ldo.h lgc.h \ + ltable.h +ldblib.o: ldblib.c lua.h luaconf.h lauxlib.h lualib.h +ldebug.o: ldebug.c lua.h luaconf.h lapi.h lobject.h llimits.h lcode.h \ + llex.h lzio.h lmem.h lopcodes.h lparser.h ldebug.h lstate.h ltm.h ldo.h \ + lfunc.h lstring.h lgc.h ltable.h lvm.h +ldo.o: ldo.c lua.h luaconf.h ldebug.h lstate.h lobject.h llimits.h ltm.h \ + lzio.h lmem.h ldo.h lfunc.h lgc.h lopcodes.h lparser.h lstring.h \ + ltable.h lundump.h lvm.h +ldump.o: ldump.c lua.h luaconf.h lobject.h llimits.h lstate.h ltm.h \ + lzio.h lmem.h lundump.h +lfunc.o: lfunc.c lua.h luaconf.h lfunc.h lobject.h llimits.h lgc.h lmem.h \ + lstate.h ltm.h lzio.h +lgc.o: lgc.c lua.h luaconf.h ldebug.h lstate.h lobject.h llimits.h ltm.h \ + lzio.h lmem.h ldo.h lfunc.h lgc.h lstring.h ltable.h +linit.o: linit.c lua.h luaconf.h lualib.h lauxlib.h +liolib.o: liolib.c lua.h luaconf.h lauxlib.h lualib.h +llex.o: llex.c lua.h luaconf.h ldo.h lobject.h llimits.h lstate.h ltm.h \ + lzio.h lmem.h llex.h lparser.h lstring.h lgc.h ltable.h +lmathlib.o: lmathlib.c lua.h luaconf.h lauxlib.h lualib.h +lmem.o: lmem.c lua.h luaconf.h ldebug.h lstate.h lobject.h llimits.h \ + ltm.h lzio.h lmem.h ldo.h +loadlib.o: loadlib.c lua.h luaconf.h lauxlib.h lualib.h +lobject.o: lobject.c lua.h luaconf.h ldo.h lobject.h llimits.h lstate.h \ + ltm.h lzio.h lmem.h lstring.h lgc.h lvm.h +lopcodes.o: lopcodes.c lopcodes.h llimits.h lua.h luaconf.h +loslib.o: loslib.c lua.h luaconf.h lauxlib.h lualib.h +lparser.o: lparser.c lua.h luaconf.h lcode.h llex.h lobject.h llimits.h \ + lzio.h lmem.h lopcodes.h lparser.h ldebug.h lstate.h ltm.h ldo.h \ + lfunc.h lstring.h lgc.h ltable.h +lstate.o: lstate.c lua.h luaconf.h ldebug.h lstate.h lobject.h llimits.h \ + ltm.h lzio.h lmem.h ldo.h lfunc.h lgc.h llex.h lstring.h ltable.h +lstring.o: lstring.c lua.h luaconf.h lmem.h llimits.h lobject.h lstate.h \ + ltm.h lzio.h lstring.h lgc.h +lstrlib.o: lstrlib.c lua.h luaconf.h lauxlib.h lualib.h +ltable.o: ltable.c lua.h luaconf.h ldebug.h lstate.h lobject.h llimits.h \ + ltm.h lzio.h lmem.h ldo.h lgc.h ltable.h +ltablib.o: ltablib.c lua.h luaconf.h lauxlib.h lualib.h +ltm.o: ltm.c lua.h luaconf.h lobject.h llimits.h lstate.h ltm.h lzio.h \ + lmem.h lstring.h lgc.h ltable.h +lua.o: lua.c lua.h luaconf.h lauxlib.h lualib.h +luac.o: luac.c lua.h luaconf.h lauxlib.h ldo.h lobject.h llimits.h \ + lstate.h ltm.h lzio.h lmem.h lfunc.h lopcodes.h lstring.h lgc.h \ + lundump.h +lundump.o: lundump.c lua.h luaconf.h ldebug.h lstate.h lobject.h \ + llimits.h ltm.h lzio.h lmem.h ldo.h lfunc.h lstring.h lgc.h lundump.h +lvm.o: lvm.c lua.h luaconf.h ldebug.h lstate.h lobject.h llimits.h ltm.h \ + lzio.h lmem.h ldo.h lfunc.h lgc.h lopcodes.h lstring.h ltable.h lvm.h +lzio.o: lzio.c lua.h luaconf.h llimits.h lmem.h lstate.h lobject.h ltm.h \ + lzio.h +print.o: print.c ldebug.h lstate.h lua.h luaconf.h lobject.h llimits.h \ + ltm.h lzio.h lmem.h lopcodes.h lundump.h + +# (end of Makefile) diff --git a/extern/lua-5.1.5/src/lapi.c b/extern/lua-5.1.5/src/lapi.c new file mode 100644 index 00000000..5d5145d2 --- /dev/null +++ b/extern/lua-5.1.5/src/lapi.c @@ -0,0 +1,1087 @@ +/* +** $Id: lapi.c,v 2.55.1.5 2008/07/04 18:41:18 roberto Exp $ +** Lua API +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include +#include + +#define lapi_c +#define LUA_CORE + +#include "lua.h" + +#include "lapi.h" +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lgc.h" +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" +#include "lundump.h" +#include "lvm.h" + + + +const char lua_ident[] = + "$Lua: " LUA_RELEASE " " LUA_COPYRIGHT " $\n" + "$Authors: " LUA_AUTHORS " $\n" + "$URL: www.lua.org $\n"; + + + +#define api_checknelems(L, n) api_check(L, (n) <= (L->top - L->base)) + +#define api_checkvalidindex(L, i) api_check(L, (i) != luaO_nilobject) + +#define api_incr_top(L) {api_check(L, L->top < L->ci->top); L->top++;} + + + +static TValue *index2adr (lua_State *L, int idx) { + if (idx > 0) { + TValue *o = L->base + (idx - 1); + api_check(L, idx <= L->ci->top - L->base); + if (o >= L->top) return cast(TValue *, luaO_nilobject); + else return o; + } + else if (idx > LUA_REGISTRYINDEX) { + api_check(L, idx != 0 && -idx <= L->top - L->base); + return L->top + idx; + } + else switch (idx) { /* pseudo-indices */ + case LUA_REGISTRYINDEX: return registry(L); + case LUA_ENVIRONINDEX: { + Closure *func = curr_func(L); + sethvalue(L, &L->env, func->c.env); + return &L->env; + } + case LUA_GLOBALSINDEX: return gt(L); + default: { + Closure *func = curr_func(L); + idx = LUA_GLOBALSINDEX - idx; + return (idx <= func->c.nupvalues) + ? &func->c.upvalue[idx-1] + : cast(TValue *, luaO_nilobject); + } + } +} + + +static Table *getcurrenv (lua_State *L) { + if (L->ci == L->base_ci) /* no enclosing function? */ + return hvalue(gt(L)); /* use global table as environment */ + else { + Closure *func = curr_func(L); + return func->c.env; + } +} + + +void luaA_pushobject (lua_State *L, const TValue *o) { + setobj2s(L, L->top, o); + api_incr_top(L); +} + + +LUA_API int lua_checkstack (lua_State *L, int size) { + int res = 1; + lua_lock(L); + if (size > LUAI_MAXCSTACK || (L->top - L->base + size) > LUAI_MAXCSTACK) + res = 0; /* stack overflow */ + else if (size > 0) { + luaD_checkstack(L, size); + if (L->ci->top < L->top + size) + L->ci->top = L->top + size; + } + lua_unlock(L); + return res; +} + + +LUA_API void lua_xmove (lua_State *from, lua_State *to, int n) { + int i; + if (from == to) return; + lua_lock(to); + api_checknelems(from, n); + api_check(from, G(from) == G(to)); + api_check(from, to->ci->top - to->top >= n); + from->top -= n; + for (i = 0; i < n; i++) { + setobj2s(to, to->top++, from->top + i); + } + lua_unlock(to); +} + + +LUA_API void lua_setlevel (lua_State *from, lua_State *to) { + to->nCcalls = from->nCcalls; +} + + +LUA_API lua_CFunction lua_atpanic (lua_State *L, lua_CFunction panicf) { + lua_CFunction old; + lua_lock(L); + old = G(L)->panic; + G(L)->panic = panicf; + lua_unlock(L); + return old; +} + + +LUA_API lua_State *lua_newthread (lua_State *L) { + lua_State *L1; + lua_lock(L); + luaC_checkGC(L); + L1 = luaE_newthread(L); + setthvalue(L, L->top, L1); + api_incr_top(L); + lua_unlock(L); + luai_userstatethread(L, L1); + return L1; +} + + + +/* +** basic stack manipulation +*/ + + +LUA_API int lua_gettop (lua_State *L) { + return cast_int(L->top - L->base); +} + + +LUA_API void lua_settop (lua_State *L, int idx) { + lua_lock(L); + if (idx >= 0) { + api_check(L, idx <= L->stack_last - L->base); + while (L->top < L->base + idx) + setnilvalue(L->top++); + L->top = L->base + idx; + } + else { + api_check(L, -(idx+1) <= (L->top - L->base)); + L->top += idx+1; /* `subtract' index (index is negative) */ + } + lua_unlock(L); +} + + +LUA_API void lua_remove (lua_State *L, int idx) { + StkId p; + lua_lock(L); + p = index2adr(L, idx); + api_checkvalidindex(L, p); + while (++p < L->top) setobjs2s(L, p-1, p); + L->top--; + lua_unlock(L); +} + + +LUA_API void lua_insert (lua_State *L, int idx) { + StkId p; + StkId q; + lua_lock(L); + p = index2adr(L, idx); + api_checkvalidindex(L, p); + for (q = L->top; q>p; q--) setobjs2s(L, q, q-1); + setobjs2s(L, p, L->top); + lua_unlock(L); +} + + +LUA_API void lua_replace (lua_State *L, int idx) { + StkId o; + lua_lock(L); + /* explicit test for incompatible code */ + if (idx == LUA_ENVIRONINDEX && L->ci == L->base_ci) + luaG_runerror(L, "no calling environment"); + api_checknelems(L, 1); + o = index2adr(L, idx); + api_checkvalidindex(L, o); + if (idx == LUA_ENVIRONINDEX) { + Closure *func = curr_func(L); + api_check(L, ttistable(L->top - 1)); + func->c.env = hvalue(L->top - 1); + luaC_barrier(L, func, L->top - 1); + } + else { + setobj(L, o, L->top - 1); + if (idx < LUA_GLOBALSINDEX) /* function upvalue? */ + luaC_barrier(L, curr_func(L), L->top - 1); + } + L->top--; + lua_unlock(L); +} + + +LUA_API void lua_pushvalue (lua_State *L, int idx) { + lua_lock(L); + setobj2s(L, L->top, index2adr(L, idx)); + api_incr_top(L); + lua_unlock(L); +} + + + +/* +** access functions (stack -> C) +*/ + + +LUA_API int lua_type (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + return (o == luaO_nilobject) ? LUA_TNONE : ttype(o); +} + + +LUA_API const char *lua_typename (lua_State *L, int t) { + UNUSED(L); + return (t == LUA_TNONE) ? "no value" : luaT_typenames[t]; +} + + +LUA_API int lua_iscfunction (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + return iscfunction(o); +} + + +LUA_API int lua_isnumber (lua_State *L, int idx) { + TValue n; + const TValue *o = index2adr(L, idx); + return tonumber(o, &n); +} + + +LUA_API int lua_isstring (lua_State *L, int idx) { + int t = lua_type(L, idx); + return (t == LUA_TSTRING || t == LUA_TNUMBER); +} + + +LUA_API int lua_isuserdata (lua_State *L, int idx) { + const TValue *o = index2adr(L, idx); + return (ttisuserdata(o) || ttislightuserdata(o)); +} + + +LUA_API int lua_rawequal (lua_State *L, int index1, int index2) { + StkId o1 = index2adr(L, index1); + StkId o2 = index2adr(L, index2); + return (o1 == luaO_nilobject || o2 == luaO_nilobject) ? 0 + : luaO_rawequalObj(o1, o2); +} + + +LUA_API int lua_equal (lua_State *L, int index1, int index2) { + StkId o1, o2; + int i; + lua_lock(L); /* may call tag method */ + o1 = index2adr(L, index1); + o2 = index2adr(L, index2); + i = (o1 == luaO_nilobject || o2 == luaO_nilobject) ? 0 : equalobj(L, o1, o2); + lua_unlock(L); + return i; +} + + +LUA_API int lua_lessthan (lua_State *L, int index1, int index2) { + StkId o1, o2; + int i; + lua_lock(L); /* may call tag method */ + o1 = index2adr(L, index1); + o2 = index2adr(L, index2); + i = (o1 == luaO_nilobject || o2 == luaO_nilobject) ? 0 + : luaV_lessthan(L, o1, o2); + lua_unlock(L); + return i; +} + + + +LUA_API lua_Number lua_tonumber (lua_State *L, int idx) { + TValue n; + const TValue *o = index2adr(L, idx); + if (tonumber(o, &n)) + return nvalue(o); + else + return 0; +} + + +LUA_API lua_Integer lua_tointeger (lua_State *L, int idx) { + TValue n; + const TValue *o = index2adr(L, idx); + if (tonumber(o, &n)) { + lua_Integer res; + lua_Number num = nvalue(o); + lua_number2integer(res, num); + return res; + } + else + return 0; +} + + +LUA_API int lua_toboolean (lua_State *L, int idx) { + const TValue *o = index2adr(L, idx); + return !l_isfalse(o); +} + + +LUA_API const char *lua_tolstring (lua_State *L, int idx, size_t *len) { + StkId o = index2adr(L, idx); + if (!ttisstring(o)) { + lua_lock(L); /* `luaV_tostring' may create a new string */ + if (!luaV_tostring(L, o)) { /* conversion failed? */ + if (len != NULL) *len = 0; + lua_unlock(L); + return NULL; + } + luaC_checkGC(L); + o = index2adr(L, idx); /* previous call may reallocate the stack */ + lua_unlock(L); + } + if (len != NULL) *len = tsvalue(o)->len; + return svalue(o); +} + + +LUA_API size_t lua_objlen (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + switch (ttype(o)) { + case LUA_TSTRING: return tsvalue(o)->len; + case LUA_TUSERDATA: return uvalue(o)->len; + case LUA_TTABLE: return luaH_getn(hvalue(o)); + case LUA_TNUMBER: { + size_t l; + lua_lock(L); /* `luaV_tostring' may create a new string */ + l = (luaV_tostring(L, o) ? tsvalue(o)->len : 0); + lua_unlock(L); + return l; + } + default: return 0; + } +} + + +LUA_API lua_CFunction lua_tocfunction (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + return (!iscfunction(o)) ? NULL : clvalue(o)->c.f; +} + + +LUA_API void *lua_touserdata (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + switch (ttype(o)) { + case LUA_TUSERDATA: return (rawuvalue(o) + 1); + case LUA_TLIGHTUSERDATA: return pvalue(o); + default: return NULL; + } +} + + +LUA_API lua_State *lua_tothread (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + return (!ttisthread(o)) ? NULL : thvalue(o); +} + + +LUA_API const void *lua_topointer (lua_State *L, int idx) { + StkId o = index2adr(L, idx); + switch (ttype(o)) { + case LUA_TTABLE: return hvalue(o); + case LUA_TFUNCTION: return clvalue(o); + case LUA_TTHREAD: return thvalue(o); + case LUA_TUSERDATA: + case LUA_TLIGHTUSERDATA: + return lua_touserdata(L, idx); + default: return NULL; + } +} + + + +/* +** push functions (C -> stack) +*/ + + +LUA_API void lua_pushnil (lua_State *L) { + lua_lock(L); + setnilvalue(L->top); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_pushnumber (lua_State *L, lua_Number n) { + lua_lock(L); + setnvalue(L->top, n); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_pushinteger (lua_State *L, lua_Integer n) { + lua_lock(L); + setnvalue(L->top, cast_num(n)); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_pushlstring (lua_State *L, const char *s, size_t len) { + lua_lock(L); + luaC_checkGC(L); + setsvalue2s(L, L->top, luaS_newlstr(L, s, len)); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_pushstring (lua_State *L, const char *s) { + if (s == NULL) + lua_pushnil(L); + else + lua_pushlstring(L, s, strlen(s)); +} + + +LUA_API const char *lua_pushvfstring (lua_State *L, const char *fmt, + va_list argp) { + const char *ret; + lua_lock(L); + luaC_checkGC(L); + ret = luaO_pushvfstring(L, fmt, argp); + lua_unlock(L); + return ret; +} + + +LUA_API const char *lua_pushfstring (lua_State *L, const char *fmt, ...) { + const char *ret; + va_list argp; + lua_lock(L); + luaC_checkGC(L); + va_start(argp, fmt); + ret = luaO_pushvfstring(L, fmt, argp); + va_end(argp); + lua_unlock(L); + return ret; +} + + +LUA_API void lua_pushcclosure (lua_State *L, lua_CFunction fn, int n) { + Closure *cl; + lua_lock(L); + luaC_checkGC(L); + api_checknelems(L, n); + cl = luaF_newCclosure(L, n, getcurrenv(L)); + cl->c.f = fn; + L->top -= n; + while (n--) + setobj2n(L, &cl->c.upvalue[n], L->top+n); + setclvalue(L, L->top, cl); + lua_assert(iswhite(obj2gco(cl))); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_pushboolean (lua_State *L, int b) { + lua_lock(L); + setbvalue(L->top, (b != 0)); /* ensure that true is 1 */ + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_pushlightuserdata (lua_State *L, void *p) { + lua_lock(L); + setpvalue(L->top, p); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API int lua_pushthread (lua_State *L) { + lua_lock(L); + setthvalue(L, L->top, L); + api_incr_top(L); + lua_unlock(L); + return (G(L)->mainthread == L); +} + + + +/* +** get functions (Lua -> stack) +*/ + + +LUA_API void lua_gettable (lua_State *L, int idx) { + StkId t; + lua_lock(L); + t = index2adr(L, idx); + api_checkvalidindex(L, t); + luaV_gettable(L, t, L->top - 1, L->top - 1); + lua_unlock(L); +} + + +LUA_API void lua_getfield (lua_State *L, int idx, const char *k) { + StkId t; + TValue key; + lua_lock(L); + t = index2adr(L, idx); + api_checkvalidindex(L, t); + setsvalue(L, &key, luaS_new(L, k)); + luaV_gettable(L, t, &key, L->top); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_rawget (lua_State *L, int idx) { + StkId t; + lua_lock(L); + t = index2adr(L, idx); + api_check(L, ttistable(t)); + setobj2s(L, L->top - 1, luaH_get(hvalue(t), L->top - 1)); + lua_unlock(L); +} + + +LUA_API void lua_rawgeti (lua_State *L, int idx, int n) { + StkId o; + lua_lock(L); + o = index2adr(L, idx); + api_check(L, ttistable(o)); + setobj2s(L, L->top, luaH_getnum(hvalue(o), n)); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API void lua_createtable (lua_State *L, int narray, int nrec) { + lua_lock(L); + luaC_checkGC(L); + sethvalue(L, L->top, luaH_new(L, narray, nrec)); + api_incr_top(L); + lua_unlock(L); +} + + +LUA_API int lua_getmetatable (lua_State *L, int objindex) { + const TValue *obj; + Table *mt = NULL; + int res; + lua_lock(L); + obj = index2adr(L, objindex); + switch (ttype(obj)) { + case LUA_TTABLE: + mt = hvalue(obj)->metatable; + break; + case LUA_TUSERDATA: + mt = uvalue(obj)->metatable; + break; + default: + mt = G(L)->mt[ttype(obj)]; + break; + } + if (mt == NULL) + res = 0; + else { + sethvalue(L, L->top, mt); + api_incr_top(L); + res = 1; + } + lua_unlock(L); + return res; +} + + +LUA_API void lua_getfenv (lua_State *L, int idx) { + StkId o; + lua_lock(L); + o = index2adr(L, idx); + api_checkvalidindex(L, o); + switch (ttype(o)) { + case LUA_TFUNCTION: + sethvalue(L, L->top, clvalue(o)->c.env); + break; + case LUA_TUSERDATA: + sethvalue(L, L->top, uvalue(o)->env); + break; + case LUA_TTHREAD: + setobj2s(L, L->top, gt(thvalue(o))); + break; + default: + setnilvalue(L->top); + break; + } + api_incr_top(L); + lua_unlock(L); +} + + +/* +** set functions (stack -> Lua) +*/ + + +LUA_API void lua_settable (lua_State *L, int idx) { + StkId t; + lua_lock(L); + api_checknelems(L, 2); + t = index2adr(L, idx); + api_checkvalidindex(L, t); + luaV_settable(L, t, L->top - 2, L->top - 1); + L->top -= 2; /* pop index and value */ + lua_unlock(L); +} + + +LUA_API void lua_setfield (lua_State *L, int idx, const char *k) { + StkId t; + TValue key; + lua_lock(L); + api_checknelems(L, 1); + t = index2adr(L, idx); + api_checkvalidindex(L, t); + setsvalue(L, &key, luaS_new(L, k)); + luaV_settable(L, t, &key, L->top - 1); + L->top--; /* pop value */ + lua_unlock(L); +} + + +LUA_API void lua_rawset (lua_State *L, int idx) { + StkId t; + lua_lock(L); + api_checknelems(L, 2); + t = index2adr(L, idx); + api_check(L, ttistable(t)); + setobj2t(L, luaH_set(L, hvalue(t), L->top-2), L->top-1); + luaC_barriert(L, hvalue(t), L->top-1); + L->top -= 2; + lua_unlock(L); +} + + +LUA_API void lua_rawseti (lua_State *L, int idx, int n) { + StkId o; + lua_lock(L); + api_checknelems(L, 1); + o = index2adr(L, idx); + api_check(L, ttistable(o)); + setobj2t(L, luaH_setnum(L, hvalue(o), n), L->top-1); + luaC_barriert(L, hvalue(o), L->top-1); + L->top--; + lua_unlock(L); +} + + +LUA_API int lua_setmetatable (lua_State *L, int objindex) { + TValue *obj; + Table *mt; + lua_lock(L); + api_checknelems(L, 1); + obj = index2adr(L, objindex); + api_checkvalidindex(L, obj); + if (ttisnil(L->top - 1)) + mt = NULL; + else { + api_check(L, ttistable(L->top - 1)); + mt = hvalue(L->top - 1); + } + switch (ttype(obj)) { + case LUA_TTABLE: { + hvalue(obj)->metatable = mt; + if (mt) + luaC_objbarriert(L, hvalue(obj), mt); + break; + } + case LUA_TUSERDATA: { + uvalue(obj)->metatable = mt; + if (mt) + luaC_objbarrier(L, rawuvalue(obj), mt); + break; + } + default: { + G(L)->mt[ttype(obj)] = mt; + break; + } + } + L->top--; + lua_unlock(L); + return 1; +} + + +LUA_API int lua_setfenv (lua_State *L, int idx) { + StkId o; + int res = 1; + lua_lock(L); + api_checknelems(L, 1); + o = index2adr(L, idx); + api_checkvalidindex(L, o); + api_check(L, ttistable(L->top - 1)); + switch (ttype(o)) { + case LUA_TFUNCTION: + clvalue(o)->c.env = hvalue(L->top - 1); + break; + case LUA_TUSERDATA: + uvalue(o)->env = hvalue(L->top - 1); + break; + case LUA_TTHREAD: + sethvalue(L, gt(thvalue(o)), hvalue(L->top - 1)); + break; + default: + res = 0; + break; + } + if (res) luaC_objbarrier(L, gcvalue(o), hvalue(L->top - 1)); + L->top--; + lua_unlock(L); + return res; +} + + +/* +** `load' and `call' functions (run Lua code) +*/ + + +#define adjustresults(L,nres) \ + { if (nres == LUA_MULTRET && L->top >= L->ci->top) L->ci->top = L->top; } + + +#define checkresults(L,na,nr) \ + api_check(L, (nr) == LUA_MULTRET || (L->ci->top - L->top >= (nr) - (na))) + + +LUA_API void lua_call (lua_State *L, int nargs, int nresults) { + StkId func; + lua_lock(L); + api_checknelems(L, nargs+1); + checkresults(L, nargs, nresults); + func = L->top - (nargs+1); + luaD_call(L, func, nresults); + adjustresults(L, nresults); + lua_unlock(L); +} + + + +/* +** Execute a protected call. +*/ +struct CallS { /* data to `f_call' */ + StkId func; + int nresults; +}; + + +static void f_call (lua_State *L, void *ud) { + struct CallS *c = cast(struct CallS *, ud); + luaD_call(L, c->func, c->nresults); +} + + + +LUA_API int lua_pcall (lua_State *L, int nargs, int nresults, int errfunc) { + struct CallS c; + int status; + ptrdiff_t func; + lua_lock(L); + api_checknelems(L, nargs+1); + checkresults(L, nargs, nresults); + if (errfunc == 0) + func = 0; + else { + StkId o = index2adr(L, errfunc); + api_checkvalidindex(L, o); + func = savestack(L, o); + } + c.func = L->top - (nargs+1); /* function to be called */ + c.nresults = nresults; + status = luaD_pcall(L, f_call, &c, savestack(L, c.func), func); + adjustresults(L, nresults); + lua_unlock(L); + return status; +} + + +/* +** Execute a protected C call. +*/ +struct CCallS { /* data to `f_Ccall' */ + lua_CFunction func; + void *ud; +}; + + +static void f_Ccall (lua_State *L, void *ud) { + struct CCallS *c = cast(struct CCallS *, ud); + Closure *cl; + cl = luaF_newCclosure(L, 0, getcurrenv(L)); + cl->c.f = c->func; + setclvalue(L, L->top, cl); /* push function */ + api_incr_top(L); + setpvalue(L->top, c->ud); /* push only argument */ + api_incr_top(L); + luaD_call(L, L->top - 2, 0); +} + + +LUA_API int lua_cpcall (lua_State *L, lua_CFunction func, void *ud) { + struct CCallS c; + int status; + lua_lock(L); + c.func = func; + c.ud = ud; + status = luaD_pcall(L, f_Ccall, &c, savestack(L, L->top), 0); + lua_unlock(L); + return status; +} + + +LUA_API int lua_load (lua_State *L, lua_Reader reader, void *data, + const char *chunkname) { + ZIO z; + int status; + lua_lock(L); + if (!chunkname) chunkname = "?"; + luaZ_init(L, &z, reader, data); + status = luaD_protectedparser(L, &z, chunkname); + lua_unlock(L); + return status; +} + + +LUA_API int lua_dump (lua_State *L, lua_Writer writer, void *data) { + int status; + TValue *o; + lua_lock(L); + api_checknelems(L, 1); + o = L->top - 1; + if (isLfunction(o)) + status = luaU_dump(L, clvalue(o)->l.p, writer, data, 0); + else + status = 1; + lua_unlock(L); + return status; +} + + +LUA_API int lua_status (lua_State *L) { + return L->status; +} + + +/* +** Garbage-collection function +*/ + +LUA_API int lua_gc (lua_State *L, int what, int data) { + int res = 0; + global_State *g; + lua_lock(L); + g = G(L); + switch (what) { + case LUA_GCSTOP: { + g->GCthreshold = MAX_LUMEM; + break; + } + case LUA_GCRESTART: { + g->GCthreshold = g->totalbytes; + break; + } + case LUA_GCCOLLECT: { + luaC_fullgc(L); + break; + } + case LUA_GCCOUNT: { + /* GC values are expressed in Kbytes: #bytes/2^10 */ + res = cast_int(g->totalbytes >> 10); + break; + } + case LUA_GCCOUNTB: { + res = cast_int(g->totalbytes & 0x3ff); + break; + } + case LUA_GCSTEP: { + lu_mem a = (cast(lu_mem, data) << 10); + if (a <= g->totalbytes) + g->GCthreshold = g->totalbytes - a; + else + g->GCthreshold = 0; + while (g->GCthreshold <= g->totalbytes) { + luaC_step(L); + if (g->gcstate == GCSpause) { /* end of cycle? */ + res = 1; /* signal it */ + break; + } + } + break; + } + case LUA_GCSETPAUSE: { + res = g->gcpause; + g->gcpause = data; + break; + } + case LUA_GCSETSTEPMUL: { + res = g->gcstepmul; + g->gcstepmul = data; + break; + } + default: res = -1; /* invalid option */ + } + lua_unlock(L); + return res; +} + + + +/* +** miscellaneous functions +*/ + + +LUA_API int lua_error (lua_State *L) { + lua_lock(L); + api_checknelems(L, 1); + luaG_errormsg(L); + lua_unlock(L); + return 0; /* to avoid warnings */ +} + + +LUA_API int lua_next (lua_State *L, int idx) { + StkId t; + int more; + lua_lock(L); + t = index2adr(L, idx); + api_check(L, ttistable(t)); + more = luaH_next(L, hvalue(t), L->top - 1); + if (more) { + api_incr_top(L); + } + else /* no more elements */ + L->top -= 1; /* remove key */ + lua_unlock(L); + return more; +} + + +LUA_API void lua_concat (lua_State *L, int n) { + lua_lock(L); + api_checknelems(L, n); + if (n >= 2) { + luaC_checkGC(L); + luaV_concat(L, n, cast_int(L->top - L->base) - 1); + L->top -= (n-1); + } + else if (n == 0) { /* push empty string */ + setsvalue2s(L, L->top, luaS_newlstr(L, "", 0)); + api_incr_top(L); + } + /* else n == 1; nothing to do */ + lua_unlock(L); +} + + +LUA_API lua_Alloc lua_getallocf (lua_State *L, void **ud) { + lua_Alloc f; + lua_lock(L); + if (ud) *ud = G(L)->ud; + f = G(L)->frealloc; + lua_unlock(L); + return f; +} + + +LUA_API void lua_setallocf (lua_State *L, lua_Alloc f, void *ud) { + lua_lock(L); + G(L)->ud = ud; + G(L)->frealloc = f; + lua_unlock(L); +} + + +LUA_API void *lua_newuserdata (lua_State *L, size_t size) { + Udata *u; + lua_lock(L); + luaC_checkGC(L); + u = luaS_newudata(L, size, getcurrenv(L)); + setuvalue(L, L->top, u); + api_incr_top(L); + lua_unlock(L); + return u + 1; +} + + + + +static const char *aux_upvalue (StkId fi, int n, TValue **val) { + Closure *f; + if (!ttisfunction(fi)) return NULL; + f = clvalue(fi); + if (f->c.isC) { + if (!(1 <= n && n <= f->c.nupvalues)) return NULL; + *val = &f->c.upvalue[n-1]; + return ""; + } + else { + Proto *p = f->l.p; + if (!(1 <= n && n <= p->sizeupvalues)) return NULL; + *val = f->l.upvals[n-1]->v; + return getstr(p->upvalues[n-1]); + } +} + + +LUA_API const char *lua_getupvalue (lua_State *L, int funcindex, int n) { + const char *name; + TValue *val; + lua_lock(L); + name = aux_upvalue(index2adr(L, funcindex), n, &val); + if (name) { + setobj2s(L, L->top, val); + api_incr_top(L); + } + lua_unlock(L); + return name; +} + + +LUA_API const char *lua_setupvalue (lua_State *L, int funcindex, int n) { + const char *name; + TValue *val; + StkId fi; + lua_lock(L); + fi = index2adr(L, funcindex); + api_checknelems(L, 1); + name = aux_upvalue(fi, n, &val); + if (name) { + L->top--; + setobj(L, val, L->top); + luaC_barrier(L, clvalue(fi), L->top); + } + lua_unlock(L); + return name; +} + diff --git a/extern/lua-5.1.5/src/lapi.h b/extern/lua-5.1.5/src/lapi.h new file mode 100644 index 00000000..2c3fab24 --- /dev/null +++ b/extern/lua-5.1.5/src/lapi.h @@ -0,0 +1,16 @@ +/* +** $Id: lapi.h,v 2.2.1.1 2007/12/27 13:02:25 roberto Exp $ +** Auxiliary functions from Lua API +** See Copyright Notice in lua.h +*/ + +#ifndef lapi_h +#define lapi_h + + +#include "lobject.h" + + +LUAI_FUNC void luaA_pushobject (lua_State *L, const TValue *o); + +#endif diff --git a/extern/lua-5.1.5/src/lauxlib.c b/extern/lua-5.1.5/src/lauxlib.c new file mode 100644 index 00000000..10f14e2c --- /dev/null +++ b/extern/lua-5.1.5/src/lauxlib.c @@ -0,0 +1,652 @@ +/* +** $Id: lauxlib.c,v 1.159.1.3 2008/01/21 13:20:51 roberto Exp $ +** Auxiliary functions for building Lua libraries +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include +#include +#include +#include + + +/* This file uses only the official API of Lua. +** Any function declared here could be written as an application function. +*/ + +#define lauxlib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" + + +#define FREELIST_REF 0 /* free list of references */ + + +/* convert a stack index to positive */ +#define abs_index(L, i) ((i) > 0 || (i) <= LUA_REGISTRYINDEX ? (i) : \ + lua_gettop(L) + (i) + 1) + + +/* +** {====================================================== +** Error-report functions +** ======================================================= +*/ + + +LUALIB_API int luaL_argerror (lua_State *L, int narg, const char *extramsg) { + lua_Debug ar; + if (!lua_getstack(L, 0, &ar)) /* no stack frame? */ + return luaL_error(L, "bad argument #%d (%s)", narg, extramsg); + lua_getinfo(L, "n", &ar); + if (strcmp(ar.namewhat, "method") == 0) { + narg--; /* do not count `self' */ + if (narg == 0) /* error is in the self argument itself? */ + return luaL_error(L, "calling " LUA_QS " on bad self (%s)", + ar.name, extramsg); + } + if (ar.name == NULL) + ar.name = "?"; + return luaL_error(L, "bad argument #%d to " LUA_QS " (%s)", + narg, ar.name, extramsg); +} + + +LUALIB_API int luaL_typerror (lua_State *L, int narg, const char *tname) { + const char *msg = lua_pushfstring(L, "%s expected, got %s", + tname, luaL_typename(L, narg)); + return luaL_argerror(L, narg, msg); +} + + +static void tag_error (lua_State *L, int narg, int tag) { + luaL_typerror(L, narg, lua_typename(L, tag)); +} + + +LUALIB_API void luaL_where (lua_State *L, int level) { + lua_Debug ar; + if (lua_getstack(L, level, &ar)) { /* check function at level */ + lua_getinfo(L, "Sl", &ar); /* get info about it */ + if (ar.currentline > 0) { /* is there info? */ + lua_pushfstring(L, "%s:%d: ", ar.short_src, ar.currentline); + return; + } + } + lua_pushliteral(L, ""); /* else, no information available... */ +} + + +LUALIB_API int luaL_error (lua_State *L, const char *fmt, ...) { + va_list argp; + va_start(argp, fmt); + luaL_where(L, 1); + lua_pushvfstring(L, fmt, argp); + va_end(argp); + lua_concat(L, 2); + return lua_error(L); +} + +/* }====================================================== */ + + +LUALIB_API int luaL_checkoption (lua_State *L, int narg, const char *def, + const char *const lst[]) { + const char *name = (def) ? luaL_optstring(L, narg, def) : + luaL_checkstring(L, narg); + int i; + for (i=0; lst[i]; i++) + if (strcmp(lst[i], name) == 0) + return i; + return luaL_argerror(L, narg, + lua_pushfstring(L, "invalid option " LUA_QS, name)); +} + + +LUALIB_API int luaL_newmetatable (lua_State *L, const char *tname) { + lua_getfield(L, LUA_REGISTRYINDEX, tname); /* get registry.name */ + if (!lua_isnil(L, -1)) /* name already in use? */ + return 0; /* leave previous value on top, but return 0 */ + lua_pop(L, 1); + lua_newtable(L); /* create metatable */ + lua_pushvalue(L, -1); + lua_setfield(L, LUA_REGISTRYINDEX, tname); /* registry.name = metatable */ + return 1; +} + + +LUALIB_API void *luaL_checkudata (lua_State *L, int ud, const char *tname) { + void *p = lua_touserdata(L, ud); + if (p != NULL) { /* value is a userdata? */ + if (lua_getmetatable(L, ud)) { /* does it have a metatable? */ + lua_getfield(L, LUA_REGISTRYINDEX, tname); /* get correct metatable */ + if (lua_rawequal(L, -1, -2)) { /* does it have the correct mt? */ + lua_pop(L, 2); /* remove both metatables */ + return p; + } + } + } + luaL_typerror(L, ud, tname); /* else error */ + return NULL; /* to avoid warnings */ +} + + +LUALIB_API void luaL_checkstack (lua_State *L, int space, const char *mes) { + if (!lua_checkstack(L, space)) + luaL_error(L, "stack overflow (%s)", mes); +} + + +LUALIB_API void luaL_checktype (lua_State *L, int narg, int t) { + if (lua_type(L, narg) != t) + tag_error(L, narg, t); +} + + +LUALIB_API void luaL_checkany (lua_State *L, int narg) { + if (lua_type(L, narg) == LUA_TNONE) + luaL_argerror(L, narg, "value expected"); +} + + +LUALIB_API const char *luaL_checklstring (lua_State *L, int narg, size_t *len) { + const char *s = lua_tolstring(L, narg, len); + if (!s) tag_error(L, narg, LUA_TSTRING); + return s; +} + + +LUALIB_API const char *luaL_optlstring (lua_State *L, int narg, + const char *def, size_t *len) { + if (lua_isnoneornil(L, narg)) { + if (len) + *len = (def ? strlen(def) : 0); + return def; + } + else return luaL_checklstring(L, narg, len); +} + + +LUALIB_API lua_Number luaL_checknumber (lua_State *L, int narg) { + lua_Number d = lua_tonumber(L, narg); + if (d == 0 && !lua_isnumber(L, narg)) /* avoid extra test when d is not 0 */ + tag_error(L, narg, LUA_TNUMBER); + return d; +} + + +LUALIB_API lua_Number luaL_optnumber (lua_State *L, int narg, lua_Number def) { + return luaL_opt(L, luaL_checknumber, narg, def); +} + + +LUALIB_API lua_Integer luaL_checkinteger (lua_State *L, int narg) { + lua_Integer d = lua_tointeger(L, narg); + if (d == 0 && !lua_isnumber(L, narg)) /* avoid extra test when d is not 0 */ + tag_error(L, narg, LUA_TNUMBER); + return d; +} + + +LUALIB_API lua_Integer luaL_optinteger (lua_State *L, int narg, + lua_Integer def) { + return luaL_opt(L, luaL_checkinteger, narg, def); +} + + +LUALIB_API int luaL_getmetafield (lua_State *L, int obj, const char *event) { + if (!lua_getmetatable(L, obj)) /* no metatable? */ + return 0; + lua_pushstring(L, event); + lua_rawget(L, -2); + if (lua_isnil(L, -1)) { + lua_pop(L, 2); /* remove metatable and metafield */ + return 0; + } + else { + lua_remove(L, -2); /* remove only metatable */ + return 1; + } +} + + +LUALIB_API int luaL_callmeta (lua_State *L, int obj, const char *event) { + obj = abs_index(L, obj); + if (!luaL_getmetafield(L, obj, event)) /* no metafield? */ + return 0; + lua_pushvalue(L, obj); + lua_call(L, 1, 1); + return 1; +} + + +LUALIB_API void (luaL_register) (lua_State *L, const char *libname, + const luaL_Reg *l) { + luaI_openlib(L, libname, l, 0); +} + + +static int libsize (const luaL_Reg *l) { + int size = 0; + for (; l->name; l++) size++; + return size; +} + + +LUALIB_API void luaI_openlib (lua_State *L, const char *libname, + const luaL_Reg *l, int nup) { + if (libname) { + int size = libsize(l); + /* check whether lib already exists */ + luaL_findtable(L, LUA_REGISTRYINDEX, "_LOADED", 1); + lua_getfield(L, -1, libname); /* get _LOADED[libname] */ + if (!lua_istable(L, -1)) { /* not found? */ + lua_pop(L, 1); /* remove previous result */ + /* try global variable (and create one if it does not exist) */ + if (luaL_findtable(L, LUA_GLOBALSINDEX, libname, size) != NULL) + luaL_error(L, "name conflict for module " LUA_QS, libname); + lua_pushvalue(L, -1); + lua_setfield(L, -3, libname); /* _LOADED[libname] = new table */ + } + lua_remove(L, -2); /* remove _LOADED table */ + lua_insert(L, -(nup+1)); /* move library table to below upvalues */ + } + for (; l->name; l++) { + int i; + for (i=0; ifunc, nup); + lua_setfield(L, -(nup+2), l->name); + } + lua_pop(L, nup); /* remove upvalues */ +} + + + +/* +** {====================================================== +** getn-setn: size for arrays +** ======================================================= +*/ + +#if defined(LUA_COMPAT_GETN) + +static int checkint (lua_State *L, int topop) { + int n = (lua_type(L, -1) == LUA_TNUMBER) ? lua_tointeger(L, -1) : -1; + lua_pop(L, topop); + return n; +} + + +static void getsizes (lua_State *L) { + lua_getfield(L, LUA_REGISTRYINDEX, "LUA_SIZES"); + if (lua_isnil(L, -1)) { /* no `size' table? */ + lua_pop(L, 1); /* remove nil */ + lua_newtable(L); /* create it */ + lua_pushvalue(L, -1); /* `size' will be its own metatable */ + lua_setmetatable(L, -2); + lua_pushliteral(L, "kv"); + lua_setfield(L, -2, "__mode"); /* metatable(N).__mode = "kv" */ + lua_pushvalue(L, -1); + lua_setfield(L, LUA_REGISTRYINDEX, "LUA_SIZES"); /* store in register */ + } +} + + +LUALIB_API void luaL_setn (lua_State *L, int t, int n) { + t = abs_index(L, t); + lua_pushliteral(L, "n"); + lua_rawget(L, t); + if (checkint(L, 1) >= 0) { /* is there a numeric field `n'? */ + lua_pushliteral(L, "n"); /* use it */ + lua_pushinteger(L, n); + lua_rawset(L, t); + } + else { /* use `sizes' */ + getsizes(L); + lua_pushvalue(L, t); + lua_pushinteger(L, n); + lua_rawset(L, -3); /* sizes[t] = n */ + lua_pop(L, 1); /* remove `sizes' */ + } +} + + +LUALIB_API int luaL_getn (lua_State *L, int t) { + int n; + t = abs_index(L, t); + lua_pushliteral(L, "n"); /* try t.n */ + lua_rawget(L, t); + if ((n = checkint(L, 1)) >= 0) return n; + getsizes(L); /* else try sizes[t] */ + lua_pushvalue(L, t); + lua_rawget(L, -2); + if ((n = checkint(L, 2)) >= 0) return n; + return (int)lua_objlen(L, t); +} + +#endif + +/* }====================================================== */ + + + +LUALIB_API const char *luaL_gsub (lua_State *L, const char *s, const char *p, + const char *r) { + const char *wild; + size_t l = strlen(p); + luaL_Buffer b; + luaL_buffinit(L, &b); + while ((wild = strstr(s, p)) != NULL) { + luaL_addlstring(&b, s, wild - s); /* push prefix */ + luaL_addstring(&b, r); /* push replacement in place of pattern */ + s = wild + l; /* continue after `p' */ + } + luaL_addstring(&b, s); /* push last suffix */ + luaL_pushresult(&b); + return lua_tostring(L, -1); +} + + +LUALIB_API const char *luaL_findtable (lua_State *L, int idx, + const char *fname, int szhint) { + const char *e; + lua_pushvalue(L, idx); + do { + e = strchr(fname, '.'); + if (e == NULL) e = fname + strlen(fname); + lua_pushlstring(L, fname, e - fname); + lua_rawget(L, -2); + if (lua_isnil(L, -1)) { /* no such field? */ + lua_pop(L, 1); /* remove this nil */ + lua_createtable(L, 0, (*e == '.' ? 1 : szhint)); /* new table for field */ + lua_pushlstring(L, fname, e - fname); + lua_pushvalue(L, -2); + lua_settable(L, -4); /* set new table into field */ + } + else if (!lua_istable(L, -1)) { /* field has a non-table value? */ + lua_pop(L, 2); /* remove table and value */ + return fname; /* return problematic part of the name */ + } + lua_remove(L, -2); /* remove previous table */ + fname = e + 1; + } while (*e == '.'); + return NULL; +} + + + +/* +** {====================================================== +** Generic Buffer manipulation +** ======================================================= +*/ + + +#define bufflen(B) ((B)->p - (B)->buffer) +#define bufffree(B) ((size_t)(LUAL_BUFFERSIZE - bufflen(B))) + +#define LIMIT (LUA_MINSTACK/2) + + +static int emptybuffer (luaL_Buffer *B) { + size_t l = bufflen(B); + if (l == 0) return 0; /* put nothing on stack */ + else { + lua_pushlstring(B->L, B->buffer, l); + B->p = B->buffer; + B->lvl++; + return 1; + } +} + + +static void adjuststack (luaL_Buffer *B) { + if (B->lvl > 1) { + lua_State *L = B->L; + int toget = 1; /* number of levels to concat */ + size_t toplen = lua_strlen(L, -1); + do { + size_t l = lua_strlen(L, -(toget+1)); + if (B->lvl - toget + 1 >= LIMIT || toplen > l) { + toplen += l; + toget++; + } + else break; + } while (toget < B->lvl); + lua_concat(L, toget); + B->lvl = B->lvl - toget + 1; + } +} + + +LUALIB_API char *luaL_prepbuffer (luaL_Buffer *B) { + if (emptybuffer(B)) + adjuststack(B); + return B->buffer; +} + + +LUALIB_API void luaL_addlstring (luaL_Buffer *B, const char *s, size_t l) { + while (l--) + luaL_addchar(B, *s++); +} + + +LUALIB_API void luaL_addstring (luaL_Buffer *B, const char *s) { + luaL_addlstring(B, s, strlen(s)); +} + + +LUALIB_API void luaL_pushresult (luaL_Buffer *B) { + emptybuffer(B); + lua_concat(B->L, B->lvl); + B->lvl = 1; +} + + +LUALIB_API void luaL_addvalue (luaL_Buffer *B) { + lua_State *L = B->L; + size_t vl; + const char *s = lua_tolstring(L, -1, &vl); + if (vl <= bufffree(B)) { /* fit into buffer? */ + memcpy(B->p, s, vl); /* put it there */ + B->p += vl; + lua_pop(L, 1); /* remove from stack */ + } + else { + if (emptybuffer(B)) + lua_insert(L, -2); /* put buffer before new value */ + B->lvl++; /* add new value into B stack */ + adjuststack(B); + } +} + + +LUALIB_API void luaL_buffinit (lua_State *L, luaL_Buffer *B) { + B->L = L; + B->p = B->buffer; + B->lvl = 0; +} + +/* }====================================================== */ + + +LUALIB_API int luaL_ref (lua_State *L, int t) { + int ref; + t = abs_index(L, t); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); /* remove from stack */ + return LUA_REFNIL; /* `nil' has a unique fixed reference */ + } + lua_rawgeti(L, t, FREELIST_REF); /* get first free element */ + ref = (int)lua_tointeger(L, -1); /* ref = t[FREELIST_REF] */ + lua_pop(L, 1); /* remove it from stack */ + if (ref != 0) { /* any free element? */ + lua_rawgeti(L, t, ref); /* remove it from list */ + lua_rawseti(L, t, FREELIST_REF); /* (t[FREELIST_REF] = t[ref]) */ + } + else { /* no free elements */ + ref = (int)lua_objlen(L, t); + ref++; /* create new reference */ + } + lua_rawseti(L, t, ref); + return ref; +} + + +LUALIB_API void luaL_unref (lua_State *L, int t, int ref) { + if (ref >= 0) { + t = abs_index(L, t); + lua_rawgeti(L, t, FREELIST_REF); + lua_rawseti(L, t, ref); /* t[ref] = t[FREELIST_REF] */ + lua_pushinteger(L, ref); + lua_rawseti(L, t, FREELIST_REF); /* t[FREELIST_REF] = ref */ + } +} + + + +/* +** {====================================================== +** Load functions +** ======================================================= +*/ + +typedef struct LoadF { + int extraline; + FILE *f; + char buff[LUAL_BUFFERSIZE]; +} LoadF; + + +static const char *getF (lua_State *L, void *ud, size_t *size) { + LoadF *lf = (LoadF *)ud; + (void)L; + if (lf->extraline) { + lf->extraline = 0; + *size = 1; + return "\n"; + } + if (feof(lf->f)) return NULL; + *size = fread(lf->buff, 1, sizeof(lf->buff), lf->f); + return (*size > 0) ? lf->buff : NULL; +} + + +static int errfile (lua_State *L, const char *what, int fnameindex) { + const char *serr = strerror(errno); + const char *filename = lua_tostring(L, fnameindex) + 1; + lua_pushfstring(L, "cannot %s %s: %s", what, filename, serr); + lua_remove(L, fnameindex); + return LUA_ERRFILE; +} + + +LUALIB_API int luaL_loadfile (lua_State *L, const char *filename) { + LoadF lf; + int status, readstatus; + int c; + int fnameindex = lua_gettop(L) + 1; /* index of filename on the stack */ + lf.extraline = 0; + if (filename == NULL) { + lua_pushliteral(L, "=stdin"); + lf.f = stdin; + } + else { + lua_pushfstring(L, "@%s", filename); + lf.f = fopen(filename, "r"); + if (lf.f == NULL) return errfile(L, "open", fnameindex); + } + c = getc(lf.f); + if (c == '#') { /* Unix exec. file? */ + lf.extraline = 1; + while ((c = getc(lf.f)) != EOF && c != '\n') ; /* skip first line */ + if (c == '\n') c = getc(lf.f); + } + if (c == LUA_SIGNATURE[0] && filename) { /* binary file? */ + lf.f = freopen(filename, "rb", lf.f); /* reopen in binary mode */ + if (lf.f == NULL) return errfile(L, "reopen", fnameindex); + /* skip eventual `#!...' */ + while ((c = getc(lf.f)) != EOF && c != LUA_SIGNATURE[0]) ; + lf.extraline = 0; + } + ungetc(c, lf.f); + status = lua_load(L, getF, &lf, lua_tostring(L, -1)); + readstatus = ferror(lf.f); + if (filename) fclose(lf.f); /* close file (even in case of errors) */ + if (readstatus) { + lua_settop(L, fnameindex); /* ignore results from `lua_load' */ + return errfile(L, "read", fnameindex); + } + lua_remove(L, fnameindex); + return status; +} + + +typedef struct LoadS { + const char *s; + size_t size; +} LoadS; + + +static const char *getS (lua_State *L, void *ud, size_t *size) { + LoadS *ls = (LoadS *)ud; + (void)L; + if (ls->size == 0) return NULL; + *size = ls->size; + ls->size = 0; + return ls->s; +} + + +LUALIB_API int luaL_loadbuffer (lua_State *L, const char *buff, size_t size, + const char *name) { + LoadS ls; + ls.s = buff; + ls.size = size; + return lua_load(L, getS, &ls, name); +} + + +LUALIB_API int (luaL_loadstring) (lua_State *L, const char *s) { + return luaL_loadbuffer(L, s, strlen(s), s); +} + + + +/* }====================================================== */ + + +static void *l_alloc (void *ud, void *ptr, size_t osize, size_t nsize) { + (void)ud; + (void)osize; + if (nsize == 0) { + free(ptr); + return NULL; + } + else + return realloc(ptr, nsize); +} + + +static int panic (lua_State *L) { + (void)L; /* to avoid warnings */ + fprintf(stderr, "PANIC: unprotected error in call to Lua API (%s)\n", + lua_tostring(L, -1)); + return 0; +} + + +LUALIB_API lua_State *luaL_newstate (void) { + lua_State *L = lua_newstate(l_alloc, NULL); + if (L) lua_atpanic(L, &panic); + return L; +} + diff --git a/extern/lua-5.1.5/src/lauxlib.h b/extern/lua-5.1.5/src/lauxlib.h new file mode 100644 index 00000000..34258235 --- /dev/null +++ b/extern/lua-5.1.5/src/lauxlib.h @@ -0,0 +1,174 @@ +/* +** $Id: lauxlib.h,v 1.88.1.1 2007/12/27 13:02:25 roberto Exp $ +** Auxiliary functions for building Lua libraries +** See Copyright Notice in lua.h +*/ + + +#ifndef lauxlib_h +#define lauxlib_h + + +#include +#include + +#include "lua.h" + + +#if defined(LUA_COMPAT_GETN) +LUALIB_API int (luaL_getn) (lua_State *L, int t); +LUALIB_API void (luaL_setn) (lua_State *L, int t, int n); +#else +#define luaL_getn(L,i) ((int)lua_objlen(L, i)) +#define luaL_setn(L,i,j) ((void)0) /* no op! */ +#endif + +#if defined(LUA_COMPAT_OPENLIB) +#define luaI_openlib luaL_openlib +#endif + + +/* extra error code for `luaL_load' */ +#define LUA_ERRFILE (LUA_ERRERR+1) + + +typedef struct luaL_Reg { + const char *name; + lua_CFunction func; +} luaL_Reg; + + + +LUALIB_API void (luaI_openlib) (lua_State *L, const char *libname, + const luaL_Reg *l, int nup); +LUALIB_API void (luaL_register) (lua_State *L, const char *libname, + const luaL_Reg *l); +LUALIB_API int (luaL_getmetafield) (lua_State *L, int obj, const char *e); +LUALIB_API int (luaL_callmeta) (lua_State *L, int obj, const char *e); +LUALIB_API int (luaL_typerror) (lua_State *L, int narg, const char *tname); +LUALIB_API int (luaL_argerror) (lua_State *L, int numarg, const char *extramsg); +LUALIB_API const char *(luaL_checklstring) (lua_State *L, int numArg, + size_t *l); +LUALIB_API const char *(luaL_optlstring) (lua_State *L, int numArg, + const char *def, size_t *l); +LUALIB_API lua_Number (luaL_checknumber) (lua_State *L, int numArg); +LUALIB_API lua_Number (luaL_optnumber) (lua_State *L, int nArg, lua_Number def); + +LUALIB_API lua_Integer (luaL_checkinteger) (lua_State *L, int numArg); +LUALIB_API lua_Integer (luaL_optinteger) (lua_State *L, int nArg, + lua_Integer def); + +LUALIB_API void (luaL_checkstack) (lua_State *L, int sz, const char *msg); +LUALIB_API void (luaL_checktype) (lua_State *L, int narg, int t); +LUALIB_API void (luaL_checkany) (lua_State *L, int narg); + +LUALIB_API int (luaL_newmetatable) (lua_State *L, const char *tname); +LUALIB_API void *(luaL_checkudata) (lua_State *L, int ud, const char *tname); + +LUALIB_API void (luaL_where) (lua_State *L, int lvl); +LUALIB_API int (luaL_error) (lua_State *L, const char *fmt, ...); + +LUALIB_API int (luaL_checkoption) (lua_State *L, int narg, const char *def, + const char *const lst[]); + +LUALIB_API int (luaL_ref) (lua_State *L, int t); +LUALIB_API void (luaL_unref) (lua_State *L, int t, int ref); + +LUALIB_API int (luaL_loadfile) (lua_State *L, const char *filename); +LUALIB_API int (luaL_loadbuffer) (lua_State *L, const char *buff, size_t sz, + const char *name); +LUALIB_API int (luaL_loadstring) (lua_State *L, const char *s); + +LUALIB_API lua_State *(luaL_newstate) (void); + + +LUALIB_API const char *(luaL_gsub) (lua_State *L, const char *s, const char *p, + const char *r); + +LUALIB_API const char *(luaL_findtable) (lua_State *L, int idx, + const char *fname, int szhint); + + + + +/* +** =============================================================== +** some useful macros +** =============================================================== +*/ + +#define luaL_argcheck(L, cond,numarg,extramsg) \ + ((void)((cond) || luaL_argerror(L, (numarg), (extramsg)))) +#define luaL_checkstring(L,n) (luaL_checklstring(L, (n), NULL)) +#define luaL_optstring(L,n,d) (luaL_optlstring(L, (n), (d), NULL)) +#define luaL_checkint(L,n) ((int)luaL_checkinteger(L, (n))) +#define luaL_optint(L,n,d) ((int)luaL_optinteger(L, (n), (d))) +#define luaL_checklong(L,n) ((long)luaL_checkinteger(L, (n))) +#define luaL_optlong(L,n,d) ((long)luaL_optinteger(L, (n), (d))) + +#define luaL_typename(L,i) lua_typename(L, lua_type(L,(i))) + +#define luaL_dofile(L, fn) \ + (luaL_loadfile(L, fn) || lua_pcall(L, 0, LUA_MULTRET, 0)) + +#define luaL_dostring(L, s) \ + (luaL_loadstring(L, s) || lua_pcall(L, 0, LUA_MULTRET, 0)) + +#define luaL_getmetatable(L,n) (lua_getfield(L, LUA_REGISTRYINDEX, (n))) + +#define luaL_opt(L,f,n,d) (lua_isnoneornil(L,(n)) ? (d) : f(L,(n))) + +/* +** {====================================================== +** Generic Buffer manipulation +** ======================================================= +*/ + + + +typedef struct luaL_Buffer { + char *p; /* current position in buffer */ + int lvl; /* number of strings in the stack (level) */ + lua_State *L; + char buffer[LUAL_BUFFERSIZE]; +} luaL_Buffer; + +#define luaL_addchar(B,c) \ + ((void)((B)->p < ((B)->buffer+LUAL_BUFFERSIZE) || luaL_prepbuffer(B)), \ + (*(B)->p++ = (char)(c))) + +/* compatibility only */ +#define luaL_putchar(B,c) luaL_addchar(B,c) + +#define luaL_addsize(B,n) ((B)->p += (n)) + +LUALIB_API void (luaL_buffinit) (lua_State *L, luaL_Buffer *B); +LUALIB_API char *(luaL_prepbuffer) (luaL_Buffer *B); +LUALIB_API void (luaL_addlstring) (luaL_Buffer *B, const char *s, size_t l); +LUALIB_API void (luaL_addstring) (luaL_Buffer *B, const char *s); +LUALIB_API void (luaL_addvalue) (luaL_Buffer *B); +LUALIB_API void (luaL_pushresult) (luaL_Buffer *B); + + +/* }====================================================== */ + + +/* compatibility with ref system */ + +/* pre-defined references */ +#define LUA_NOREF (-2) +#define LUA_REFNIL (-1) + +#define lua_ref(L,lock) ((lock) ? luaL_ref(L, LUA_REGISTRYINDEX) : \ + (lua_pushstring(L, "unlocked references are obsolete"), lua_error(L), 0)) + +#define lua_unref(L,ref) luaL_unref(L, LUA_REGISTRYINDEX, (ref)) + +#define lua_getref(L,ref) lua_rawgeti(L, LUA_REGISTRYINDEX, (ref)) + + +#define luaL_reg luaL_Reg + +#endif + + diff --git a/extern/lua-5.1.5/src/lbaselib.c b/extern/lua-5.1.5/src/lbaselib.c new file mode 100644 index 00000000..2ab550bd --- /dev/null +++ b/extern/lua-5.1.5/src/lbaselib.c @@ -0,0 +1,653 @@ +/* +** $Id: lbaselib.c,v 1.191.1.6 2008/02/14 16:46:22 roberto Exp $ +** Basic library +** See Copyright Notice in lua.h +*/ + + + +#include +#include +#include +#include + +#define lbaselib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + + + +/* +** If your system does not support `stdout', you can just remove this function. +** If you need, you can define your own `print' function, following this +** model but changing `fputs' to put the strings at a proper place +** (a console window or a log file, for instance). +*/ +static int luaB_print (lua_State *L) { + int n = lua_gettop(L); /* number of arguments */ + int i; + lua_getglobal(L, "tostring"); + for (i=1; i<=n; i++) { + const char *s; + lua_pushvalue(L, -1); /* function to be called */ + lua_pushvalue(L, i); /* value to print */ + lua_call(L, 1, 1); + s = lua_tostring(L, -1); /* get result */ + if (s == NULL) + return luaL_error(L, LUA_QL("tostring") " must return a string to " + LUA_QL("print")); + if (i>1) fputs("\t", stdout); + fputs(s, stdout); + lua_pop(L, 1); /* pop result */ + } + fputs("\n", stdout); + return 0; +} + + +static int luaB_tonumber (lua_State *L) { + int base = luaL_optint(L, 2, 10); + if (base == 10) { /* standard conversion */ + luaL_checkany(L, 1); + if (lua_isnumber(L, 1)) { + lua_pushnumber(L, lua_tonumber(L, 1)); + return 1; + } + } + else { + const char *s1 = luaL_checkstring(L, 1); + char *s2; + unsigned long n; + luaL_argcheck(L, 2 <= base && base <= 36, 2, "base out of range"); + n = strtoul(s1, &s2, base); + if (s1 != s2) { /* at least one valid digit? */ + while (isspace((unsigned char)(*s2))) s2++; /* skip trailing spaces */ + if (*s2 == '\0') { /* no invalid trailing characters? */ + lua_pushnumber(L, (lua_Number)n); + return 1; + } + } + } + lua_pushnil(L); /* else not a number */ + return 1; +} + + +static int luaB_error (lua_State *L) { + int level = luaL_optint(L, 2, 1); + lua_settop(L, 1); + if (lua_isstring(L, 1) && level > 0) { /* add extra information? */ + luaL_where(L, level); + lua_pushvalue(L, 1); + lua_concat(L, 2); + } + return lua_error(L); +} + + +static int luaB_getmetatable (lua_State *L) { + luaL_checkany(L, 1); + if (!lua_getmetatable(L, 1)) { + lua_pushnil(L); + return 1; /* no metatable */ + } + luaL_getmetafield(L, 1, "__metatable"); + return 1; /* returns either __metatable field (if present) or metatable */ +} + + +static int luaB_setmetatable (lua_State *L) { + int t = lua_type(L, 2); + luaL_checktype(L, 1, LUA_TTABLE); + luaL_argcheck(L, t == LUA_TNIL || t == LUA_TTABLE, 2, + "nil or table expected"); + if (luaL_getmetafield(L, 1, "__metatable")) + luaL_error(L, "cannot change a protected metatable"); + lua_settop(L, 2); + lua_setmetatable(L, 1); + return 1; +} + + +static void getfunc (lua_State *L, int opt) { + if (lua_isfunction(L, 1)) lua_pushvalue(L, 1); + else { + lua_Debug ar; + int level = opt ? luaL_optint(L, 1, 1) : luaL_checkint(L, 1); + luaL_argcheck(L, level >= 0, 1, "level must be non-negative"); + if (lua_getstack(L, level, &ar) == 0) + luaL_argerror(L, 1, "invalid level"); + lua_getinfo(L, "f", &ar); + if (lua_isnil(L, -1)) + luaL_error(L, "no function environment for tail call at level %d", + level); + } +} + + +static int luaB_getfenv (lua_State *L) { + getfunc(L, 1); + if (lua_iscfunction(L, -1)) /* is a C function? */ + lua_pushvalue(L, LUA_GLOBALSINDEX); /* return the thread's global env. */ + else + lua_getfenv(L, -1); + return 1; +} + + +static int luaB_setfenv (lua_State *L) { + luaL_checktype(L, 2, LUA_TTABLE); + getfunc(L, 0); + lua_pushvalue(L, 2); + if (lua_isnumber(L, 1) && lua_tonumber(L, 1) == 0) { + /* change environment of current thread */ + lua_pushthread(L); + lua_insert(L, -2); + lua_setfenv(L, -2); + return 0; + } + else if (lua_iscfunction(L, -2) || lua_setfenv(L, -2) == 0) + luaL_error(L, + LUA_QL("setfenv") " cannot change environment of given object"); + return 1; +} + + +static int luaB_rawequal (lua_State *L) { + luaL_checkany(L, 1); + luaL_checkany(L, 2); + lua_pushboolean(L, lua_rawequal(L, 1, 2)); + return 1; +} + + +static int luaB_rawget (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + luaL_checkany(L, 2); + lua_settop(L, 2); + lua_rawget(L, 1); + return 1; +} + +static int luaB_rawset (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + luaL_checkany(L, 2); + luaL_checkany(L, 3); + lua_settop(L, 3); + lua_rawset(L, 1); + return 1; +} + + +static int luaB_gcinfo (lua_State *L) { + lua_pushinteger(L, lua_getgccount(L)); + return 1; +} + + +static int luaB_collectgarbage (lua_State *L) { + static const char *const opts[] = {"stop", "restart", "collect", + "count", "step", "setpause", "setstepmul", NULL}; + static const int optsnum[] = {LUA_GCSTOP, LUA_GCRESTART, LUA_GCCOLLECT, + LUA_GCCOUNT, LUA_GCSTEP, LUA_GCSETPAUSE, LUA_GCSETSTEPMUL}; + int o = luaL_checkoption(L, 1, "collect", opts); + int ex = luaL_optint(L, 2, 0); + int res = lua_gc(L, optsnum[o], ex); + switch (optsnum[o]) { + case LUA_GCCOUNT: { + int b = lua_gc(L, LUA_GCCOUNTB, 0); + lua_pushnumber(L, res + ((lua_Number)b/1024)); + return 1; + } + case LUA_GCSTEP: { + lua_pushboolean(L, res); + return 1; + } + default: { + lua_pushnumber(L, res); + return 1; + } + } +} + + +static int luaB_type (lua_State *L) { + luaL_checkany(L, 1); + lua_pushstring(L, luaL_typename(L, 1)); + return 1; +} + + +static int luaB_next (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_settop(L, 2); /* create a 2nd argument if there isn't one */ + if (lua_next(L, 1)) + return 2; + else { + lua_pushnil(L); + return 1; + } +} + + +static int luaB_pairs (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushvalue(L, lua_upvalueindex(1)); /* return generator, */ + lua_pushvalue(L, 1); /* state, */ + lua_pushnil(L); /* and initial value */ + return 3; +} + + +static int ipairsaux (lua_State *L) { + int i = luaL_checkint(L, 2); + luaL_checktype(L, 1, LUA_TTABLE); + i++; /* next value */ + lua_pushinteger(L, i); + lua_rawgeti(L, 1, i); + return (lua_isnil(L, -1)) ? 0 : 2; +} + + +static int luaB_ipairs (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushvalue(L, lua_upvalueindex(1)); /* return generator, */ + lua_pushvalue(L, 1); /* state, */ + lua_pushinteger(L, 0); /* and initial value */ + return 3; +} + + +static int load_aux (lua_State *L, int status) { + if (status == 0) /* OK? */ + return 1; + else { + lua_pushnil(L); + lua_insert(L, -2); /* put before error message */ + return 2; /* return nil plus error message */ + } +} + + +static int luaB_loadstring (lua_State *L) { + size_t l; + const char *s = luaL_checklstring(L, 1, &l); + const char *chunkname = luaL_optstring(L, 2, s); + return load_aux(L, luaL_loadbuffer(L, s, l, chunkname)); +} + + +static int luaB_loadfile (lua_State *L) { + const char *fname = luaL_optstring(L, 1, NULL); + return load_aux(L, luaL_loadfile(L, fname)); +} + + +/* +** Reader for generic `load' function: `lua_load' uses the +** stack for internal stuff, so the reader cannot change the +** stack top. Instead, it keeps its resulting string in a +** reserved slot inside the stack. +*/ +static const char *generic_reader (lua_State *L, void *ud, size_t *size) { + (void)ud; /* to avoid warnings */ + luaL_checkstack(L, 2, "too many nested functions"); + lua_pushvalue(L, 1); /* get function */ + lua_call(L, 0, 1); /* call it */ + if (lua_isnil(L, -1)) { + *size = 0; + return NULL; + } + else if (lua_isstring(L, -1)) { + lua_replace(L, 3); /* save string in a reserved stack slot */ + return lua_tolstring(L, 3, size); + } + else luaL_error(L, "reader function must return a string"); + return NULL; /* to avoid warnings */ +} + + +static int luaB_load (lua_State *L) { + int status; + const char *cname = luaL_optstring(L, 2, "=(load)"); + luaL_checktype(L, 1, LUA_TFUNCTION); + lua_settop(L, 3); /* function, eventual name, plus one reserved slot */ + status = lua_load(L, generic_reader, NULL, cname); + return load_aux(L, status); +} + + +static int luaB_dofile (lua_State *L) { + const char *fname = luaL_optstring(L, 1, NULL); + int n = lua_gettop(L); + if (luaL_loadfile(L, fname) != 0) lua_error(L); + lua_call(L, 0, LUA_MULTRET); + return lua_gettop(L) - n; +} + + +static int luaB_assert (lua_State *L) { + luaL_checkany(L, 1); + if (!lua_toboolean(L, 1)) + return luaL_error(L, "%s", luaL_optstring(L, 2, "assertion failed!")); + return lua_gettop(L); +} + + +static int luaB_unpack (lua_State *L) { + int i, e, n; + luaL_checktype(L, 1, LUA_TTABLE); + i = luaL_optint(L, 2, 1); + e = luaL_opt(L, luaL_checkint, 3, luaL_getn(L, 1)); + if (i > e) return 0; /* empty range */ + n = e - i + 1; /* number of elements */ + if (n <= 0 || !lua_checkstack(L, n)) /* n <= 0 means arith. overflow */ + return luaL_error(L, "too many results to unpack"); + lua_rawgeti(L, 1, i); /* push arg[i] (avoiding overflow problems) */ + while (i++ < e) /* push arg[i + 1...e] */ + lua_rawgeti(L, 1, i); + return n; +} + + +static int luaB_select (lua_State *L) { + int n = lua_gettop(L); + if (lua_type(L, 1) == LUA_TSTRING && *lua_tostring(L, 1) == '#') { + lua_pushinteger(L, n-1); + return 1; + } + else { + int i = luaL_checkint(L, 1); + if (i < 0) i = n + i; + else if (i > n) i = n; + luaL_argcheck(L, 1 <= i, 1, "index out of range"); + return n - i; + } +} + + +static int luaB_pcall (lua_State *L) { + int status; + luaL_checkany(L, 1); + status = lua_pcall(L, lua_gettop(L) - 1, LUA_MULTRET, 0); + lua_pushboolean(L, (status == 0)); + lua_insert(L, 1); + return lua_gettop(L); /* return status + all results */ +} + + +static int luaB_xpcall (lua_State *L) { + int status; + luaL_checkany(L, 2); + lua_settop(L, 2); + lua_insert(L, 1); /* put error function under function to be called */ + status = lua_pcall(L, 0, LUA_MULTRET, 1); + lua_pushboolean(L, (status == 0)); + lua_replace(L, 1); + return lua_gettop(L); /* return status + all results */ +} + + +static int luaB_tostring (lua_State *L) { + luaL_checkany(L, 1); + if (luaL_callmeta(L, 1, "__tostring")) /* is there a metafield? */ + return 1; /* use its value */ + switch (lua_type(L, 1)) { + case LUA_TNUMBER: + lua_pushstring(L, lua_tostring(L, 1)); + break; + case LUA_TSTRING: + lua_pushvalue(L, 1); + break; + case LUA_TBOOLEAN: + lua_pushstring(L, (lua_toboolean(L, 1) ? "true" : "false")); + break; + case LUA_TNIL: + lua_pushliteral(L, "nil"); + break; + default: + lua_pushfstring(L, "%s: %p", luaL_typename(L, 1), lua_topointer(L, 1)); + break; + } + return 1; +} + + +static int luaB_newproxy (lua_State *L) { + lua_settop(L, 1); + lua_newuserdata(L, 0); /* create proxy */ + if (lua_toboolean(L, 1) == 0) + return 1; /* no metatable */ + else if (lua_isboolean(L, 1)) { + lua_newtable(L); /* create a new metatable `m' ... */ + lua_pushvalue(L, -1); /* ... and mark `m' as a valid metatable */ + lua_pushboolean(L, 1); + lua_rawset(L, lua_upvalueindex(1)); /* weaktable[m] = true */ + } + else { + int validproxy = 0; /* to check if weaktable[metatable(u)] == true */ + if (lua_getmetatable(L, 1)) { + lua_rawget(L, lua_upvalueindex(1)); + validproxy = lua_toboolean(L, -1); + lua_pop(L, 1); /* remove value */ + } + luaL_argcheck(L, validproxy, 1, "boolean or proxy expected"); + lua_getmetatable(L, 1); /* metatable is valid; get it */ + } + lua_setmetatable(L, 2); + return 1; +} + + +static const luaL_Reg base_funcs[] = { + {"assert", luaB_assert}, + {"collectgarbage", luaB_collectgarbage}, + {"dofile", luaB_dofile}, + {"error", luaB_error}, + {"gcinfo", luaB_gcinfo}, + {"getfenv", luaB_getfenv}, + {"getmetatable", luaB_getmetatable}, + {"loadfile", luaB_loadfile}, + {"load", luaB_load}, + {"loadstring", luaB_loadstring}, + {"next", luaB_next}, + {"pcall", luaB_pcall}, + {"print", luaB_print}, + {"rawequal", luaB_rawequal}, + {"rawget", luaB_rawget}, + {"rawset", luaB_rawset}, + {"select", luaB_select}, + {"setfenv", luaB_setfenv}, + {"setmetatable", luaB_setmetatable}, + {"tonumber", luaB_tonumber}, + {"tostring", luaB_tostring}, + {"type", luaB_type}, + {"unpack", luaB_unpack}, + {"xpcall", luaB_xpcall}, + {NULL, NULL} +}; + + +/* +** {====================================================== +** Coroutine library +** ======================================================= +*/ + +#define CO_RUN 0 /* running */ +#define CO_SUS 1 /* suspended */ +#define CO_NOR 2 /* 'normal' (it resumed another coroutine) */ +#define CO_DEAD 3 + +static const char *const statnames[] = + {"running", "suspended", "normal", "dead"}; + +static int costatus (lua_State *L, lua_State *co) { + if (L == co) return CO_RUN; + switch (lua_status(co)) { + case LUA_YIELD: + return CO_SUS; + case 0: { + lua_Debug ar; + if (lua_getstack(co, 0, &ar) > 0) /* does it have frames? */ + return CO_NOR; /* it is running */ + else if (lua_gettop(co) == 0) + return CO_DEAD; + else + return CO_SUS; /* initial state */ + } + default: /* some error occured */ + return CO_DEAD; + } +} + + +static int luaB_costatus (lua_State *L) { + lua_State *co = lua_tothread(L, 1); + luaL_argcheck(L, co, 1, "coroutine expected"); + lua_pushstring(L, statnames[costatus(L, co)]); + return 1; +} + + +static int auxresume (lua_State *L, lua_State *co, int narg) { + int status = costatus(L, co); + if (!lua_checkstack(co, narg)) + luaL_error(L, "too many arguments to resume"); + if (status != CO_SUS) { + lua_pushfstring(L, "cannot resume %s coroutine", statnames[status]); + return -1; /* error flag */ + } + lua_xmove(L, co, narg); + lua_setlevel(L, co); + status = lua_resume(co, narg); + if (status == 0 || status == LUA_YIELD) { + int nres = lua_gettop(co); + if (!lua_checkstack(L, nres + 1)) + luaL_error(L, "too many results to resume"); + lua_xmove(co, L, nres); /* move yielded values */ + return nres; + } + else { + lua_xmove(co, L, 1); /* move error message */ + return -1; /* error flag */ + } +} + + +static int luaB_coresume (lua_State *L) { + lua_State *co = lua_tothread(L, 1); + int r; + luaL_argcheck(L, co, 1, "coroutine expected"); + r = auxresume(L, co, lua_gettop(L) - 1); + if (r < 0) { + lua_pushboolean(L, 0); + lua_insert(L, -2); + return 2; /* return false + error message */ + } + else { + lua_pushboolean(L, 1); + lua_insert(L, -(r + 1)); + return r + 1; /* return true + `resume' returns */ + } +} + + +static int luaB_auxwrap (lua_State *L) { + lua_State *co = lua_tothread(L, lua_upvalueindex(1)); + int r = auxresume(L, co, lua_gettop(L)); + if (r < 0) { + if (lua_isstring(L, -1)) { /* error object is a string? */ + luaL_where(L, 1); /* add extra info */ + lua_insert(L, -2); + lua_concat(L, 2); + } + lua_error(L); /* propagate error */ + } + return r; +} + + +static int luaB_cocreate (lua_State *L) { + lua_State *NL = lua_newthread(L); + luaL_argcheck(L, lua_isfunction(L, 1) && !lua_iscfunction(L, 1), 1, + "Lua function expected"); + lua_pushvalue(L, 1); /* move function to top */ + lua_xmove(L, NL, 1); /* move function from L to NL */ + return 1; +} + + +static int luaB_cowrap (lua_State *L) { + luaB_cocreate(L); + lua_pushcclosure(L, luaB_auxwrap, 1); + return 1; +} + + +static int luaB_yield (lua_State *L) { + return lua_yield(L, lua_gettop(L)); +} + + +static int luaB_corunning (lua_State *L) { + if (lua_pushthread(L)) + lua_pushnil(L); /* main thread is not a coroutine */ + return 1; +} + + +static const luaL_Reg co_funcs[] = { + {"create", luaB_cocreate}, + {"resume", luaB_coresume}, + {"running", luaB_corunning}, + {"status", luaB_costatus}, + {"wrap", luaB_cowrap}, + {"yield", luaB_yield}, + {NULL, NULL} +}; + +/* }====================================================== */ + + +static void auxopen (lua_State *L, const char *name, + lua_CFunction f, lua_CFunction u) { + lua_pushcfunction(L, u); + lua_pushcclosure(L, f, 1); + lua_setfield(L, -2, name); +} + + +static void base_open (lua_State *L) { + /* set global _G */ + lua_pushvalue(L, LUA_GLOBALSINDEX); + lua_setglobal(L, "_G"); + /* open lib into global table */ + luaL_register(L, "_G", base_funcs); + lua_pushliteral(L, LUA_VERSION); + lua_setglobal(L, "_VERSION"); /* set global _VERSION */ + /* `ipairs' and `pairs' need auxiliary functions as upvalues */ + auxopen(L, "ipairs", luaB_ipairs, ipairsaux); + auxopen(L, "pairs", luaB_pairs, luaB_next); + /* `newproxy' needs a weaktable as upvalue */ + lua_createtable(L, 0, 1); /* new table `w' */ + lua_pushvalue(L, -1); /* `w' will be its own metatable */ + lua_setmetatable(L, -2); + lua_pushliteral(L, "kv"); + lua_setfield(L, -2, "__mode"); /* metatable(w).__mode = "kv" */ + lua_pushcclosure(L, luaB_newproxy, 1); + lua_setglobal(L, "newproxy"); /* set global `newproxy' */ +} + + +LUALIB_API int luaopen_base (lua_State *L) { + base_open(L); + luaL_register(L, LUA_COLIBNAME, co_funcs); + return 2; +} + diff --git a/extern/lua-5.1.5/src/lcode.c b/extern/lua-5.1.5/src/lcode.c new file mode 100644 index 00000000..679cb9cf --- /dev/null +++ b/extern/lua-5.1.5/src/lcode.c @@ -0,0 +1,831 @@ +/* +** $Id: lcode.c,v 2.25.1.5 2011/01/31 14:53:16 roberto Exp $ +** Code generator for Lua +** See Copyright Notice in lua.h +*/ + + +#include + +#define lcode_c +#define LUA_CORE + +#include "lua.h" + +#include "lcode.h" +#include "ldebug.h" +#include "ldo.h" +#include "lgc.h" +#include "llex.h" +#include "lmem.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lparser.h" +#include "ltable.h" + + +#define hasjumps(e) ((e)->t != (e)->f) + + +static int isnumeral(expdesc *e) { + return (e->k == VKNUM && e->t == NO_JUMP && e->f == NO_JUMP); +} + + +void luaK_nil (FuncState *fs, int from, int n) { + Instruction *previous; + if (fs->pc > fs->lasttarget) { /* no jumps to current position? */ + if (fs->pc == 0) { /* function start? */ + if (from >= fs->nactvar) + return; /* positions are already clean */ + } + else { + previous = &fs->f->code[fs->pc-1]; + if (GET_OPCODE(*previous) == OP_LOADNIL) { + int pfrom = GETARG_A(*previous); + int pto = GETARG_B(*previous); + if (pfrom <= from && from <= pto+1) { /* can connect both? */ + if (from+n-1 > pto) + SETARG_B(*previous, from+n-1); + return; + } + } + } + } + luaK_codeABC(fs, OP_LOADNIL, from, from+n-1, 0); /* else no optimization */ +} + + +int luaK_jump (FuncState *fs) { + int jpc = fs->jpc; /* save list of jumps to here */ + int j; + fs->jpc = NO_JUMP; + j = luaK_codeAsBx(fs, OP_JMP, 0, NO_JUMP); + luaK_concat(fs, &j, jpc); /* keep them on hold */ + return j; +} + + +void luaK_ret (FuncState *fs, int first, int nret) { + luaK_codeABC(fs, OP_RETURN, first, nret+1, 0); +} + + +static int condjump (FuncState *fs, OpCode op, int A, int B, int C) { + luaK_codeABC(fs, op, A, B, C); + return luaK_jump(fs); +} + + +static void fixjump (FuncState *fs, int pc, int dest) { + Instruction *jmp = &fs->f->code[pc]; + int offset = dest-(pc+1); + lua_assert(dest != NO_JUMP); + if (abs(offset) > MAXARG_sBx) + luaX_syntaxerror(fs->ls, "control structure too long"); + SETARG_sBx(*jmp, offset); +} + + +/* +** returns current `pc' and marks it as a jump target (to avoid wrong +** optimizations with consecutive instructions not in the same basic block). +*/ +int luaK_getlabel (FuncState *fs) { + fs->lasttarget = fs->pc; + return fs->pc; +} + + +static int getjump (FuncState *fs, int pc) { + int offset = GETARG_sBx(fs->f->code[pc]); + if (offset == NO_JUMP) /* point to itself represents end of list */ + return NO_JUMP; /* end of list */ + else + return (pc+1)+offset; /* turn offset into absolute position */ +} + + +static Instruction *getjumpcontrol (FuncState *fs, int pc) { + Instruction *pi = &fs->f->code[pc]; + if (pc >= 1 && testTMode(GET_OPCODE(*(pi-1)))) + return pi-1; + else + return pi; +} + + +/* +** check whether list has any jump that do not produce a value +** (or produce an inverted value) +*/ +static int need_value (FuncState *fs, int list) { + for (; list != NO_JUMP; list = getjump(fs, list)) { + Instruction i = *getjumpcontrol(fs, list); + if (GET_OPCODE(i) != OP_TESTSET) return 1; + } + return 0; /* not found */ +} + + +static int patchtestreg (FuncState *fs, int node, int reg) { + Instruction *i = getjumpcontrol(fs, node); + if (GET_OPCODE(*i) != OP_TESTSET) + return 0; /* cannot patch other instructions */ + if (reg != NO_REG && reg != GETARG_B(*i)) + SETARG_A(*i, reg); + else /* no register to put value or register already has the value */ + *i = CREATE_ABC(OP_TEST, GETARG_B(*i), 0, GETARG_C(*i)); + + return 1; +} + + +static void removevalues (FuncState *fs, int list) { + for (; list != NO_JUMP; list = getjump(fs, list)) + patchtestreg(fs, list, NO_REG); +} + + +static void patchlistaux (FuncState *fs, int list, int vtarget, int reg, + int dtarget) { + while (list != NO_JUMP) { + int next = getjump(fs, list); + if (patchtestreg(fs, list, reg)) + fixjump(fs, list, vtarget); + else + fixjump(fs, list, dtarget); /* jump to default target */ + list = next; + } +} + + +static void dischargejpc (FuncState *fs) { + patchlistaux(fs, fs->jpc, fs->pc, NO_REG, fs->pc); + fs->jpc = NO_JUMP; +} + + +void luaK_patchlist (FuncState *fs, int list, int target) { + if (target == fs->pc) + luaK_patchtohere(fs, list); + else { + lua_assert(target < fs->pc); + patchlistaux(fs, list, target, NO_REG, target); + } +} + + +void luaK_patchtohere (FuncState *fs, int list) { + luaK_getlabel(fs); + luaK_concat(fs, &fs->jpc, list); +} + + +void luaK_concat (FuncState *fs, int *l1, int l2) { + if (l2 == NO_JUMP) return; + else if (*l1 == NO_JUMP) + *l1 = l2; + else { + int list = *l1; + int next; + while ((next = getjump(fs, list)) != NO_JUMP) /* find last element */ + list = next; + fixjump(fs, list, l2); + } +} + + +void luaK_checkstack (FuncState *fs, int n) { + int newstack = fs->freereg + n; + if (newstack > fs->f->maxstacksize) { + if (newstack >= MAXSTACK) + luaX_syntaxerror(fs->ls, "function or expression too complex"); + fs->f->maxstacksize = cast_byte(newstack); + } +} + + +void luaK_reserveregs (FuncState *fs, int n) { + luaK_checkstack(fs, n); + fs->freereg += n; +} + + +static void freereg (FuncState *fs, int reg) { + if (!ISK(reg) && reg >= fs->nactvar) { + fs->freereg--; + lua_assert(reg == fs->freereg); + } +} + + +static void freeexp (FuncState *fs, expdesc *e) { + if (e->k == VNONRELOC) + freereg(fs, e->u.s.info); +} + + +static int addk (FuncState *fs, TValue *k, TValue *v) { + lua_State *L = fs->L; + TValue *idx = luaH_set(L, fs->h, k); + Proto *f = fs->f; + int oldsize = f->sizek; + if (ttisnumber(idx)) { + lua_assert(luaO_rawequalObj(&fs->f->k[cast_int(nvalue(idx))], v)); + return cast_int(nvalue(idx)); + } + else { /* constant not found; create a new entry */ + setnvalue(idx, cast_num(fs->nk)); + luaM_growvector(L, f->k, fs->nk, f->sizek, TValue, + MAXARG_Bx, "constant table overflow"); + while (oldsize < f->sizek) setnilvalue(&f->k[oldsize++]); + setobj(L, &f->k[fs->nk], v); + luaC_barrier(L, f, v); + return fs->nk++; + } +} + + +int luaK_stringK (FuncState *fs, TString *s) { + TValue o; + setsvalue(fs->L, &o, s); + return addk(fs, &o, &o); +} + + +int luaK_numberK (FuncState *fs, lua_Number r) { + TValue o; + setnvalue(&o, r); + return addk(fs, &o, &o); +} + + +static int boolK (FuncState *fs, int b) { + TValue o; + setbvalue(&o, b); + return addk(fs, &o, &o); +} + + +static int nilK (FuncState *fs) { + TValue k, v; + setnilvalue(&v); + /* cannot use nil as key; instead use table itself to represent nil */ + sethvalue(fs->L, &k, fs->h); + return addk(fs, &k, &v); +} + + +void luaK_setreturns (FuncState *fs, expdesc *e, int nresults) { + if (e->k == VCALL) { /* expression is an open function call? */ + SETARG_C(getcode(fs, e), nresults+1); + } + else if (e->k == VVARARG) { + SETARG_B(getcode(fs, e), nresults+1); + SETARG_A(getcode(fs, e), fs->freereg); + luaK_reserveregs(fs, 1); + } +} + + +void luaK_setoneret (FuncState *fs, expdesc *e) { + if (e->k == VCALL) { /* expression is an open function call? */ + e->k = VNONRELOC; + e->u.s.info = GETARG_A(getcode(fs, e)); + } + else if (e->k == VVARARG) { + SETARG_B(getcode(fs, e), 2); + e->k = VRELOCABLE; /* can relocate its simple result */ + } +} + + +void luaK_dischargevars (FuncState *fs, expdesc *e) { + switch (e->k) { + case VLOCAL: { + e->k = VNONRELOC; + break; + } + case VUPVAL: { + e->u.s.info = luaK_codeABC(fs, OP_GETUPVAL, 0, e->u.s.info, 0); + e->k = VRELOCABLE; + break; + } + case VGLOBAL: { + e->u.s.info = luaK_codeABx(fs, OP_GETGLOBAL, 0, e->u.s.info); + e->k = VRELOCABLE; + break; + } + case VINDEXED: { + freereg(fs, e->u.s.aux); + freereg(fs, e->u.s.info); + e->u.s.info = luaK_codeABC(fs, OP_GETTABLE, 0, e->u.s.info, e->u.s.aux); + e->k = VRELOCABLE; + break; + } + case VVARARG: + case VCALL: { + luaK_setoneret(fs, e); + break; + } + default: break; /* there is one value available (somewhere) */ + } +} + + +static int code_label (FuncState *fs, int A, int b, int jump) { + luaK_getlabel(fs); /* those instructions may be jump targets */ + return luaK_codeABC(fs, OP_LOADBOOL, A, b, jump); +} + + +static void discharge2reg (FuncState *fs, expdesc *e, int reg) { + luaK_dischargevars(fs, e); + switch (e->k) { + case VNIL: { + luaK_nil(fs, reg, 1); + break; + } + case VFALSE: case VTRUE: { + luaK_codeABC(fs, OP_LOADBOOL, reg, e->k == VTRUE, 0); + break; + } + case VK: { + luaK_codeABx(fs, OP_LOADK, reg, e->u.s.info); + break; + } + case VKNUM: { + luaK_codeABx(fs, OP_LOADK, reg, luaK_numberK(fs, e->u.nval)); + break; + } + case VRELOCABLE: { + Instruction *pc = &getcode(fs, e); + SETARG_A(*pc, reg); + break; + } + case VNONRELOC: { + if (reg != e->u.s.info) + luaK_codeABC(fs, OP_MOVE, reg, e->u.s.info, 0); + break; + } + default: { + lua_assert(e->k == VVOID || e->k == VJMP); + return; /* nothing to do... */ + } + } + e->u.s.info = reg; + e->k = VNONRELOC; +} + + +static void discharge2anyreg (FuncState *fs, expdesc *e) { + if (e->k != VNONRELOC) { + luaK_reserveregs(fs, 1); + discharge2reg(fs, e, fs->freereg-1); + } +} + + +static void exp2reg (FuncState *fs, expdesc *e, int reg) { + discharge2reg(fs, e, reg); + if (e->k == VJMP) + luaK_concat(fs, &e->t, e->u.s.info); /* put this jump in `t' list */ + if (hasjumps(e)) { + int final; /* position after whole expression */ + int p_f = NO_JUMP; /* position of an eventual LOAD false */ + int p_t = NO_JUMP; /* position of an eventual LOAD true */ + if (need_value(fs, e->t) || need_value(fs, e->f)) { + int fj = (e->k == VJMP) ? NO_JUMP : luaK_jump(fs); + p_f = code_label(fs, reg, 0, 1); + p_t = code_label(fs, reg, 1, 0); + luaK_patchtohere(fs, fj); + } + final = luaK_getlabel(fs); + patchlistaux(fs, e->f, final, reg, p_f); + patchlistaux(fs, e->t, final, reg, p_t); + } + e->f = e->t = NO_JUMP; + e->u.s.info = reg; + e->k = VNONRELOC; +} + + +void luaK_exp2nextreg (FuncState *fs, expdesc *e) { + luaK_dischargevars(fs, e); + freeexp(fs, e); + luaK_reserveregs(fs, 1); + exp2reg(fs, e, fs->freereg - 1); +} + + +int luaK_exp2anyreg (FuncState *fs, expdesc *e) { + luaK_dischargevars(fs, e); + if (e->k == VNONRELOC) { + if (!hasjumps(e)) return e->u.s.info; /* exp is already in a register */ + if (e->u.s.info >= fs->nactvar) { /* reg. is not a local? */ + exp2reg(fs, e, e->u.s.info); /* put value on it */ + return e->u.s.info; + } + } + luaK_exp2nextreg(fs, e); /* default */ + return e->u.s.info; +} + + +void luaK_exp2val (FuncState *fs, expdesc *e) { + if (hasjumps(e)) + luaK_exp2anyreg(fs, e); + else + luaK_dischargevars(fs, e); +} + + +int luaK_exp2RK (FuncState *fs, expdesc *e) { + luaK_exp2val(fs, e); + switch (e->k) { + case VKNUM: + case VTRUE: + case VFALSE: + case VNIL: { + if (fs->nk <= MAXINDEXRK) { /* constant fit in RK operand? */ + e->u.s.info = (e->k == VNIL) ? nilK(fs) : + (e->k == VKNUM) ? luaK_numberK(fs, e->u.nval) : + boolK(fs, (e->k == VTRUE)); + e->k = VK; + return RKASK(e->u.s.info); + } + else break; + } + case VK: { + if (e->u.s.info <= MAXINDEXRK) /* constant fit in argC? */ + return RKASK(e->u.s.info); + else break; + } + default: break; + } + /* not a constant in the right range: put it in a register */ + return luaK_exp2anyreg(fs, e); +} + + +void luaK_storevar (FuncState *fs, expdesc *var, expdesc *ex) { + switch (var->k) { + case VLOCAL: { + freeexp(fs, ex); + exp2reg(fs, ex, var->u.s.info); + return; + } + case VUPVAL: { + int e = luaK_exp2anyreg(fs, ex); + luaK_codeABC(fs, OP_SETUPVAL, e, var->u.s.info, 0); + break; + } + case VGLOBAL: { + int e = luaK_exp2anyreg(fs, ex); + luaK_codeABx(fs, OP_SETGLOBAL, e, var->u.s.info); + break; + } + case VINDEXED: { + int e = luaK_exp2RK(fs, ex); + luaK_codeABC(fs, OP_SETTABLE, var->u.s.info, var->u.s.aux, e); + break; + } + default: { + lua_assert(0); /* invalid var kind to store */ + break; + } + } + freeexp(fs, ex); +} + + +void luaK_self (FuncState *fs, expdesc *e, expdesc *key) { + int func; + luaK_exp2anyreg(fs, e); + freeexp(fs, e); + func = fs->freereg; + luaK_reserveregs(fs, 2); + luaK_codeABC(fs, OP_SELF, func, e->u.s.info, luaK_exp2RK(fs, key)); + freeexp(fs, key); + e->u.s.info = func; + e->k = VNONRELOC; +} + + +static void invertjump (FuncState *fs, expdesc *e) { + Instruction *pc = getjumpcontrol(fs, e->u.s.info); + lua_assert(testTMode(GET_OPCODE(*pc)) && GET_OPCODE(*pc) != OP_TESTSET && + GET_OPCODE(*pc) != OP_TEST); + SETARG_A(*pc, !(GETARG_A(*pc))); +} + + +static int jumponcond (FuncState *fs, expdesc *e, int cond) { + if (e->k == VRELOCABLE) { + Instruction ie = getcode(fs, e); + if (GET_OPCODE(ie) == OP_NOT) { + fs->pc--; /* remove previous OP_NOT */ + return condjump(fs, OP_TEST, GETARG_B(ie), 0, !cond); + } + /* else go through */ + } + discharge2anyreg(fs, e); + freeexp(fs, e); + return condjump(fs, OP_TESTSET, NO_REG, e->u.s.info, cond); +} + + +void luaK_goiftrue (FuncState *fs, expdesc *e) { + int pc; /* pc of last jump */ + luaK_dischargevars(fs, e); + switch (e->k) { + case VK: case VKNUM: case VTRUE: { + pc = NO_JUMP; /* always true; do nothing */ + break; + } + case VJMP: { + invertjump(fs, e); + pc = e->u.s.info; + break; + } + default: { + pc = jumponcond(fs, e, 0); + break; + } + } + luaK_concat(fs, &e->f, pc); /* insert last jump in `f' list */ + luaK_patchtohere(fs, e->t); + e->t = NO_JUMP; +} + + +static void luaK_goiffalse (FuncState *fs, expdesc *e) { + int pc; /* pc of last jump */ + luaK_dischargevars(fs, e); + switch (e->k) { + case VNIL: case VFALSE: { + pc = NO_JUMP; /* always false; do nothing */ + break; + } + case VJMP: { + pc = e->u.s.info; + break; + } + default: { + pc = jumponcond(fs, e, 1); + break; + } + } + luaK_concat(fs, &e->t, pc); /* insert last jump in `t' list */ + luaK_patchtohere(fs, e->f); + e->f = NO_JUMP; +} + + +static void codenot (FuncState *fs, expdesc *e) { + luaK_dischargevars(fs, e); + switch (e->k) { + case VNIL: case VFALSE: { + e->k = VTRUE; + break; + } + case VK: case VKNUM: case VTRUE: { + e->k = VFALSE; + break; + } + case VJMP: { + invertjump(fs, e); + break; + } + case VRELOCABLE: + case VNONRELOC: { + discharge2anyreg(fs, e); + freeexp(fs, e); + e->u.s.info = luaK_codeABC(fs, OP_NOT, 0, e->u.s.info, 0); + e->k = VRELOCABLE; + break; + } + default: { + lua_assert(0); /* cannot happen */ + break; + } + } + /* interchange true and false lists */ + { int temp = e->f; e->f = e->t; e->t = temp; } + removevalues(fs, e->f); + removevalues(fs, e->t); +} + + +void luaK_indexed (FuncState *fs, expdesc *t, expdesc *k) { + t->u.s.aux = luaK_exp2RK(fs, k); + t->k = VINDEXED; +} + + +static int constfolding (OpCode op, expdesc *e1, expdesc *e2) { + lua_Number v1, v2, r; + if (!isnumeral(e1) || !isnumeral(e2)) return 0; + v1 = e1->u.nval; + v2 = e2->u.nval; + switch (op) { + case OP_ADD: r = luai_numadd(v1, v2); break; + case OP_SUB: r = luai_numsub(v1, v2); break; + case OP_MUL: r = luai_nummul(v1, v2); break; + case OP_DIV: + if (v2 == 0) return 0; /* do not attempt to divide by 0 */ + r = luai_numdiv(v1, v2); break; + case OP_MOD: + if (v2 == 0) return 0; /* do not attempt to divide by 0 */ + r = luai_nummod(v1, v2); break; + case OP_POW: r = luai_numpow(v1, v2); break; + case OP_UNM: r = luai_numunm(v1); break; + case OP_LEN: return 0; /* no constant folding for 'len' */ + default: lua_assert(0); r = 0; break; + } + if (luai_numisnan(r)) return 0; /* do not attempt to produce NaN */ + e1->u.nval = r; + return 1; +} + + +static void codearith (FuncState *fs, OpCode op, expdesc *e1, expdesc *e2) { + if (constfolding(op, e1, e2)) + return; + else { + int o2 = (op != OP_UNM && op != OP_LEN) ? luaK_exp2RK(fs, e2) : 0; + int o1 = luaK_exp2RK(fs, e1); + if (o1 > o2) { + freeexp(fs, e1); + freeexp(fs, e2); + } + else { + freeexp(fs, e2); + freeexp(fs, e1); + } + e1->u.s.info = luaK_codeABC(fs, op, 0, o1, o2); + e1->k = VRELOCABLE; + } +} + + +static void codecomp (FuncState *fs, OpCode op, int cond, expdesc *e1, + expdesc *e2) { + int o1 = luaK_exp2RK(fs, e1); + int o2 = luaK_exp2RK(fs, e2); + freeexp(fs, e2); + freeexp(fs, e1); + if (cond == 0 && op != OP_EQ) { + int temp; /* exchange args to replace by `<' or `<=' */ + temp = o1; o1 = o2; o2 = temp; /* o1 <==> o2 */ + cond = 1; + } + e1->u.s.info = condjump(fs, op, cond, o1, o2); + e1->k = VJMP; +} + + +void luaK_prefix (FuncState *fs, UnOpr op, expdesc *e) { + expdesc e2; + e2.t = e2.f = NO_JUMP; e2.k = VKNUM; e2.u.nval = 0; + switch (op) { + case OPR_MINUS: { + if (!isnumeral(e)) + luaK_exp2anyreg(fs, e); /* cannot operate on non-numeric constants */ + codearith(fs, OP_UNM, e, &e2); + break; + } + case OPR_NOT: codenot(fs, e); break; + case OPR_LEN: { + luaK_exp2anyreg(fs, e); /* cannot operate on constants */ + codearith(fs, OP_LEN, e, &e2); + break; + } + default: lua_assert(0); + } +} + + +void luaK_infix (FuncState *fs, BinOpr op, expdesc *v) { + switch (op) { + case OPR_AND: { + luaK_goiftrue(fs, v); + break; + } + case OPR_OR: { + luaK_goiffalse(fs, v); + break; + } + case OPR_CONCAT: { + luaK_exp2nextreg(fs, v); /* operand must be on the `stack' */ + break; + } + case OPR_ADD: case OPR_SUB: case OPR_MUL: case OPR_DIV: + case OPR_MOD: case OPR_POW: { + if (!isnumeral(v)) luaK_exp2RK(fs, v); + break; + } + default: { + luaK_exp2RK(fs, v); + break; + } + } +} + + +void luaK_posfix (FuncState *fs, BinOpr op, expdesc *e1, expdesc *e2) { + switch (op) { + case OPR_AND: { + lua_assert(e1->t == NO_JUMP); /* list must be closed */ + luaK_dischargevars(fs, e2); + luaK_concat(fs, &e2->f, e1->f); + *e1 = *e2; + break; + } + case OPR_OR: { + lua_assert(e1->f == NO_JUMP); /* list must be closed */ + luaK_dischargevars(fs, e2); + luaK_concat(fs, &e2->t, e1->t); + *e1 = *e2; + break; + } + case OPR_CONCAT: { + luaK_exp2val(fs, e2); + if (e2->k == VRELOCABLE && GET_OPCODE(getcode(fs, e2)) == OP_CONCAT) { + lua_assert(e1->u.s.info == GETARG_B(getcode(fs, e2))-1); + freeexp(fs, e1); + SETARG_B(getcode(fs, e2), e1->u.s.info); + e1->k = VRELOCABLE; e1->u.s.info = e2->u.s.info; + } + else { + luaK_exp2nextreg(fs, e2); /* operand must be on the 'stack' */ + codearith(fs, OP_CONCAT, e1, e2); + } + break; + } + case OPR_ADD: codearith(fs, OP_ADD, e1, e2); break; + case OPR_SUB: codearith(fs, OP_SUB, e1, e2); break; + case OPR_MUL: codearith(fs, OP_MUL, e1, e2); break; + case OPR_DIV: codearith(fs, OP_DIV, e1, e2); break; + case OPR_MOD: codearith(fs, OP_MOD, e1, e2); break; + case OPR_POW: codearith(fs, OP_POW, e1, e2); break; + case OPR_EQ: codecomp(fs, OP_EQ, 1, e1, e2); break; + case OPR_NE: codecomp(fs, OP_EQ, 0, e1, e2); break; + case OPR_LT: codecomp(fs, OP_LT, 1, e1, e2); break; + case OPR_LE: codecomp(fs, OP_LE, 1, e1, e2); break; + case OPR_GT: codecomp(fs, OP_LT, 0, e1, e2); break; + case OPR_GE: codecomp(fs, OP_LE, 0, e1, e2); break; + default: lua_assert(0); + } +} + + +void luaK_fixline (FuncState *fs, int line) { + fs->f->lineinfo[fs->pc - 1] = line; +} + + +static int luaK_code (FuncState *fs, Instruction i, int line) { + Proto *f = fs->f; + dischargejpc(fs); /* `pc' will change */ + /* put new instruction in code array */ + luaM_growvector(fs->L, f->code, fs->pc, f->sizecode, Instruction, + MAX_INT, "code size overflow"); + f->code[fs->pc] = i; + /* save corresponding line information */ + luaM_growvector(fs->L, f->lineinfo, fs->pc, f->sizelineinfo, int, + MAX_INT, "code size overflow"); + f->lineinfo[fs->pc] = line; + return fs->pc++; +} + + +int luaK_codeABC (FuncState *fs, OpCode o, int a, int b, int c) { + lua_assert(getOpMode(o) == iABC); + lua_assert(getBMode(o) != OpArgN || b == 0); + lua_assert(getCMode(o) != OpArgN || c == 0); + return luaK_code(fs, CREATE_ABC(o, a, b, c), fs->ls->lastline); +} + + +int luaK_codeABx (FuncState *fs, OpCode o, int a, unsigned int bc) { + lua_assert(getOpMode(o) == iABx || getOpMode(o) == iAsBx); + lua_assert(getCMode(o) == OpArgN); + return luaK_code(fs, CREATE_ABx(o, a, bc), fs->ls->lastline); +} + + +void luaK_setlist (FuncState *fs, int base, int nelems, int tostore) { + int c = (nelems - 1)/LFIELDS_PER_FLUSH + 1; + int b = (tostore == LUA_MULTRET) ? 0 : tostore; + lua_assert(tostore != 0); + if (c <= MAXARG_C) + luaK_codeABC(fs, OP_SETLIST, base, b, c); + else { + luaK_codeABC(fs, OP_SETLIST, base, b, 0); + luaK_code(fs, cast(Instruction, c), fs->ls->lastline); + } + fs->freereg = base + 1; /* free registers with list values */ +} + diff --git a/extern/lua-5.1.5/src/lcode.h b/extern/lua-5.1.5/src/lcode.h new file mode 100644 index 00000000..b941c607 --- /dev/null +++ b/extern/lua-5.1.5/src/lcode.h @@ -0,0 +1,76 @@ +/* +** $Id: lcode.h,v 1.48.1.1 2007/12/27 13:02:25 roberto Exp $ +** Code generator for Lua +** See Copyright Notice in lua.h +*/ + +#ifndef lcode_h +#define lcode_h + +#include "llex.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lparser.h" + + +/* +** Marks the end of a patch list. It is an invalid value both as an absolute +** address, and as a list link (would link an element to itself). +*/ +#define NO_JUMP (-1) + + +/* +** grep "ORDER OPR" if you change these enums +*/ +typedef enum BinOpr { + OPR_ADD, OPR_SUB, OPR_MUL, OPR_DIV, OPR_MOD, OPR_POW, + OPR_CONCAT, + OPR_NE, OPR_EQ, + OPR_LT, OPR_LE, OPR_GT, OPR_GE, + OPR_AND, OPR_OR, + OPR_NOBINOPR +} BinOpr; + + +typedef enum UnOpr { OPR_MINUS, OPR_NOT, OPR_LEN, OPR_NOUNOPR } UnOpr; + + +#define getcode(fs,e) ((fs)->f->code[(e)->u.s.info]) + +#define luaK_codeAsBx(fs,o,A,sBx) luaK_codeABx(fs,o,A,(sBx)+MAXARG_sBx) + +#define luaK_setmultret(fs,e) luaK_setreturns(fs, e, LUA_MULTRET) + +LUAI_FUNC int luaK_codeABx (FuncState *fs, OpCode o, int A, unsigned int Bx); +LUAI_FUNC int luaK_codeABC (FuncState *fs, OpCode o, int A, int B, int C); +LUAI_FUNC void luaK_fixline (FuncState *fs, int line); +LUAI_FUNC void luaK_nil (FuncState *fs, int from, int n); +LUAI_FUNC void luaK_reserveregs (FuncState *fs, int n); +LUAI_FUNC void luaK_checkstack (FuncState *fs, int n); +LUAI_FUNC int luaK_stringK (FuncState *fs, TString *s); +LUAI_FUNC int luaK_numberK (FuncState *fs, lua_Number r); +LUAI_FUNC void luaK_dischargevars (FuncState *fs, expdesc *e); +LUAI_FUNC int luaK_exp2anyreg (FuncState *fs, expdesc *e); +LUAI_FUNC void luaK_exp2nextreg (FuncState *fs, expdesc *e); +LUAI_FUNC void luaK_exp2val (FuncState *fs, expdesc *e); +LUAI_FUNC int luaK_exp2RK (FuncState *fs, expdesc *e); +LUAI_FUNC void luaK_self (FuncState *fs, expdesc *e, expdesc *key); +LUAI_FUNC void luaK_indexed (FuncState *fs, expdesc *t, expdesc *k); +LUAI_FUNC void luaK_goiftrue (FuncState *fs, expdesc *e); +LUAI_FUNC void luaK_storevar (FuncState *fs, expdesc *var, expdesc *e); +LUAI_FUNC void luaK_setreturns (FuncState *fs, expdesc *e, int nresults); +LUAI_FUNC void luaK_setoneret (FuncState *fs, expdesc *e); +LUAI_FUNC int luaK_jump (FuncState *fs); +LUAI_FUNC void luaK_ret (FuncState *fs, int first, int nret); +LUAI_FUNC void luaK_patchlist (FuncState *fs, int list, int target); +LUAI_FUNC void luaK_patchtohere (FuncState *fs, int list); +LUAI_FUNC void luaK_concat (FuncState *fs, int *l1, int l2); +LUAI_FUNC int luaK_getlabel (FuncState *fs); +LUAI_FUNC void luaK_prefix (FuncState *fs, UnOpr op, expdesc *v); +LUAI_FUNC void luaK_infix (FuncState *fs, BinOpr op, expdesc *v); +LUAI_FUNC void luaK_posfix (FuncState *fs, BinOpr op, expdesc *v1, expdesc *v2); +LUAI_FUNC void luaK_setlist (FuncState *fs, int base, int nelems, int tostore); + + +#endif diff --git a/extern/lua-5.1.5/src/ldblib.c b/extern/lua-5.1.5/src/ldblib.c new file mode 100644 index 00000000..2027eda5 --- /dev/null +++ b/extern/lua-5.1.5/src/ldblib.c @@ -0,0 +1,398 @@ +/* +** $Id: ldblib.c,v 1.104.1.4 2009/08/04 18:50:18 roberto Exp $ +** Interface from Lua to its debug API +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include + +#define ldblib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + + +static int db_getregistry (lua_State *L) { + lua_pushvalue(L, LUA_REGISTRYINDEX); + return 1; +} + + +static int db_getmetatable (lua_State *L) { + luaL_checkany(L, 1); + if (!lua_getmetatable(L, 1)) { + lua_pushnil(L); /* no metatable */ + } + return 1; +} + + +static int db_setmetatable (lua_State *L) { + int t = lua_type(L, 2); + luaL_argcheck(L, t == LUA_TNIL || t == LUA_TTABLE, 2, + "nil or table expected"); + lua_settop(L, 2); + lua_pushboolean(L, lua_setmetatable(L, 1)); + return 1; +} + + +static int db_getfenv (lua_State *L) { + luaL_checkany(L, 1); + lua_getfenv(L, 1); + return 1; +} + + +static int db_setfenv (lua_State *L) { + luaL_checktype(L, 2, LUA_TTABLE); + lua_settop(L, 2); + if (lua_setfenv(L, 1) == 0) + luaL_error(L, LUA_QL("setfenv") + " cannot change environment of given object"); + return 1; +} + + +static void settabss (lua_State *L, const char *i, const char *v) { + lua_pushstring(L, v); + lua_setfield(L, -2, i); +} + + +static void settabsi (lua_State *L, const char *i, int v) { + lua_pushinteger(L, v); + lua_setfield(L, -2, i); +} + + +static lua_State *getthread (lua_State *L, int *arg) { + if (lua_isthread(L, 1)) { + *arg = 1; + return lua_tothread(L, 1); + } + else { + *arg = 0; + return L; + } +} + + +static void treatstackoption (lua_State *L, lua_State *L1, const char *fname) { + if (L == L1) { + lua_pushvalue(L, -2); + lua_remove(L, -3); + } + else + lua_xmove(L1, L, 1); + lua_setfield(L, -2, fname); +} + + +static int db_getinfo (lua_State *L) { + lua_Debug ar; + int arg; + lua_State *L1 = getthread(L, &arg); + const char *options = luaL_optstring(L, arg+2, "flnSu"); + if (lua_isnumber(L, arg+1)) { + if (!lua_getstack(L1, (int)lua_tointeger(L, arg+1), &ar)) { + lua_pushnil(L); /* level out of range */ + return 1; + } + } + else if (lua_isfunction(L, arg+1)) { + lua_pushfstring(L, ">%s", options); + options = lua_tostring(L, -1); + lua_pushvalue(L, arg+1); + lua_xmove(L, L1, 1); + } + else + return luaL_argerror(L, arg+1, "function or level expected"); + if (!lua_getinfo(L1, options, &ar)) + return luaL_argerror(L, arg+2, "invalid option"); + lua_createtable(L, 0, 2); + if (strchr(options, 'S')) { + settabss(L, "source", ar.source); + settabss(L, "short_src", ar.short_src); + settabsi(L, "linedefined", ar.linedefined); + settabsi(L, "lastlinedefined", ar.lastlinedefined); + settabss(L, "what", ar.what); + } + if (strchr(options, 'l')) + settabsi(L, "currentline", ar.currentline); + if (strchr(options, 'u')) + settabsi(L, "nups", ar.nups); + if (strchr(options, 'n')) { + settabss(L, "name", ar.name); + settabss(L, "namewhat", ar.namewhat); + } + if (strchr(options, 'L')) + treatstackoption(L, L1, "activelines"); + if (strchr(options, 'f')) + treatstackoption(L, L1, "func"); + return 1; /* return table */ +} + + +static int db_getlocal (lua_State *L) { + int arg; + lua_State *L1 = getthread(L, &arg); + lua_Debug ar; + const char *name; + if (!lua_getstack(L1, luaL_checkint(L, arg+1), &ar)) /* out of range? */ + return luaL_argerror(L, arg+1, "level out of range"); + name = lua_getlocal(L1, &ar, luaL_checkint(L, arg+2)); + if (name) { + lua_xmove(L1, L, 1); + lua_pushstring(L, name); + lua_pushvalue(L, -2); + return 2; + } + else { + lua_pushnil(L); + return 1; + } +} + + +static int db_setlocal (lua_State *L) { + int arg; + lua_State *L1 = getthread(L, &arg); + lua_Debug ar; + if (!lua_getstack(L1, luaL_checkint(L, arg+1), &ar)) /* out of range? */ + return luaL_argerror(L, arg+1, "level out of range"); + luaL_checkany(L, arg+3); + lua_settop(L, arg+3); + lua_xmove(L, L1, 1); + lua_pushstring(L, lua_setlocal(L1, &ar, luaL_checkint(L, arg+2))); + return 1; +} + + +static int auxupvalue (lua_State *L, int get) { + const char *name; + int n = luaL_checkint(L, 2); + luaL_checktype(L, 1, LUA_TFUNCTION); + if (lua_iscfunction(L, 1)) return 0; /* cannot touch C upvalues from Lua */ + name = get ? lua_getupvalue(L, 1, n) : lua_setupvalue(L, 1, n); + if (name == NULL) return 0; + lua_pushstring(L, name); + lua_insert(L, -(get+1)); + return get + 1; +} + + +static int db_getupvalue (lua_State *L) { + return auxupvalue(L, 1); +} + + +static int db_setupvalue (lua_State *L) { + luaL_checkany(L, 3); + return auxupvalue(L, 0); +} + + + +static const char KEY_HOOK = 'h'; + + +static void hookf (lua_State *L, lua_Debug *ar) { + static const char *const hooknames[] = + {"call", "return", "line", "count", "tail return"}; + lua_pushlightuserdata(L, (void *)&KEY_HOOK); + lua_rawget(L, LUA_REGISTRYINDEX); + lua_pushlightuserdata(L, L); + lua_rawget(L, -2); + if (lua_isfunction(L, -1)) { + lua_pushstring(L, hooknames[(int)ar->event]); + if (ar->currentline >= 0) + lua_pushinteger(L, ar->currentline); + else lua_pushnil(L); + lua_assert(lua_getinfo(L, "lS", ar)); + lua_call(L, 2, 0); + } +} + + +static int makemask (const char *smask, int count) { + int mask = 0; + if (strchr(smask, 'c')) mask |= LUA_MASKCALL; + if (strchr(smask, 'r')) mask |= LUA_MASKRET; + if (strchr(smask, 'l')) mask |= LUA_MASKLINE; + if (count > 0) mask |= LUA_MASKCOUNT; + return mask; +} + + +static char *unmakemask (int mask, char *smask) { + int i = 0; + if (mask & LUA_MASKCALL) smask[i++] = 'c'; + if (mask & LUA_MASKRET) smask[i++] = 'r'; + if (mask & LUA_MASKLINE) smask[i++] = 'l'; + smask[i] = '\0'; + return smask; +} + + +static void gethooktable (lua_State *L) { + lua_pushlightuserdata(L, (void *)&KEY_HOOK); + lua_rawget(L, LUA_REGISTRYINDEX); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_createtable(L, 0, 1); + lua_pushlightuserdata(L, (void *)&KEY_HOOK); + lua_pushvalue(L, -2); + lua_rawset(L, LUA_REGISTRYINDEX); + } +} + + +static int db_sethook (lua_State *L) { + int arg, mask, count; + lua_Hook func; + lua_State *L1 = getthread(L, &arg); + if (lua_isnoneornil(L, arg+1)) { + lua_settop(L, arg+1); + func = NULL; mask = 0; count = 0; /* turn off hooks */ + } + else { + const char *smask = luaL_checkstring(L, arg+2); + luaL_checktype(L, arg+1, LUA_TFUNCTION); + count = luaL_optint(L, arg+3, 0); + func = hookf; mask = makemask(smask, count); + } + gethooktable(L); + lua_pushlightuserdata(L, L1); + lua_pushvalue(L, arg+1); + lua_rawset(L, -3); /* set new hook */ + lua_pop(L, 1); /* remove hook table */ + lua_sethook(L1, func, mask, count); /* set hooks */ + return 0; +} + + +static int db_gethook (lua_State *L) { + int arg; + lua_State *L1 = getthread(L, &arg); + char buff[5]; + int mask = lua_gethookmask(L1); + lua_Hook hook = lua_gethook(L1); + if (hook != NULL && hook != hookf) /* external hook? */ + lua_pushliteral(L, "external hook"); + else { + gethooktable(L); + lua_pushlightuserdata(L, L1); + lua_rawget(L, -2); /* get hook */ + lua_remove(L, -2); /* remove hook table */ + } + lua_pushstring(L, unmakemask(mask, buff)); + lua_pushinteger(L, lua_gethookcount(L1)); + return 3; +} + + +static int db_debug (lua_State *L) { + for (;;) { + char buffer[250]; + fputs("lua_debug> ", stderr); + if (fgets(buffer, sizeof(buffer), stdin) == 0 || + strcmp(buffer, "cont\n") == 0) + return 0; + if (luaL_loadbuffer(L, buffer, strlen(buffer), "=(debug command)") || + lua_pcall(L, 0, 0, 0)) { + fputs(lua_tostring(L, -1), stderr); + fputs("\n", stderr); + } + lua_settop(L, 0); /* remove eventual returns */ + } +} + + +#define LEVELS1 12 /* size of the first part of the stack */ +#define LEVELS2 10 /* size of the second part of the stack */ + +static int db_errorfb (lua_State *L) { + int level; + int firstpart = 1; /* still before eventual `...' */ + int arg; + lua_State *L1 = getthread(L, &arg); + lua_Debug ar; + if (lua_isnumber(L, arg+2)) { + level = (int)lua_tointeger(L, arg+2); + lua_pop(L, 1); + } + else + level = (L == L1) ? 1 : 0; /* level 0 may be this own function */ + if (lua_gettop(L) == arg) + lua_pushliteral(L, ""); + else if (!lua_isstring(L, arg+1)) return 1; /* message is not a string */ + else lua_pushliteral(L, "\n"); + lua_pushliteral(L, "stack traceback:"); + while (lua_getstack(L1, level++, &ar)) { + if (level > LEVELS1 && firstpart) { + /* no more than `LEVELS2' more levels? */ + if (!lua_getstack(L1, level+LEVELS2, &ar)) + level--; /* keep going */ + else { + lua_pushliteral(L, "\n\t..."); /* too many levels */ + while (lua_getstack(L1, level+LEVELS2, &ar)) /* find last levels */ + level++; + } + firstpart = 0; + continue; + } + lua_pushliteral(L, "\n\t"); + lua_getinfo(L1, "Snl", &ar); + lua_pushfstring(L, "%s:", ar.short_src); + if (ar.currentline > 0) + lua_pushfstring(L, "%d:", ar.currentline); + if (*ar.namewhat != '\0') /* is there a name? */ + lua_pushfstring(L, " in function " LUA_QS, ar.name); + else { + if (*ar.what == 'm') /* main? */ + lua_pushfstring(L, " in main chunk"); + else if (*ar.what == 'C' || *ar.what == 't') + lua_pushliteral(L, " ?"); /* C function or tail call */ + else + lua_pushfstring(L, " in function <%s:%d>", + ar.short_src, ar.linedefined); + } + lua_concat(L, lua_gettop(L) - arg); + } + lua_concat(L, lua_gettop(L) - arg); + return 1; +} + + +static const luaL_Reg dblib[] = { + {"debug", db_debug}, + {"getfenv", db_getfenv}, + {"gethook", db_gethook}, + {"getinfo", db_getinfo}, + {"getlocal", db_getlocal}, + {"getregistry", db_getregistry}, + {"getmetatable", db_getmetatable}, + {"getupvalue", db_getupvalue}, + {"setfenv", db_setfenv}, + {"sethook", db_sethook}, + {"setlocal", db_setlocal}, + {"setmetatable", db_setmetatable}, + {"setupvalue", db_setupvalue}, + {"traceback", db_errorfb}, + {NULL, NULL} +}; + + +LUALIB_API int luaopen_debug (lua_State *L) { + luaL_register(L, LUA_DBLIBNAME, dblib); + return 1; +} + diff --git a/extern/lua-5.1.5/src/ldebug.c b/extern/lua-5.1.5/src/ldebug.c new file mode 100644 index 00000000..50ad3d38 --- /dev/null +++ b/extern/lua-5.1.5/src/ldebug.c @@ -0,0 +1,638 @@ +/* +** $Id: ldebug.c,v 2.29.1.6 2008/05/08 16:56:26 roberto Exp $ +** Debug Interface +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include + + +#define ldebug_c +#define LUA_CORE + +#include "lua.h" + +#include "lapi.h" +#include "lcode.h" +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" +#include "lvm.h" + + + +static const char *getfuncname (lua_State *L, CallInfo *ci, const char **name); + + +static int currentpc (lua_State *L, CallInfo *ci) { + if (!isLua(ci)) return -1; /* function is not a Lua function? */ + if (ci == L->ci) + ci->savedpc = L->savedpc; + return pcRel(ci->savedpc, ci_func(ci)->l.p); +} + + +static int currentline (lua_State *L, CallInfo *ci) { + int pc = currentpc(L, ci); + if (pc < 0) + return -1; /* only active lua functions have current-line information */ + else + return getline(ci_func(ci)->l.p, pc); +} + + +/* +** this function can be called asynchronous (e.g. during a signal) +*/ +LUA_API int lua_sethook (lua_State *L, lua_Hook func, int mask, int count) { + if (func == NULL || mask == 0) { /* turn off hooks? */ + mask = 0; + func = NULL; + } + L->hook = func; + L->basehookcount = count; + resethookcount(L); + L->hookmask = cast_byte(mask); + return 1; +} + + +LUA_API lua_Hook lua_gethook (lua_State *L) { + return L->hook; +} + + +LUA_API int lua_gethookmask (lua_State *L) { + return L->hookmask; +} + + +LUA_API int lua_gethookcount (lua_State *L) { + return L->basehookcount; +} + + +LUA_API int lua_getstack (lua_State *L, int level, lua_Debug *ar) { + int status; + CallInfo *ci; + lua_lock(L); + for (ci = L->ci; level > 0 && ci > L->base_ci; ci--) { + level--; + if (f_isLua(ci)) /* Lua function? */ + level -= ci->tailcalls; /* skip lost tail calls */ + } + if (level == 0 && ci > L->base_ci) { /* level found? */ + status = 1; + ar->i_ci = cast_int(ci - L->base_ci); + } + else if (level < 0) { /* level is of a lost tail call? */ + status = 1; + ar->i_ci = 0; + } + else status = 0; /* no such level */ + lua_unlock(L); + return status; +} + + +static Proto *getluaproto (CallInfo *ci) { + return (isLua(ci) ? ci_func(ci)->l.p : NULL); +} + + +static const char *findlocal (lua_State *L, CallInfo *ci, int n) { + const char *name; + Proto *fp = getluaproto(ci); + if (fp && (name = luaF_getlocalname(fp, n, currentpc(L, ci))) != NULL) + return name; /* is a local variable in a Lua function */ + else { + StkId limit = (ci == L->ci) ? L->top : (ci+1)->func; + if (limit - ci->base >= n && n > 0) /* is 'n' inside 'ci' stack? */ + return "(*temporary)"; + else + return NULL; + } +} + + +LUA_API const char *lua_getlocal (lua_State *L, const lua_Debug *ar, int n) { + CallInfo *ci = L->base_ci + ar->i_ci; + const char *name = findlocal(L, ci, n); + lua_lock(L); + if (name) + luaA_pushobject(L, ci->base + (n - 1)); + lua_unlock(L); + return name; +} + + +LUA_API const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n) { + CallInfo *ci = L->base_ci + ar->i_ci; + const char *name = findlocal(L, ci, n); + lua_lock(L); + if (name) + setobjs2s(L, ci->base + (n - 1), L->top - 1); + L->top--; /* pop value */ + lua_unlock(L); + return name; +} + + +static void funcinfo (lua_Debug *ar, Closure *cl) { + if (cl->c.isC) { + ar->source = "=[C]"; + ar->linedefined = -1; + ar->lastlinedefined = -1; + ar->what = "C"; + } + else { + ar->source = getstr(cl->l.p->source); + ar->linedefined = cl->l.p->linedefined; + ar->lastlinedefined = cl->l.p->lastlinedefined; + ar->what = (ar->linedefined == 0) ? "main" : "Lua"; + } + luaO_chunkid(ar->short_src, ar->source, LUA_IDSIZE); +} + + +static void info_tailcall (lua_Debug *ar) { + ar->name = ar->namewhat = ""; + ar->what = "tail"; + ar->lastlinedefined = ar->linedefined = ar->currentline = -1; + ar->source = "=(tail call)"; + luaO_chunkid(ar->short_src, ar->source, LUA_IDSIZE); + ar->nups = 0; +} + + +static void collectvalidlines (lua_State *L, Closure *f) { + if (f == NULL || f->c.isC) { + setnilvalue(L->top); + } + else { + Table *t = luaH_new(L, 0, 0); + int *lineinfo = f->l.p->lineinfo; + int i; + for (i=0; il.p->sizelineinfo; i++) + setbvalue(luaH_setnum(L, t, lineinfo[i]), 1); + sethvalue(L, L->top, t); + } + incr_top(L); +} + + +static int auxgetinfo (lua_State *L, const char *what, lua_Debug *ar, + Closure *f, CallInfo *ci) { + int status = 1; + if (f == NULL) { + info_tailcall(ar); + return status; + } + for (; *what; what++) { + switch (*what) { + case 'S': { + funcinfo(ar, f); + break; + } + case 'l': { + ar->currentline = (ci) ? currentline(L, ci) : -1; + break; + } + case 'u': { + ar->nups = f->c.nupvalues; + break; + } + case 'n': { + ar->namewhat = (ci) ? getfuncname(L, ci, &ar->name) : NULL; + if (ar->namewhat == NULL) { + ar->namewhat = ""; /* not found */ + ar->name = NULL; + } + break; + } + case 'L': + case 'f': /* handled by lua_getinfo */ + break; + default: status = 0; /* invalid option */ + } + } + return status; +} + + +LUA_API int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar) { + int status; + Closure *f = NULL; + CallInfo *ci = NULL; + lua_lock(L); + if (*what == '>') { + StkId func = L->top - 1; + luai_apicheck(L, ttisfunction(func)); + what++; /* skip the '>' */ + f = clvalue(func); + L->top--; /* pop function */ + } + else if (ar->i_ci != 0) { /* no tail call? */ + ci = L->base_ci + ar->i_ci; + lua_assert(ttisfunction(ci->func)); + f = clvalue(ci->func); + } + status = auxgetinfo(L, what, ar, f, ci); + if (strchr(what, 'f')) { + if (f == NULL) setnilvalue(L->top); + else setclvalue(L, L->top, f); + incr_top(L); + } + if (strchr(what, 'L')) + collectvalidlines(L, f); + lua_unlock(L); + return status; +} + + +/* +** {====================================================== +** Symbolic Execution and code checker +** ======================================================= +*/ + +#define check(x) if (!(x)) return 0; + +#define checkjump(pt,pc) check(0 <= pc && pc < pt->sizecode) + +#define checkreg(pt,reg) check((reg) < (pt)->maxstacksize) + + + +static int precheck (const Proto *pt) { + check(pt->maxstacksize <= MAXSTACK); + check(pt->numparams+(pt->is_vararg & VARARG_HASARG) <= pt->maxstacksize); + check(!(pt->is_vararg & VARARG_NEEDSARG) || + (pt->is_vararg & VARARG_HASARG)); + check(pt->sizeupvalues <= pt->nups); + check(pt->sizelineinfo == pt->sizecode || pt->sizelineinfo == 0); + check(pt->sizecode > 0 && GET_OPCODE(pt->code[pt->sizecode-1]) == OP_RETURN); + return 1; +} + + +#define checkopenop(pt,pc) luaG_checkopenop((pt)->code[(pc)+1]) + +int luaG_checkopenop (Instruction i) { + switch (GET_OPCODE(i)) { + case OP_CALL: + case OP_TAILCALL: + case OP_RETURN: + case OP_SETLIST: { + check(GETARG_B(i) == 0); + return 1; + } + default: return 0; /* invalid instruction after an open call */ + } +} + + +static int checkArgMode (const Proto *pt, int r, enum OpArgMask mode) { + switch (mode) { + case OpArgN: check(r == 0); break; + case OpArgU: break; + case OpArgR: checkreg(pt, r); break; + case OpArgK: + check(ISK(r) ? INDEXK(r) < pt->sizek : r < pt->maxstacksize); + break; + } + return 1; +} + + +static Instruction symbexec (const Proto *pt, int lastpc, int reg) { + int pc; + int last; /* stores position of last instruction that changed `reg' */ + last = pt->sizecode-1; /* points to final return (a `neutral' instruction) */ + check(precheck(pt)); + for (pc = 0; pc < lastpc; pc++) { + Instruction i = pt->code[pc]; + OpCode op = GET_OPCODE(i); + int a = GETARG_A(i); + int b = 0; + int c = 0; + check(op < NUM_OPCODES); + checkreg(pt, a); + switch (getOpMode(op)) { + case iABC: { + b = GETARG_B(i); + c = GETARG_C(i); + check(checkArgMode(pt, b, getBMode(op))); + check(checkArgMode(pt, c, getCMode(op))); + break; + } + case iABx: { + b = GETARG_Bx(i); + if (getBMode(op) == OpArgK) check(b < pt->sizek); + break; + } + case iAsBx: { + b = GETARG_sBx(i); + if (getBMode(op) == OpArgR) { + int dest = pc+1+b; + check(0 <= dest && dest < pt->sizecode); + if (dest > 0) { + int j; + /* check that it does not jump to a setlist count; this + is tricky, because the count from a previous setlist may + have the same value of an invalid setlist; so, we must + go all the way back to the first of them (if any) */ + for (j = 0; j < dest; j++) { + Instruction d = pt->code[dest-1-j]; + if (!(GET_OPCODE(d) == OP_SETLIST && GETARG_C(d) == 0)) break; + } + /* if 'j' is even, previous value is not a setlist (even if + it looks like one) */ + check((j&1) == 0); + } + } + break; + } + } + if (testAMode(op)) { + if (a == reg) last = pc; /* change register `a' */ + } + if (testTMode(op)) { + check(pc+2 < pt->sizecode); /* check skip */ + check(GET_OPCODE(pt->code[pc+1]) == OP_JMP); + } + switch (op) { + case OP_LOADBOOL: { + if (c == 1) { /* does it jump? */ + check(pc+2 < pt->sizecode); /* check its jump */ + check(GET_OPCODE(pt->code[pc+1]) != OP_SETLIST || + GETARG_C(pt->code[pc+1]) != 0); + } + break; + } + case OP_LOADNIL: { + if (a <= reg && reg <= b) + last = pc; /* set registers from `a' to `b' */ + break; + } + case OP_GETUPVAL: + case OP_SETUPVAL: { + check(b < pt->nups); + break; + } + case OP_GETGLOBAL: + case OP_SETGLOBAL: { + check(ttisstring(&pt->k[b])); + break; + } + case OP_SELF: { + checkreg(pt, a+1); + if (reg == a+1) last = pc; + break; + } + case OP_CONCAT: { + check(b < c); /* at least two operands */ + break; + } + case OP_TFORLOOP: { + check(c >= 1); /* at least one result (control variable) */ + checkreg(pt, a+2+c); /* space for results */ + if (reg >= a+2) last = pc; /* affect all regs above its base */ + break; + } + case OP_FORLOOP: + case OP_FORPREP: + checkreg(pt, a+3); + /* go through */ + case OP_JMP: { + int dest = pc+1+b; + /* not full check and jump is forward and do not skip `lastpc'? */ + if (reg != NO_REG && pc < dest && dest <= lastpc) + pc += b; /* do the jump */ + break; + } + case OP_CALL: + case OP_TAILCALL: { + if (b != 0) { + checkreg(pt, a+b-1); + } + c--; /* c = num. returns */ + if (c == LUA_MULTRET) { + check(checkopenop(pt, pc)); + } + else if (c != 0) + checkreg(pt, a+c-1); + if (reg >= a) last = pc; /* affect all registers above base */ + break; + } + case OP_RETURN: { + b--; /* b = num. returns */ + if (b > 0) checkreg(pt, a+b-1); + break; + } + case OP_SETLIST: { + if (b > 0) checkreg(pt, a + b); + if (c == 0) { + pc++; + check(pc < pt->sizecode - 1); + } + break; + } + case OP_CLOSURE: { + int nup, j; + check(b < pt->sizep); + nup = pt->p[b]->nups; + check(pc + nup < pt->sizecode); + for (j = 1; j <= nup; j++) { + OpCode op1 = GET_OPCODE(pt->code[pc + j]); + check(op1 == OP_GETUPVAL || op1 == OP_MOVE); + } + if (reg != NO_REG) /* tracing? */ + pc += nup; /* do not 'execute' these pseudo-instructions */ + break; + } + case OP_VARARG: { + check((pt->is_vararg & VARARG_ISVARARG) && + !(pt->is_vararg & VARARG_NEEDSARG)); + b--; + if (b == LUA_MULTRET) check(checkopenop(pt, pc)); + checkreg(pt, a+b-1); + break; + } + default: break; + } + } + return pt->code[last]; +} + +#undef check +#undef checkjump +#undef checkreg + +/* }====================================================== */ + + +int luaG_checkcode (const Proto *pt) { + return (symbexec(pt, pt->sizecode, NO_REG) != 0); +} + + +static const char *kname (Proto *p, int c) { + if (ISK(c) && ttisstring(&p->k[INDEXK(c)])) + return svalue(&p->k[INDEXK(c)]); + else + return "?"; +} + + +static const char *getobjname (lua_State *L, CallInfo *ci, int stackpos, + const char **name) { + if (isLua(ci)) { /* a Lua function? */ + Proto *p = ci_func(ci)->l.p; + int pc = currentpc(L, ci); + Instruction i; + *name = luaF_getlocalname(p, stackpos+1, pc); + if (*name) /* is a local? */ + return "local"; + i = symbexec(p, pc, stackpos); /* try symbolic execution */ + lua_assert(pc != -1); + switch (GET_OPCODE(i)) { + case OP_GETGLOBAL: { + int g = GETARG_Bx(i); /* global index */ + lua_assert(ttisstring(&p->k[g])); + *name = svalue(&p->k[g]); + return "global"; + } + case OP_MOVE: { + int a = GETARG_A(i); + int b = GETARG_B(i); /* move from `b' to `a' */ + if (b < a) + return getobjname(L, ci, b, name); /* get name for `b' */ + break; + } + case OP_GETTABLE: { + int k = GETARG_C(i); /* key index */ + *name = kname(p, k); + return "field"; + } + case OP_GETUPVAL: { + int u = GETARG_B(i); /* upvalue index */ + *name = p->upvalues ? getstr(p->upvalues[u]) : "?"; + return "upvalue"; + } + case OP_SELF: { + int k = GETARG_C(i); /* key index */ + *name = kname(p, k); + return "method"; + } + default: break; + } + } + return NULL; /* no useful name found */ +} + + +static const char *getfuncname (lua_State *L, CallInfo *ci, const char **name) { + Instruction i; + if ((isLua(ci) && ci->tailcalls > 0) || !isLua(ci - 1)) + return NULL; /* calling function is not Lua (or is unknown) */ + ci--; /* calling function */ + i = ci_func(ci)->l.p->code[currentpc(L, ci)]; + if (GET_OPCODE(i) == OP_CALL || GET_OPCODE(i) == OP_TAILCALL || + GET_OPCODE(i) == OP_TFORLOOP) + return getobjname(L, ci, GETARG_A(i), name); + else + return NULL; /* no useful name can be found */ +} + + +/* only ANSI way to check whether a pointer points to an array */ +static int isinstack (CallInfo *ci, const TValue *o) { + StkId p; + for (p = ci->base; p < ci->top; p++) + if (o == p) return 1; + return 0; +} + + +void luaG_typeerror (lua_State *L, const TValue *o, const char *op) { + const char *name = NULL; + const char *t = luaT_typenames[ttype(o)]; + const char *kind = (isinstack(L->ci, o)) ? + getobjname(L, L->ci, cast_int(o - L->base), &name) : + NULL; + if (kind) + luaG_runerror(L, "attempt to %s %s " LUA_QS " (a %s value)", + op, kind, name, t); + else + luaG_runerror(L, "attempt to %s a %s value", op, t); +} + + +void luaG_concaterror (lua_State *L, StkId p1, StkId p2) { + if (ttisstring(p1) || ttisnumber(p1)) p1 = p2; + lua_assert(!ttisstring(p1) && !ttisnumber(p1)); + luaG_typeerror(L, p1, "concatenate"); +} + + +void luaG_aritherror (lua_State *L, const TValue *p1, const TValue *p2) { + TValue temp; + if (luaV_tonumber(p1, &temp) == NULL) + p2 = p1; /* first operand is wrong */ + luaG_typeerror(L, p2, "perform arithmetic on"); +} + + +int luaG_ordererror (lua_State *L, const TValue *p1, const TValue *p2) { + const char *t1 = luaT_typenames[ttype(p1)]; + const char *t2 = luaT_typenames[ttype(p2)]; + if (t1[2] == t2[2]) + luaG_runerror(L, "attempt to compare two %s values", t1); + else + luaG_runerror(L, "attempt to compare %s with %s", t1, t2); + return 0; +} + + +static void addinfo (lua_State *L, const char *msg) { + CallInfo *ci = L->ci; + if (isLua(ci)) { /* is Lua code? */ + char buff[LUA_IDSIZE]; /* add file:line information */ + int line = currentline(L, ci); + luaO_chunkid(buff, getstr(getluaproto(ci)->source), LUA_IDSIZE); + luaO_pushfstring(L, "%s:%d: %s", buff, line, msg); + } +} + + +void luaG_errormsg (lua_State *L) { + if (L->errfunc != 0) { /* is there an error handling function? */ + StkId errfunc = restorestack(L, L->errfunc); + if (!ttisfunction(errfunc)) luaD_throw(L, LUA_ERRERR); + setobjs2s(L, L->top, L->top - 1); /* move argument */ + setobjs2s(L, L->top - 1, errfunc); /* push function */ + incr_top(L); + luaD_call(L, L->top - 2, 1); /* call it */ + } + luaD_throw(L, LUA_ERRRUN); +} + + +void luaG_runerror (lua_State *L, const char *fmt, ...) { + va_list argp; + va_start(argp, fmt); + addinfo(L, luaO_pushvfstring(L, fmt, argp)); + va_end(argp); + luaG_errormsg(L); +} + diff --git a/extern/lua-5.1.5/src/ldebug.h b/extern/lua-5.1.5/src/ldebug.h new file mode 100644 index 00000000..ba28a972 --- /dev/null +++ b/extern/lua-5.1.5/src/ldebug.h @@ -0,0 +1,33 @@ +/* +** $Id: ldebug.h,v 2.3.1.1 2007/12/27 13:02:25 roberto Exp $ +** Auxiliary functions from Debug Interface module +** See Copyright Notice in lua.h +*/ + +#ifndef ldebug_h +#define ldebug_h + + +#include "lstate.h" + + +#define pcRel(pc, p) (cast(int, (pc) - (p)->code) - 1) + +#define getline(f,pc) (((f)->lineinfo) ? (f)->lineinfo[pc] : 0) + +#define resethookcount(L) (L->hookcount = L->basehookcount) + + +LUAI_FUNC void luaG_typeerror (lua_State *L, const TValue *o, + const char *opname); +LUAI_FUNC void luaG_concaterror (lua_State *L, StkId p1, StkId p2); +LUAI_FUNC void luaG_aritherror (lua_State *L, const TValue *p1, + const TValue *p2); +LUAI_FUNC int luaG_ordererror (lua_State *L, const TValue *p1, + const TValue *p2); +LUAI_FUNC void luaG_runerror (lua_State *L, const char *fmt, ...); +LUAI_FUNC void luaG_errormsg (lua_State *L); +LUAI_FUNC int luaG_checkcode (const Proto *pt); +LUAI_FUNC int luaG_checkopenop (Instruction i); + +#endif diff --git a/extern/lua-5.1.5/src/ldo.c b/extern/lua-5.1.5/src/ldo.c new file mode 100644 index 00000000..d1bf786c --- /dev/null +++ b/extern/lua-5.1.5/src/ldo.c @@ -0,0 +1,519 @@ +/* +** $Id: ldo.c,v 2.38.1.4 2012/01/18 02:27:10 roberto Exp $ +** Stack and Call structure of Lua +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include + +#define ldo_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lgc.h" +#include "lmem.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lparser.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" +#include "lundump.h" +#include "lvm.h" +#include "lzio.h" + + + + +/* +** {====================================================== +** Error-recovery functions +** ======================================================= +*/ + + +/* chain list of long jump buffers */ +struct lua_longjmp { + struct lua_longjmp *previous; + luai_jmpbuf b; + volatile int status; /* error code */ +}; + + +void luaD_seterrorobj (lua_State *L, int errcode, StkId oldtop) { + switch (errcode) { + case LUA_ERRMEM: { + setsvalue2s(L, oldtop, luaS_newliteral(L, MEMERRMSG)); + break; + } + case LUA_ERRERR: { + setsvalue2s(L, oldtop, luaS_newliteral(L, "error in error handling")); + break; + } + case LUA_ERRSYNTAX: + case LUA_ERRRUN: { + setobjs2s(L, oldtop, L->top - 1); /* error message on current top */ + break; + } + } + L->top = oldtop + 1; +} + + +static void restore_stack_limit (lua_State *L) { + lua_assert(L->stack_last - L->stack == L->stacksize - EXTRA_STACK - 1); + if (L->size_ci > LUAI_MAXCALLS) { /* there was an overflow? */ + int inuse = cast_int(L->ci - L->base_ci); + if (inuse + 1 < LUAI_MAXCALLS) /* can `undo' overflow? */ + luaD_reallocCI(L, LUAI_MAXCALLS); + } +} + + +static void resetstack (lua_State *L, int status) { + L->ci = L->base_ci; + L->base = L->ci->base; + luaF_close(L, L->base); /* close eventual pending closures */ + luaD_seterrorobj(L, status, L->base); + L->nCcalls = L->baseCcalls; + L->allowhook = 1; + restore_stack_limit(L); + L->errfunc = 0; + L->errorJmp = NULL; +} + + +void luaD_throw (lua_State *L, int errcode) { + if (L->errorJmp) { + L->errorJmp->status = errcode; + LUAI_THROW(L, L->errorJmp); + } + else { + L->status = cast_byte(errcode); + if (G(L)->panic) { + resetstack(L, errcode); + lua_unlock(L); + G(L)->panic(L); + } + exit(EXIT_FAILURE); + } +} + + +int luaD_rawrunprotected (lua_State *L, Pfunc f, void *ud) { + struct lua_longjmp lj; + lj.status = 0; + lj.previous = L->errorJmp; /* chain new error handler */ + L->errorJmp = &lj; + LUAI_TRY(L, &lj, + (*f)(L, ud); + ); + L->errorJmp = lj.previous; /* restore old error handler */ + return lj.status; +} + +/* }====================================================== */ + + +static void correctstack (lua_State *L, TValue *oldstack) { + CallInfo *ci; + GCObject *up; + L->top = (L->top - oldstack) + L->stack; + for (up = L->openupval; up != NULL; up = up->gch.next) + gco2uv(up)->v = (gco2uv(up)->v - oldstack) + L->stack; + for (ci = L->base_ci; ci <= L->ci; ci++) { + ci->top = (ci->top - oldstack) + L->stack; + ci->base = (ci->base - oldstack) + L->stack; + ci->func = (ci->func - oldstack) + L->stack; + } + L->base = (L->base - oldstack) + L->stack; +} + + +void luaD_reallocstack (lua_State *L, int newsize) { + TValue *oldstack = L->stack; + int realsize = newsize + 1 + EXTRA_STACK; + lua_assert(L->stack_last - L->stack == L->stacksize - EXTRA_STACK - 1); + luaM_reallocvector(L, L->stack, L->stacksize, realsize, TValue); + L->stacksize = realsize; + L->stack_last = L->stack+newsize; + correctstack(L, oldstack); +} + + +void luaD_reallocCI (lua_State *L, int newsize) { + CallInfo *oldci = L->base_ci; + luaM_reallocvector(L, L->base_ci, L->size_ci, newsize, CallInfo); + L->size_ci = newsize; + L->ci = (L->ci - oldci) + L->base_ci; + L->end_ci = L->base_ci + L->size_ci - 1; +} + + +void luaD_growstack (lua_State *L, int n) { + if (n <= L->stacksize) /* double size is enough? */ + luaD_reallocstack(L, 2*L->stacksize); + else + luaD_reallocstack(L, L->stacksize + n); +} + + +static CallInfo *growCI (lua_State *L) { + if (L->size_ci > LUAI_MAXCALLS) /* overflow while handling overflow? */ + luaD_throw(L, LUA_ERRERR); + else { + luaD_reallocCI(L, 2*L->size_ci); + if (L->size_ci > LUAI_MAXCALLS) + luaG_runerror(L, "stack overflow"); + } + return ++L->ci; +} + + +void luaD_callhook (lua_State *L, int event, int line) { + lua_Hook hook = L->hook; + if (hook && L->allowhook) { + ptrdiff_t top = savestack(L, L->top); + ptrdiff_t ci_top = savestack(L, L->ci->top); + lua_Debug ar; + ar.event = event; + ar.currentline = line; + if (event == LUA_HOOKTAILRET) + ar.i_ci = 0; /* tail call; no debug information about it */ + else + ar.i_ci = cast_int(L->ci - L->base_ci); + luaD_checkstack(L, LUA_MINSTACK); /* ensure minimum stack size */ + L->ci->top = L->top + LUA_MINSTACK; + lua_assert(L->ci->top <= L->stack_last); + L->allowhook = 0; /* cannot call hooks inside a hook */ + lua_unlock(L); + (*hook)(L, &ar); + lua_lock(L); + lua_assert(!L->allowhook); + L->allowhook = 1; + L->ci->top = restorestack(L, ci_top); + L->top = restorestack(L, top); + } +} + + +static StkId adjust_varargs (lua_State *L, Proto *p, int actual) { + int i; + int nfixargs = p->numparams; + Table *htab = NULL; + StkId base, fixed; + for (; actual < nfixargs; ++actual) + setnilvalue(L->top++); +#if defined(LUA_COMPAT_VARARG) + if (p->is_vararg & VARARG_NEEDSARG) { /* compat. with old-style vararg? */ + int nvar = actual - nfixargs; /* number of extra arguments */ + lua_assert(p->is_vararg & VARARG_HASARG); + luaC_checkGC(L); + luaD_checkstack(L, p->maxstacksize); + htab = luaH_new(L, nvar, 1); /* create `arg' table */ + for (i=0; itop - nvar + i); + /* store counter in field `n' */ + setnvalue(luaH_setstr(L, htab, luaS_newliteral(L, "n")), cast_num(nvar)); + } +#endif + /* move fixed parameters to final position */ + fixed = L->top - actual; /* first fixed argument */ + base = L->top; /* final position of first argument */ + for (i=0; itop++, fixed+i); + setnilvalue(fixed+i); + } + /* add `arg' parameter */ + if (htab) { + sethvalue(L, L->top++, htab); + lua_assert(iswhite(obj2gco(htab))); + } + return base; +} + + +static StkId tryfuncTM (lua_State *L, StkId func) { + const TValue *tm = luaT_gettmbyobj(L, func, TM_CALL); + StkId p; + ptrdiff_t funcr = savestack(L, func); + if (!ttisfunction(tm)) + luaG_typeerror(L, func, "call"); + /* Open a hole inside the stack at `func' */ + for (p = L->top; p > func; p--) setobjs2s(L, p, p-1); + incr_top(L); + func = restorestack(L, funcr); /* previous call may change stack */ + setobj2s(L, func, tm); /* tag method is the new function to be called */ + return func; +} + + + +#define inc_ci(L) \ + ((L->ci == L->end_ci) ? growCI(L) : \ + (condhardstacktests(luaD_reallocCI(L, L->size_ci)), ++L->ci)) + + +int luaD_precall (lua_State *L, StkId func, int nresults) { + LClosure *cl; + ptrdiff_t funcr; + if (!ttisfunction(func)) /* `func' is not a function? */ + func = tryfuncTM(L, func); /* check the `function' tag method */ + funcr = savestack(L, func); + cl = &clvalue(func)->l; + L->ci->savedpc = L->savedpc; + if (!cl->isC) { /* Lua function? prepare its call */ + CallInfo *ci; + StkId st, base; + Proto *p = cl->p; + luaD_checkstack(L, p->maxstacksize); + func = restorestack(L, funcr); + if (!p->is_vararg) { /* no varargs? */ + base = func + 1; + if (L->top > base + p->numparams) + L->top = base + p->numparams; + } + else { /* vararg function */ + int nargs = cast_int(L->top - func) - 1; + base = adjust_varargs(L, p, nargs); + func = restorestack(L, funcr); /* previous call may change the stack */ + } + ci = inc_ci(L); /* now `enter' new function */ + ci->func = func; + L->base = ci->base = base; + ci->top = L->base + p->maxstacksize; + lua_assert(ci->top <= L->stack_last); + L->savedpc = p->code; /* starting point */ + ci->tailcalls = 0; + ci->nresults = nresults; + for (st = L->top; st < ci->top; st++) + setnilvalue(st); + L->top = ci->top; + if (L->hookmask & LUA_MASKCALL) { + L->savedpc++; /* hooks assume 'pc' is already incremented */ + luaD_callhook(L, LUA_HOOKCALL, -1); + L->savedpc--; /* correct 'pc' */ + } + return PCRLUA; + } + else { /* if is a C function, call it */ + CallInfo *ci; + int n; + luaD_checkstack(L, LUA_MINSTACK); /* ensure minimum stack size */ + ci = inc_ci(L); /* now `enter' new function */ + ci->func = restorestack(L, funcr); + L->base = ci->base = ci->func + 1; + ci->top = L->top + LUA_MINSTACK; + lua_assert(ci->top <= L->stack_last); + ci->nresults = nresults; + if (L->hookmask & LUA_MASKCALL) + luaD_callhook(L, LUA_HOOKCALL, -1); + lua_unlock(L); + n = (*curr_func(L)->c.f)(L); /* do the actual call */ + lua_lock(L); + if (n < 0) /* yielding? */ + return PCRYIELD; + else { + luaD_poscall(L, L->top - n); + return PCRC; + } + } +} + + +static StkId callrethooks (lua_State *L, StkId firstResult) { + ptrdiff_t fr = savestack(L, firstResult); /* next call may change stack */ + luaD_callhook(L, LUA_HOOKRET, -1); + if (f_isLua(L->ci)) { /* Lua function? */ + while ((L->hookmask & LUA_MASKRET) && L->ci->tailcalls--) /* tail calls */ + luaD_callhook(L, LUA_HOOKTAILRET, -1); + } + return restorestack(L, fr); +} + + +int luaD_poscall (lua_State *L, StkId firstResult) { + StkId res; + int wanted, i; + CallInfo *ci; + if (L->hookmask & LUA_MASKRET) + firstResult = callrethooks(L, firstResult); + ci = L->ci--; + res = ci->func; /* res == final position of 1st result */ + wanted = ci->nresults; + L->base = (ci - 1)->base; /* restore base */ + L->savedpc = (ci - 1)->savedpc; /* restore savedpc */ + /* move results to correct place */ + for (i = wanted; i != 0 && firstResult < L->top; i--) + setobjs2s(L, res++, firstResult++); + while (i-- > 0) + setnilvalue(res++); + L->top = res; + return (wanted - LUA_MULTRET); /* 0 iff wanted == LUA_MULTRET */ +} + + +/* +** Call a function (C or Lua). The function to be called is at *func. +** The arguments are on the stack, right after the function. +** When returns, all the results are on the stack, starting at the original +** function position. +*/ +void luaD_call (lua_State *L, StkId func, int nResults) { + if (++L->nCcalls >= LUAI_MAXCCALLS) { + if (L->nCcalls == LUAI_MAXCCALLS) + luaG_runerror(L, "C stack overflow"); + else if (L->nCcalls >= (LUAI_MAXCCALLS + (LUAI_MAXCCALLS>>3))) + luaD_throw(L, LUA_ERRERR); /* error while handing stack error */ + } + if (luaD_precall(L, func, nResults) == PCRLUA) /* is a Lua function? */ + luaV_execute(L, 1); /* call it */ + L->nCcalls--; + luaC_checkGC(L); +} + + +static void resume (lua_State *L, void *ud) { + StkId firstArg = cast(StkId, ud); + CallInfo *ci = L->ci; + if (L->status == 0) { /* start coroutine? */ + lua_assert(ci == L->base_ci && firstArg > L->base); + if (luaD_precall(L, firstArg - 1, LUA_MULTRET) != PCRLUA) + return; + } + else { /* resuming from previous yield */ + lua_assert(L->status == LUA_YIELD); + L->status = 0; + if (!f_isLua(ci)) { /* `common' yield? */ + /* finish interrupted execution of `OP_CALL' */ + lua_assert(GET_OPCODE(*((ci-1)->savedpc - 1)) == OP_CALL || + GET_OPCODE(*((ci-1)->savedpc - 1)) == OP_TAILCALL); + if (luaD_poscall(L, firstArg)) /* complete it... */ + L->top = L->ci->top; /* and correct top if not multiple results */ + } + else /* yielded inside a hook: just continue its execution */ + L->base = L->ci->base; + } + luaV_execute(L, cast_int(L->ci - L->base_ci)); +} + + +static int resume_error (lua_State *L, const char *msg) { + L->top = L->ci->base; + setsvalue2s(L, L->top, luaS_new(L, msg)); + incr_top(L); + lua_unlock(L); + return LUA_ERRRUN; +} + + +LUA_API int lua_resume (lua_State *L, int nargs) { + int status; + lua_lock(L); + if (L->status != LUA_YIELD && (L->status != 0 || L->ci != L->base_ci)) + return resume_error(L, "cannot resume non-suspended coroutine"); + if (L->nCcalls >= LUAI_MAXCCALLS) + return resume_error(L, "C stack overflow"); + luai_userstateresume(L, nargs); + lua_assert(L->errfunc == 0); + L->baseCcalls = ++L->nCcalls; + status = luaD_rawrunprotected(L, resume, L->top - nargs); + if (status != 0) { /* error? */ + L->status = cast_byte(status); /* mark thread as `dead' */ + luaD_seterrorobj(L, status, L->top); + L->ci->top = L->top; + } + else { + lua_assert(L->nCcalls == L->baseCcalls); + status = L->status; + } + --L->nCcalls; + lua_unlock(L); + return status; +} + + +LUA_API int lua_yield (lua_State *L, int nresults) { + luai_userstateyield(L, nresults); + lua_lock(L); + if (L->nCcalls > L->baseCcalls) + luaG_runerror(L, "attempt to yield across metamethod/C-call boundary"); + L->base = L->top - nresults; /* protect stack slots below */ + L->status = LUA_YIELD; + lua_unlock(L); + return -1; +} + + +int luaD_pcall (lua_State *L, Pfunc func, void *u, + ptrdiff_t old_top, ptrdiff_t ef) { + int status; + unsigned short oldnCcalls = L->nCcalls; + ptrdiff_t old_ci = saveci(L, L->ci); + lu_byte old_allowhooks = L->allowhook; + ptrdiff_t old_errfunc = L->errfunc; + L->errfunc = ef; + status = luaD_rawrunprotected(L, func, u); + if (status != 0) { /* an error occurred? */ + StkId oldtop = restorestack(L, old_top); + luaF_close(L, oldtop); /* close eventual pending closures */ + luaD_seterrorobj(L, status, oldtop); + L->nCcalls = oldnCcalls; + L->ci = restoreci(L, old_ci); + L->base = L->ci->base; + L->savedpc = L->ci->savedpc; + L->allowhook = old_allowhooks; + restore_stack_limit(L); + } + L->errfunc = old_errfunc; + return status; +} + + + +/* +** Execute a protected parser. +*/ +struct SParser { /* data to `f_parser' */ + ZIO *z; + Mbuffer buff; /* buffer to be used by the scanner */ + const char *name; +}; + +static void f_parser (lua_State *L, void *ud) { + int i; + Proto *tf; + Closure *cl; + struct SParser *p = cast(struct SParser *, ud); + int c = luaZ_lookahead(p->z); + luaC_checkGC(L); + tf = ((c == LUA_SIGNATURE[0]) ? luaU_undump : luaY_parser)(L, p->z, + &p->buff, p->name); + cl = luaF_newLclosure(L, tf->nups, hvalue(gt(L))); + cl->l.p = tf; + for (i = 0; i < tf->nups; i++) /* initialize eventual upvalues */ + cl->l.upvals[i] = luaF_newupval(L); + setclvalue(L, L->top, cl); + incr_top(L); +} + + +int luaD_protectedparser (lua_State *L, ZIO *z, const char *name) { + struct SParser p; + int status; + p.z = z; p.name = name; + luaZ_initbuffer(L, &p.buff); + status = luaD_pcall(L, f_parser, &p, savestack(L, L->top), L->errfunc); + luaZ_freebuffer(L, &p.buff); + return status; +} + + diff --git a/extern/lua-5.1.5/src/ldo.h b/extern/lua-5.1.5/src/ldo.h new file mode 100644 index 00000000..98fddac5 --- /dev/null +++ b/extern/lua-5.1.5/src/ldo.h @@ -0,0 +1,57 @@ +/* +** $Id: ldo.h,v 2.7.1.1 2007/12/27 13:02:25 roberto Exp $ +** Stack and Call structure of Lua +** See Copyright Notice in lua.h +*/ + +#ifndef ldo_h +#define ldo_h + + +#include "lobject.h" +#include "lstate.h" +#include "lzio.h" + + +#define luaD_checkstack(L,n) \ + if ((char *)L->stack_last - (char *)L->top <= (n)*(int)sizeof(TValue)) \ + luaD_growstack(L, n); \ + else condhardstacktests(luaD_reallocstack(L, L->stacksize - EXTRA_STACK - 1)); + + +#define incr_top(L) {luaD_checkstack(L,1); L->top++;} + +#define savestack(L,p) ((char *)(p) - (char *)L->stack) +#define restorestack(L,n) ((TValue *)((char *)L->stack + (n))) + +#define saveci(L,p) ((char *)(p) - (char *)L->base_ci) +#define restoreci(L,n) ((CallInfo *)((char *)L->base_ci + (n))) + + +/* results from luaD_precall */ +#define PCRLUA 0 /* initiated a call to a Lua function */ +#define PCRC 1 /* did a call to a C function */ +#define PCRYIELD 2 /* C funtion yielded */ + + +/* type of protected functions, to be ran by `runprotected' */ +typedef void (*Pfunc) (lua_State *L, void *ud); + +LUAI_FUNC int luaD_protectedparser (lua_State *L, ZIO *z, const char *name); +LUAI_FUNC void luaD_callhook (lua_State *L, int event, int line); +LUAI_FUNC int luaD_precall (lua_State *L, StkId func, int nresults); +LUAI_FUNC void luaD_call (lua_State *L, StkId func, int nResults); +LUAI_FUNC int luaD_pcall (lua_State *L, Pfunc func, void *u, + ptrdiff_t oldtop, ptrdiff_t ef); +LUAI_FUNC int luaD_poscall (lua_State *L, StkId firstResult); +LUAI_FUNC void luaD_reallocCI (lua_State *L, int newsize); +LUAI_FUNC void luaD_reallocstack (lua_State *L, int newsize); +LUAI_FUNC void luaD_growstack (lua_State *L, int n); + +LUAI_FUNC void luaD_throw (lua_State *L, int errcode); +LUAI_FUNC int luaD_rawrunprotected (lua_State *L, Pfunc f, void *ud); + +LUAI_FUNC void luaD_seterrorobj (lua_State *L, int errcode, StkId oldtop); + +#endif + diff --git a/extern/lua-5.1.5/src/ldump.c b/extern/lua-5.1.5/src/ldump.c new file mode 100644 index 00000000..c9d3d487 --- /dev/null +++ b/extern/lua-5.1.5/src/ldump.c @@ -0,0 +1,164 @@ +/* +** $Id: ldump.c,v 2.8.1.1 2007/12/27 13:02:25 roberto Exp $ +** save precompiled Lua chunks +** See Copyright Notice in lua.h +*/ + +#include + +#define ldump_c +#define LUA_CORE + +#include "lua.h" + +#include "lobject.h" +#include "lstate.h" +#include "lundump.h" + +typedef struct { + lua_State* L; + lua_Writer writer; + void* data; + int strip; + int status; +} DumpState; + +#define DumpMem(b,n,size,D) DumpBlock(b,(n)*(size),D) +#define DumpVar(x,D) DumpMem(&x,1,sizeof(x),D) + +static void DumpBlock(const void* b, size_t size, DumpState* D) +{ + if (D->status==0) + { + lua_unlock(D->L); + D->status=(*D->writer)(D->L,b,size,D->data); + lua_lock(D->L); + } +} + +static void DumpChar(int y, DumpState* D) +{ + char x=(char)y; + DumpVar(x,D); +} + +static void DumpInt(int x, DumpState* D) +{ + DumpVar(x,D); +} + +static void DumpNumber(lua_Number x, DumpState* D) +{ + DumpVar(x,D); +} + +static void DumpVector(const void* b, int n, size_t size, DumpState* D) +{ + DumpInt(n,D); + DumpMem(b,n,size,D); +} + +static void DumpString(const TString* s, DumpState* D) +{ + if (s==NULL || getstr(s)==NULL) + { + size_t size=0; + DumpVar(size,D); + } + else + { + size_t size=s->tsv.len+1; /* include trailing '\0' */ + DumpVar(size,D); + DumpBlock(getstr(s),size,D); + } +} + +#define DumpCode(f,D) DumpVector(f->code,f->sizecode,sizeof(Instruction),D) + +static void DumpFunction(const Proto* f, const TString* p, DumpState* D); + +static void DumpConstants(const Proto* f, DumpState* D) +{ + int i,n=f->sizek; + DumpInt(n,D); + for (i=0; ik[i]; + DumpChar(ttype(o),D); + switch (ttype(o)) + { + case LUA_TNIL: + break; + case LUA_TBOOLEAN: + DumpChar(bvalue(o),D); + break; + case LUA_TNUMBER: + DumpNumber(nvalue(o),D); + break; + case LUA_TSTRING: + DumpString(rawtsvalue(o),D); + break; + default: + lua_assert(0); /* cannot happen */ + break; + } + } + n=f->sizep; + DumpInt(n,D); + for (i=0; ip[i],f->source,D); +} + +static void DumpDebug(const Proto* f, DumpState* D) +{ + int i,n; + n= (D->strip) ? 0 : f->sizelineinfo; + DumpVector(f->lineinfo,n,sizeof(int),D); + n= (D->strip) ? 0 : f->sizelocvars; + DumpInt(n,D); + for (i=0; ilocvars[i].varname,D); + DumpInt(f->locvars[i].startpc,D); + DumpInt(f->locvars[i].endpc,D); + } + n= (D->strip) ? 0 : f->sizeupvalues; + DumpInt(n,D); + for (i=0; iupvalues[i],D); +} + +static void DumpFunction(const Proto* f, const TString* p, DumpState* D) +{ + DumpString((f->source==p || D->strip) ? NULL : f->source,D); + DumpInt(f->linedefined,D); + DumpInt(f->lastlinedefined,D); + DumpChar(f->nups,D); + DumpChar(f->numparams,D); + DumpChar(f->is_vararg,D); + DumpChar(f->maxstacksize,D); + DumpCode(f,D); + DumpConstants(f,D); + DumpDebug(f,D); +} + +static void DumpHeader(DumpState* D) +{ + char h[LUAC_HEADERSIZE]; + luaU_header(h); + DumpBlock(h,LUAC_HEADERSIZE,D); +} + +/* +** dump Lua function as precompiled chunk +*/ +int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip) +{ + DumpState D; + D.L=L; + D.writer=w; + D.data=data; + D.strip=strip; + D.status=0; + DumpHeader(&D); + DumpFunction(f,NULL,&D); + return D.status; +} diff --git a/extern/lua-5.1.5/src/lfunc.c b/extern/lua-5.1.5/src/lfunc.c new file mode 100644 index 00000000..813e88f5 --- /dev/null +++ b/extern/lua-5.1.5/src/lfunc.c @@ -0,0 +1,174 @@ +/* +** $Id: lfunc.c,v 2.12.1.2 2007/12/28 14:58:43 roberto Exp $ +** Auxiliary functions to manipulate prototypes and closures +** See Copyright Notice in lua.h +*/ + + +#include + +#define lfunc_c +#define LUA_CORE + +#include "lua.h" + +#include "lfunc.h" +#include "lgc.h" +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" + + + +Closure *luaF_newCclosure (lua_State *L, int nelems, Table *e) { + Closure *c = cast(Closure *, luaM_malloc(L, sizeCclosure(nelems))); + luaC_link(L, obj2gco(c), LUA_TFUNCTION); + c->c.isC = 1; + c->c.env = e; + c->c.nupvalues = cast_byte(nelems); + return c; +} + + +Closure *luaF_newLclosure (lua_State *L, int nelems, Table *e) { + Closure *c = cast(Closure *, luaM_malloc(L, sizeLclosure(nelems))); + luaC_link(L, obj2gco(c), LUA_TFUNCTION); + c->l.isC = 0; + c->l.env = e; + c->l.nupvalues = cast_byte(nelems); + while (nelems--) c->l.upvals[nelems] = NULL; + return c; +} + + +UpVal *luaF_newupval (lua_State *L) { + UpVal *uv = luaM_new(L, UpVal); + luaC_link(L, obj2gco(uv), LUA_TUPVAL); + uv->v = &uv->u.value; + setnilvalue(uv->v); + return uv; +} + + +UpVal *luaF_findupval (lua_State *L, StkId level) { + global_State *g = G(L); + GCObject **pp = &L->openupval; + UpVal *p; + UpVal *uv; + while (*pp != NULL && (p = ngcotouv(*pp))->v >= level) { + lua_assert(p->v != &p->u.value); + if (p->v == level) { /* found a corresponding upvalue? */ + if (isdead(g, obj2gco(p))) /* is it dead? */ + changewhite(obj2gco(p)); /* ressurect it */ + return p; + } + pp = &p->next; + } + uv = luaM_new(L, UpVal); /* not found: create a new one */ + uv->tt = LUA_TUPVAL; + uv->marked = luaC_white(g); + uv->v = level; /* current value lives in the stack */ + uv->next = *pp; /* chain it in the proper position */ + *pp = obj2gco(uv); + uv->u.l.prev = &g->uvhead; /* double link it in `uvhead' list */ + uv->u.l.next = g->uvhead.u.l.next; + uv->u.l.next->u.l.prev = uv; + g->uvhead.u.l.next = uv; + lua_assert(uv->u.l.next->u.l.prev == uv && uv->u.l.prev->u.l.next == uv); + return uv; +} + + +static void unlinkupval (UpVal *uv) { + lua_assert(uv->u.l.next->u.l.prev == uv && uv->u.l.prev->u.l.next == uv); + uv->u.l.next->u.l.prev = uv->u.l.prev; /* remove from `uvhead' list */ + uv->u.l.prev->u.l.next = uv->u.l.next; +} + + +void luaF_freeupval (lua_State *L, UpVal *uv) { + if (uv->v != &uv->u.value) /* is it open? */ + unlinkupval(uv); /* remove from open list */ + luaM_free(L, uv); /* free upvalue */ +} + + +void luaF_close (lua_State *L, StkId level) { + UpVal *uv; + global_State *g = G(L); + while (L->openupval != NULL && (uv = ngcotouv(L->openupval))->v >= level) { + GCObject *o = obj2gco(uv); + lua_assert(!isblack(o) && uv->v != &uv->u.value); + L->openupval = uv->next; /* remove from `open' list */ + if (isdead(g, o)) + luaF_freeupval(L, uv); /* free upvalue */ + else { + unlinkupval(uv); + setobj(L, &uv->u.value, uv->v); + uv->v = &uv->u.value; /* now current value lives here */ + luaC_linkupval(L, uv); /* link upvalue into `gcroot' list */ + } + } +} + + +Proto *luaF_newproto (lua_State *L) { + Proto *f = luaM_new(L, Proto); + luaC_link(L, obj2gco(f), LUA_TPROTO); + f->k = NULL; + f->sizek = 0; + f->p = NULL; + f->sizep = 0; + f->code = NULL; + f->sizecode = 0; + f->sizelineinfo = 0; + f->sizeupvalues = 0; + f->nups = 0; + f->upvalues = NULL; + f->numparams = 0; + f->is_vararg = 0; + f->maxstacksize = 0; + f->lineinfo = NULL; + f->sizelocvars = 0; + f->locvars = NULL; + f->linedefined = 0; + f->lastlinedefined = 0; + f->source = NULL; + return f; +} + + +void luaF_freeproto (lua_State *L, Proto *f) { + luaM_freearray(L, f->code, f->sizecode, Instruction); + luaM_freearray(L, f->p, f->sizep, Proto *); + luaM_freearray(L, f->k, f->sizek, TValue); + luaM_freearray(L, f->lineinfo, f->sizelineinfo, int); + luaM_freearray(L, f->locvars, f->sizelocvars, struct LocVar); + luaM_freearray(L, f->upvalues, f->sizeupvalues, TString *); + luaM_free(L, f); +} + + +void luaF_freeclosure (lua_State *L, Closure *c) { + int size = (c->c.isC) ? sizeCclosure(c->c.nupvalues) : + sizeLclosure(c->l.nupvalues); + luaM_freemem(L, c, size); +} + + +/* +** Look for n-th local variable at line `line' in function `func'. +** Returns NULL if not found. +*/ +const char *luaF_getlocalname (const Proto *f, int local_number, int pc) { + int i; + for (i = 0; isizelocvars && f->locvars[i].startpc <= pc; i++) { + if (pc < f->locvars[i].endpc) { /* is variable active? */ + local_number--; + if (local_number == 0) + return getstr(f->locvars[i].varname); + } + } + return NULL; /* not found */ +} + diff --git a/extern/lua-5.1.5/src/lfunc.h b/extern/lua-5.1.5/src/lfunc.h new file mode 100644 index 00000000..a68cf515 --- /dev/null +++ b/extern/lua-5.1.5/src/lfunc.h @@ -0,0 +1,34 @@ +/* +** $Id: lfunc.h,v 2.4.1.1 2007/12/27 13:02:25 roberto Exp $ +** Auxiliary functions to manipulate prototypes and closures +** See Copyright Notice in lua.h +*/ + +#ifndef lfunc_h +#define lfunc_h + + +#include "lobject.h" + + +#define sizeCclosure(n) (cast(int, sizeof(CClosure)) + \ + cast(int, sizeof(TValue)*((n)-1))) + +#define sizeLclosure(n) (cast(int, sizeof(LClosure)) + \ + cast(int, sizeof(TValue *)*((n)-1))) + + +LUAI_FUNC Proto *luaF_newproto (lua_State *L); +LUAI_FUNC Closure *luaF_newCclosure (lua_State *L, int nelems, Table *e); +LUAI_FUNC Closure *luaF_newLclosure (lua_State *L, int nelems, Table *e); +LUAI_FUNC UpVal *luaF_newupval (lua_State *L); +LUAI_FUNC UpVal *luaF_findupval (lua_State *L, StkId level); +LUAI_FUNC void luaF_close (lua_State *L, StkId level); +LUAI_FUNC void luaF_freeproto (lua_State *L, Proto *f); +LUAI_FUNC void luaF_freeclosure (lua_State *L, Closure *c); +LUAI_FUNC void luaF_freeupval (lua_State *L, UpVal *uv); +LUAI_FUNC const char *luaF_getlocalname (const Proto *func, int local_number, + int pc); + + +#endif diff --git a/extern/lua-5.1.5/src/lgc.c b/extern/lua-5.1.5/src/lgc.c new file mode 100644 index 00000000..e909c79a --- /dev/null +++ b/extern/lua-5.1.5/src/lgc.c @@ -0,0 +1,710 @@ +/* +** $Id: lgc.c,v 2.38.1.2 2011/03/18 18:05:38 roberto Exp $ +** Garbage Collector +** See Copyright Notice in lua.h +*/ + +#include + +#define lgc_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lgc.h" +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" + + +#define GCSTEPSIZE 1024u +#define GCSWEEPMAX 40 +#define GCSWEEPCOST 10 +#define GCFINALIZECOST 100 + + +#define maskmarks cast_byte(~(bitmask(BLACKBIT)|WHITEBITS)) + +#define makewhite(g,x) \ + ((x)->gch.marked = cast_byte(((x)->gch.marked & maskmarks) | luaC_white(g))) + +#define white2gray(x) reset2bits((x)->gch.marked, WHITE0BIT, WHITE1BIT) +#define black2gray(x) resetbit((x)->gch.marked, BLACKBIT) + +#define stringmark(s) reset2bits((s)->tsv.marked, WHITE0BIT, WHITE1BIT) + + +#define isfinalized(u) testbit((u)->marked, FINALIZEDBIT) +#define markfinalized(u) l_setbit((u)->marked, FINALIZEDBIT) + + +#define KEYWEAK bitmask(KEYWEAKBIT) +#define VALUEWEAK bitmask(VALUEWEAKBIT) + + + +#define markvalue(g,o) { checkconsistency(o); \ + if (iscollectable(o) && iswhite(gcvalue(o))) reallymarkobject(g,gcvalue(o)); } + +#define markobject(g,t) { if (iswhite(obj2gco(t))) \ + reallymarkobject(g, obj2gco(t)); } + + +#define setthreshold(g) (g->GCthreshold = (g->estimate/100) * g->gcpause) + + +static void removeentry (Node *n) { + lua_assert(ttisnil(gval(n))); + if (iscollectable(gkey(n))) + setttype(gkey(n), LUA_TDEADKEY); /* dead key; remove it */ +} + + +static void reallymarkobject (global_State *g, GCObject *o) { + lua_assert(iswhite(o) && !isdead(g, o)); + white2gray(o); + switch (o->gch.tt) { + case LUA_TSTRING: { + return; + } + case LUA_TUSERDATA: { + Table *mt = gco2u(o)->metatable; + gray2black(o); /* udata are never gray */ + if (mt) markobject(g, mt); + markobject(g, gco2u(o)->env); + return; + } + case LUA_TUPVAL: { + UpVal *uv = gco2uv(o); + markvalue(g, uv->v); + if (uv->v == &uv->u.value) /* closed? */ + gray2black(o); /* open upvalues are never black */ + return; + } + case LUA_TFUNCTION: { + gco2cl(o)->c.gclist = g->gray; + g->gray = o; + break; + } + case LUA_TTABLE: { + gco2h(o)->gclist = g->gray; + g->gray = o; + break; + } + case LUA_TTHREAD: { + gco2th(o)->gclist = g->gray; + g->gray = o; + break; + } + case LUA_TPROTO: { + gco2p(o)->gclist = g->gray; + g->gray = o; + break; + } + default: lua_assert(0); + } +} + + +static void marktmu (global_State *g) { + GCObject *u = g->tmudata; + if (u) { + do { + u = u->gch.next; + makewhite(g, u); /* may be marked, if left from previous GC */ + reallymarkobject(g, u); + } while (u != g->tmudata); + } +} + + +/* move `dead' udata that need finalization to list `tmudata' */ +size_t luaC_separateudata (lua_State *L, int all) { + global_State *g = G(L); + size_t deadmem = 0; + GCObject **p = &g->mainthread->next; + GCObject *curr; + while ((curr = *p) != NULL) { + if (!(iswhite(curr) || all) || isfinalized(gco2u(curr))) + p = &curr->gch.next; /* don't bother with them */ + else if (fasttm(L, gco2u(curr)->metatable, TM_GC) == NULL) { + markfinalized(gco2u(curr)); /* don't need finalization */ + p = &curr->gch.next; + } + else { /* must call its gc method */ + deadmem += sizeudata(gco2u(curr)); + markfinalized(gco2u(curr)); + *p = curr->gch.next; + /* link `curr' at the end of `tmudata' list */ + if (g->tmudata == NULL) /* list is empty? */ + g->tmudata = curr->gch.next = curr; /* creates a circular list */ + else { + curr->gch.next = g->tmudata->gch.next; + g->tmudata->gch.next = curr; + g->tmudata = curr; + } + } + } + return deadmem; +} + + +static int traversetable (global_State *g, Table *h) { + int i; + int weakkey = 0; + int weakvalue = 0; + const TValue *mode; + if (h->metatable) + markobject(g, h->metatable); + mode = gfasttm(g, h->metatable, TM_MODE); + if (mode && ttisstring(mode)) { /* is there a weak mode? */ + weakkey = (strchr(svalue(mode), 'k') != NULL); + weakvalue = (strchr(svalue(mode), 'v') != NULL); + if (weakkey || weakvalue) { /* is really weak? */ + h->marked &= ~(KEYWEAK | VALUEWEAK); /* clear bits */ + h->marked |= cast_byte((weakkey << KEYWEAKBIT) | + (weakvalue << VALUEWEAKBIT)); + h->gclist = g->weak; /* must be cleared after GC, ... */ + g->weak = obj2gco(h); /* ... so put in the appropriate list */ + } + } + if (weakkey && weakvalue) return 1; + if (!weakvalue) { + i = h->sizearray; + while (i--) + markvalue(g, &h->array[i]); + } + i = sizenode(h); + while (i--) { + Node *n = gnode(h, i); + lua_assert(ttype(gkey(n)) != LUA_TDEADKEY || ttisnil(gval(n))); + if (ttisnil(gval(n))) + removeentry(n); /* remove empty entries */ + else { + lua_assert(!ttisnil(gkey(n))); + if (!weakkey) markvalue(g, gkey(n)); + if (!weakvalue) markvalue(g, gval(n)); + } + } + return weakkey || weakvalue; +} + + +/* +** All marks are conditional because a GC may happen while the +** prototype is still being created +*/ +static void traverseproto (global_State *g, Proto *f) { + int i; + if (f->source) stringmark(f->source); + for (i=0; isizek; i++) /* mark literals */ + markvalue(g, &f->k[i]); + for (i=0; isizeupvalues; i++) { /* mark upvalue names */ + if (f->upvalues[i]) + stringmark(f->upvalues[i]); + } + for (i=0; isizep; i++) { /* mark nested protos */ + if (f->p[i]) + markobject(g, f->p[i]); + } + for (i=0; isizelocvars; i++) { /* mark local-variable names */ + if (f->locvars[i].varname) + stringmark(f->locvars[i].varname); + } +} + + + +static void traverseclosure (global_State *g, Closure *cl) { + markobject(g, cl->c.env); + if (cl->c.isC) { + int i; + for (i=0; ic.nupvalues; i++) /* mark its upvalues */ + markvalue(g, &cl->c.upvalue[i]); + } + else { + int i; + lua_assert(cl->l.nupvalues == cl->l.p->nups); + markobject(g, cl->l.p); + for (i=0; il.nupvalues; i++) /* mark its upvalues */ + markobject(g, cl->l.upvals[i]); + } +} + + +static void checkstacksizes (lua_State *L, StkId max) { + int ci_used = cast_int(L->ci - L->base_ci); /* number of `ci' in use */ + int s_used = cast_int(max - L->stack); /* part of stack in use */ + if (L->size_ci > LUAI_MAXCALLS) /* handling overflow? */ + return; /* do not touch the stacks */ + if (4*ci_used < L->size_ci && 2*BASIC_CI_SIZE < L->size_ci) + luaD_reallocCI(L, L->size_ci/2); /* still big enough... */ + condhardstacktests(luaD_reallocCI(L, ci_used + 1)); + if (4*s_used < L->stacksize && + 2*(BASIC_STACK_SIZE+EXTRA_STACK) < L->stacksize) + luaD_reallocstack(L, L->stacksize/2); /* still big enough... */ + condhardstacktests(luaD_reallocstack(L, s_used)); +} + + +static void traversestack (global_State *g, lua_State *l) { + StkId o, lim; + CallInfo *ci; + markvalue(g, gt(l)); + lim = l->top; + for (ci = l->base_ci; ci <= l->ci; ci++) { + lua_assert(ci->top <= l->stack_last); + if (lim < ci->top) lim = ci->top; + } + for (o = l->stack; o < l->top; o++) + markvalue(g, o); + for (; o <= lim; o++) + setnilvalue(o); + checkstacksizes(l, lim); +} + + +/* +** traverse one gray object, turning it to black. +** Returns `quantity' traversed. +*/ +static l_mem propagatemark (global_State *g) { + GCObject *o = g->gray; + lua_assert(isgray(o)); + gray2black(o); + switch (o->gch.tt) { + case LUA_TTABLE: { + Table *h = gco2h(o); + g->gray = h->gclist; + if (traversetable(g, h)) /* table is weak? */ + black2gray(o); /* keep it gray */ + return sizeof(Table) + sizeof(TValue) * h->sizearray + + sizeof(Node) * sizenode(h); + } + case LUA_TFUNCTION: { + Closure *cl = gco2cl(o); + g->gray = cl->c.gclist; + traverseclosure(g, cl); + return (cl->c.isC) ? sizeCclosure(cl->c.nupvalues) : + sizeLclosure(cl->l.nupvalues); + } + case LUA_TTHREAD: { + lua_State *th = gco2th(o); + g->gray = th->gclist; + th->gclist = g->grayagain; + g->grayagain = o; + black2gray(o); + traversestack(g, th); + return sizeof(lua_State) + sizeof(TValue) * th->stacksize + + sizeof(CallInfo) * th->size_ci; + } + case LUA_TPROTO: { + Proto *p = gco2p(o); + g->gray = p->gclist; + traverseproto(g, p); + return sizeof(Proto) + sizeof(Instruction) * p->sizecode + + sizeof(Proto *) * p->sizep + + sizeof(TValue) * p->sizek + + sizeof(int) * p->sizelineinfo + + sizeof(LocVar) * p->sizelocvars + + sizeof(TString *) * p->sizeupvalues; + } + default: lua_assert(0); return 0; + } +} + + +static size_t propagateall (global_State *g) { + size_t m = 0; + while (g->gray) m += propagatemark(g); + return m; +} + + +/* +** The next function tells whether a key or value can be cleared from +** a weak table. Non-collectable objects are never removed from weak +** tables. Strings behave as `values', so are never removed too. for +** other objects: if really collected, cannot keep them; for userdata +** being finalized, keep them in keys, but not in values +*/ +static int iscleared (const TValue *o, int iskey) { + if (!iscollectable(o)) return 0; + if (ttisstring(o)) { + stringmark(rawtsvalue(o)); /* strings are `values', so are never weak */ + return 0; + } + return iswhite(gcvalue(o)) || + (ttisuserdata(o) && (!iskey && isfinalized(uvalue(o)))); +} + + +/* +** clear collected entries from weaktables +*/ +static void cleartable (GCObject *l) { + while (l) { + Table *h = gco2h(l); + int i = h->sizearray; + lua_assert(testbit(h->marked, VALUEWEAKBIT) || + testbit(h->marked, KEYWEAKBIT)); + if (testbit(h->marked, VALUEWEAKBIT)) { + while (i--) { + TValue *o = &h->array[i]; + if (iscleared(o, 0)) /* value was collected? */ + setnilvalue(o); /* remove value */ + } + } + i = sizenode(h); + while (i--) { + Node *n = gnode(h, i); + if (!ttisnil(gval(n)) && /* non-empty entry? */ + (iscleared(key2tval(n), 1) || iscleared(gval(n), 0))) { + setnilvalue(gval(n)); /* remove value ... */ + removeentry(n); /* remove entry from table */ + } + } + l = h->gclist; + } +} + + +static void freeobj (lua_State *L, GCObject *o) { + switch (o->gch.tt) { + case LUA_TPROTO: luaF_freeproto(L, gco2p(o)); break; + case LUA_TFUNCTION: luaF_freeclosure(L, gco2cl(o)); break; + case LUA_TUPVAL: luaF_freeupval(L, gco2uv(o)); break; + case LUA_TTABLE: luaH_free(L, gco2h(o)); break; + case LUA_TTHREAD: { + lua_assert(gco2th(o) != L && gco2th(o) != G(L)->mainthread); + luaE_freethread(L, gco2th(o)); + break; + } + case LUA_TSTRING: { + G(L)->strt.nuse--; + luaM_freemem(L, o, sizestring(gco2ts(o))); + break; + } + case LUA_TUSERDATA: { + luaM_freemem(L, o, sizeudata(gco2u(o))); + break; + } + default: lua_assert(0); + } +} + + + +#define sweepwholelist(L,p) sweeplist(L,p,MAX_LUMEM) + + +static GCObject **sweeplist (lua_State *L, GCObject **p, lu_mem count) { + GCObject *curr; + global_State *g = G(L); + int deadmask = otherwhite(g); + while ((curr = *p) != NULL && count-- > 0) { + if (curr->gch.tt == LUA_TTHREAD) /* sweep open upvalues of each thread */ + sweepwholelist(L, &gco2th(curr)->openupval); + if ((curr->gch.marked ^ WHITEBITS) & deadmask) { /* not dead? */ + lua_assert(!isdead(g, curr) || testbit(curr->gch.marked, FIXEDBIT)); + makewhite(g, curr); /* make it white (for next cycle) */ + p = &curr->gch.next; + } + else { /* must erase `curr' */ + lua_assert(isdead(g, curr) || deadmask == bitmask(SFIXEDBIT)); + *p = curr->gch.next; + if (curr == g->rootgc) /* is the first element of the list? */ + g->rootgc = curr->gch.next; /* adjust first */ + freeobj(L, curr); + } + } + return p; +} + + +static void checkSizes (lua_State *L) { + global_State *g = G(L); + /* check size of string hash */ + if (g->strt.nuse < cast(lu_int32, g->strt.size/4) && + g->strt.size > MINSTRTABSIZE*2) + luaS_resize(L, g->strt.size/2); /* table is too big */ + /* check size of buffer */ + if (luaZ_sizebuffer(&g->buff) > LUA_MINBUFFER*2) { /* buffer too big? */ + size_t newsize = luaZ_sizebuffer(&g->buff) / 2; + luaZ_resizebuffer(L, &g->buff, newsize); + } +} + + +static void GCTM (lua_State *L) { + global_State *g = G(L); + GCObject *o = g->tmudata->gch.next; /* get first element */ + Udata *udata = rawgco2u(o); + const TValue *tm; + /* remove udata from `tmudata' */ + if (o == g->tmudata) /* last element? */ + g->tmudata = NULL; + else + g->tmudata->gch.next = udata->uv.next; + udata->uv.next = g->mainthread->next; /* return it to `root' list */ + g->mainthread->next = o; + makewhite(g, o); + tm = fasttm(L, udata->uv.metatable, TM_GC); + if (tm != NULL) { + lu_byte oldah = L->allowhook; + lu_mem oldt = g->GCthreshold; + L->allowhook = 0; /* stop debug hooks during GC tag method */ + g->GCthreshold = 2*g->totalbytes; /* avoid GC steps */ + setobj2s(L, L->top, tm); + setuvalue(L, L->top+1, udata); + L->top += 2; + luaD_call(L, L->top - 2, 0); + L->allowhook = oldah; /* restore hooks */ + g->GCthreshold = oldt; /* restore threshold */ + } +} + + +/* +** Call all GC tag methods +*/ +void luaC_callGCTM (lua_State *L) { + while (G(L)->tmudata) + GCTM(L); +} + + +void luaC_freeall (lua_State *L) { + global_State *g = G(L); + int i; + g->currentwhite = WHITEBITS | bitmask(SFIXEDBIT); /* mask to collect all elements */ + sweepwholelist(L, &g->rootgc); + for (i = 0; i < g->strt.size; i++) /* free all string lists */ + sweepwholelist(L, &g->strt.hash[i]); +} + + +static void markmt (global_State *g) { + int i; + for (i=0; imt[i]) markobject(g, g->mt[i]); +} + + +/* mark root set */ +static void markroot (lua_State *L) { + global_State *g = G(L); + g->gray = NULL; + g->grayagain = NULL; + g->weak = NULL; + markobject(g, g->mainthread); + /* make global table be traversed before main stack */ + markvalue(g, gt(g->mainthread)); + markvalue(g, registry(L)); + markmt(g); + g->gcstate = GCSpropagate; +} + + +static void remarkupvals (global_State *g) { + UpVal *uv; + for (uv = g->uvhead.u.l.next; uv != &g->uvhead; uv = uv->u.l.next) { + lua_assert(uv->u.l.next->u.l.prev == uv && uv->u.l.prev->u.l.next == uv); + if (isgray(obj2gco(uv))) + markvalue(g, uv->v); + } +} + + +static void atomic (lua_State *L) { + global_State *g = G(L); + size_t udsize; /* total size of userdata to be finalized */ + /* remark occasional upvalues of (maybe) dead threads */ + remarkupvals(g); + /* traverse objects cautch by write barrier and by 'remarkupvals' */ + propagateall(g); + /* remark weak tables */ + g->gray = g->weak; + g->weak = NULL; + lua_assert(!iswhite(obj2gco(g->mainthread))); + markobject(g, L); /* mark running thread */ + markmt(g); /* mark basic metatables (again) */ + propagateall(g); + /* remark gray again */ + g->gray = g->grayagain; + g->grayagain = NULL; + propagateall(g); + udsize = luaC_separateudata(L, 0); /* separate userdata to be finalized */ + marktmu(g); /* mark `preserved' userdata */ + udsize += propagateall(g); /* remark, to propagate `preserveness' */ + cleartable(g->weak); /* remove collected objects from weak tables */ + /* flip current white */ + g->currentwhite = cast_byte(otherwhite(g)); + g->sweepstrgc = 0; + g->sweepgc = &g->rootgc; + g->gcstate = GCSsweepstring; + g->estimate = g->totalbytes - udsize; /* first estimate */ +} + + +static l_mem singlestep (lua_State *L) { + global_State *g = G(L); + /*lua_checkmemory(L);*/ + switch (g->gcstate) { + case GCSpause: { + markroot(L); /* start a new collection */ + return 0; + } + case GCSpropagate: { + if (g->gray) + return propagatemark(g); + else { /* no more `gray' objects */ + atomic(L); /* finish mark phase */ + return 0; + } + } + case GCSsweepstring: { + lu_mem old = g->totalbytes; + sweepwholelist(L, &g->strt.hash[g->sweepstrgc++]); + if (g->sweepstrgc >= g->strt.size) /* nothing more to sweep? */ + g->gcstate = GCSsweep; /* end sweep-string phase */ + lua_assert(old >= g->totalbytes); + g->estimate -= old - g->totalbytes; + return GCSWEEPCOST; + } + case GCSsweep: { + lu_mem old = g->totalbytes; + g->sweepgc = sweeplist(L, g->sweepgc, GCSWEEPMAX); + if (*g->sweepgc == NULL) { /* nothing more to sweep? */ + checkSizes(L); + g->gcstate = GCSfinalize; /* end sweep phase */ + } + lua_assert(old >= g->totalbytes); + g->estimate -= old - g->totalbytes; + return GCSWEEPMAX*GCSWEEPCOST; + } + case GCSfinalize: { + if (g->tmudata) { + GCTM(L); + if (g->estimate > GCFINALIZECOST) + g->estimate -= GCFINALIZECOST; + return GCFINALIZECOST; + } + else { + g->gcstate = GCSpause; /* end collection */ + g->gcdept = 0; + return 0; + } + } + default: lua_assert(0); return 0; + } +} + + +void luaC_step (lua_State *L) { + global_State *g = G(L); + l_mem lim = (GCSTEPSIZE/100) * g->gcstepmul; + if (lim == 0) + lim = (MAX_LUMEM-1)/2; /* no limit */ + g->gcdept += g->totalbytes - g->GCthreshold; + do { + lim -= singlestep(L); + if (g->gcstate == GCSpause) + break; + } while (lim > 0); + if (g->gcstate != GCSpause) { + if (g->gcdept < GCSTEPSIZE) + g->GCthreshold = g->totalbytes + GCSTEPSIZE; /* - lim/g->gcstepmul;*/ + else { + g->gcdept -= GCSTEPSIZE; + g->GCthreshold = g->totalbytes; + } + } + else { + setthreshold(g); + } +} + + +void luaC_fullgc (lua_State *L) { + global_State *g = G(L); + if (g->gcstate <= GCSpropagate) { + /* reset sweep marks to sweep all elements (returning them to white) */ + g->sweepstrgc = 0; + g->sweepgc = &g->rootgc; + /* reset other collector lists */ + g->gray = NULL; + g->grayagain = NULL; + g->weak = NULL; + g->gcstate = GCSsweepstring; + } + lua_assert(g->gcstate != GCSpause && g->gcstate != GCSpropagate); + /* finish any pending sweep phase */ + while (g->gcstate != GCSfinalize) { + lua_assert(g->gcstate == GCSsweepstring || g->gcstate == GCSsweep); + singlestep(L); + } + markroot(L); + while (g->gcstate != GCSpause) { + singlestep(L); + } + setthreshold(g); +} + + +void luaC_barrierf (lua_State *L, GCObject *o, GCObject *v) { + global_State *g = G(L); + lua_assert(isblack(o) && iswhite(v) && !isdead(g, v) && !isdead(g, o)); + lua_assert(g->gcstate != GCSfinalize && g->gcstate != GCSpause); + lua_assert(ttype(&o->gch) != LUA_TTABLE); + /* must keep invariant? */ + if (g->gcstate == GCSpropagate) + reallymarkobject(g, v); /* restore invariant */ + else /* don't mind */ + makewhite(g, o); /* mark as white just to avoid other barriers */ +} + + +void luaC_barrierback (lua_State *L, Table *t) { + global_State *g = G(L); + GCObject *o = obj2gco(t); + lua_assert(isblack(o) && !isdead(g, o)); + lua_assert(g->gcstate != GCSfinalize && g->gcstate != GCSpause); + black2gray(o); /* make table gray (again) */ + t->gclist = g->grayagain; + g->grayagain = o; +} + + +void luaC_link (lua_State *L, GCObject *o, lu_byte tt) { + global_State *g = G(L); + o->gch.next = g->rootgc; + g->rootgc = o; + o->gch.marked = luaC_white(g); + o->gch.tt = tt; +} + + +void luaC_linkupval (lua_State *L, UpVal *uv) { + global_State *g = G(L); + GCObject *o = obj2gco(uv); + o->gch.next = g->rootgc; /* link upvalue into `rootgc' list */ + g->rootgc = o; + if (isgray(o)) { + if (g->gcstate == GCSpropagate) { + gray2black(o); /* closed upvalues need barrier */ + luaC_barrier(L, uv, uv->v); + } + else { /* sweep phase: sweep it (turning it into white) */ + makewhite(g, o); + lua_assert(g->gcstate != GCSfinalize && g->gcstate != GCSpause); + } + } +} + diff --git a/extern/lua-5.1.5/src/lgc.h b/extern/lua-5.1.5/src/lgc.h new file mode 100644 index 00000000..5a8dc605 --- /dev/null +++ b/extern/lua-5.1.5/src/lgc.h @@ -0,0 +1,110 @@ +/* +** $Id: lgc.h,v 2.15.1.1 2007/12/27 13:02:25 roberto Exp $ +** Garbage Collector +** See Copyright Notice in lua.h +*/ + +#ifndef lgc_h +#define lgc_h + + +#include "lobject.h" + + +/* +** Possible states of the Garbage Collector +*/ +#define GCSpause 0 +#define GCSpropagate 1 +#define GCSsweepstring 2 +#define GCSsweep 3 +#define GCSfinalize 4 + + +/* +** some userful bit tricks +*/ +#define resetbits(x,m) ((x) &= cast(lu_byte, ~(m))) +#define setbits(x,m) ((x) |= (m)) +#define testbits(x,m) ((x) & (m)) +#define bitmask(b) (1<<(b)) +#define bit2mask(b1,b2) (bitmask(b1) | bitmask(b2)) +#define l_setbit(x,b) setbits(x, bitmask(b)) +#define resetbit(x,b) resetbits(x, bitmask(b)) +#define testbit(x,b) testbits(x, bitmask(b)) +#define set2bits(x,b1,b2) setbits(x, (bit2mask(b1, b2))) +#define reset2bits(x,b1,b2) resetbits(x, (bit2mask(b1, b2))) +#define test2bits(x,b1,b2) testbits(x, (bit2mask(b1, b2))) + + + +/* +** Layout for bit use in `marked' field: +** bit 0 - object is white (type 0) +** bit 1 - object is white (type 1) +** bit 2 - object is black +** bit 3 - for userdata: has been finalized +** bit 3 - for tables: has weak keys +** bit 4 - for tables: has weak values +** bit 5 - object is fixed (should not be collected) +** bit 6 - object is "super" fixed (only the main thread) +*/ + + +#define WHITE0BIT 0 +#define WHITE1BIT 1 +#define BLACKBIT 2 +#define FINALIZEDBIT 3 +#define KEYWEAKBIT 3 +#define VALUEWEAKBIT 4 +#define FIXEDBIT 5 +#define SFIXEDBIT 6 +#define WHITEBITS bit2mask(WHITE0BIT, WHITE1BIT) + + +#define iswhite(x) test2bits((x)->gch.marked, WHITE0BIT, WHITE1BIT) +#define isblack(x) testbit((x)->gch.marked, BLACKBIT) +#define isgray(x) (!isblack(x) && !iswhite(x)) + +#define otherwhite(g) (g->currentwhite ^ WHITEBITS) +#define isdead(g,v) ((v)->gch.marked & otherwhite(g) & WHITEBITS) + +#define changewhite(x) ((x)->gch.marked ^= WHITEBITS) +#define gray2black(x) l_setbit((x)->gch.marked, BLACKBIT) + +#define valiswhite(x) (iscollectable(x) && iswhite(gcvalue(x))) + +#define luaC_white(g) cast(lu_byte, (g)->currentwhite & WHITEBITS) + + +#define luaC_checkGC(L) { \ + condhardstacktests(luaD_reallocstack(L, L->stacksize - EXTRA_STACK - 1)); \ + if (G(L)->totalbytes >= G(L)->GCthreshold) \ + luaC_step(L); } + + +#define luaC_barrier(L,p,v) { if (valiswhite(v) && isblack(obj2gco(p))) \ + luaC_barrierf(L,obj2gco(p),gcvalue(v)); } + +#define luaC_barriert(L,t,v) { if (valiswhite(v) && isblack(obj2gco(t))) \ + luaC_barrierback(L,t); } + +#define luaC_objbarrier(L,p,o) \ + { if (iswhite(obj2gco(o)) && isblack(obj2gco(p))) \ + luaC_barrierf(L,obj2gco(p),obj2gco(o)); } + +#define luaC_objbarriert(L,t,o) \ + { if (iswhite(obj2gco(o)) && isblack(obj2gco(t))) luaC_barrierback(L,t); } + +LUAI_FUNC size_t luaC_separateudata (lua_State *L, int all); +LUAI_FUNC void luaC_callGCTM (lua_State *L); +LUAI_FUNC void luaC_freeall (lua_State *L); +LUAI_FUNC void luaC_step (lua_State *L); +LUAI_FUNC void luaC_fullgc (lua_State *L); +LUAI_FUNC void luaC_link (lua_State *L, GCObject *o, lu_byte tt); +LUAI_FUNC void luaC_linkupval (lua_State *L, UpVal *uv); +LUAI_FUNC void luaC_barrierf (lua_State *L, GCObject *o, GCObject *v); +LUAI_FUNC void luaC_barrierback (lua_State *L, Table *t); + + +#endif diff --git a/extern/lua-5.1.5/src/linit.c b/extern/lua-5.1.5/src/linit.c new file mode 100644 index 00000000..c1f90dfa --- /dev/null +++ b/extern/lua-5.1.5/src/linit.c @@ -0,0 +1,38 @@ +/* +** $Id: linit.c,v 1.14.1.1 2007/12/27 13:02:25 roberto Exp $ +** Initialization of libraries for lua.c +** See Copyright Notice in lua.h +*/ + + +#define linit_c +#define LUA_LIB + +#include "lua.h" + +#include "lualib.h" +#include "lauxlib.h" + + +static const luaL_Reg lualibs[] = { + {"", luaopen_base}, + {LUA_LOADLIBNAME, luaopen_package}, + {LUA_TABLIBNAME, luaopen_table}, + {LUA_IOLIBNAME, luaopen_io}, + {LUA_OSLIBNAME, luaopen_os}, + {LUA_STRLIBNAME, luaopen_string}, + {LUA_MATHLIBNAME, luaopen_math}, + {LUA_DBLIBNAME, luaopen_debug}, + {NULL, NULL} +}; + + +LUALIB_API void luaL_openlibs (lua_State *L) { + const luaL_Reg *lib = lualibs; + for (; lib->func; lib++) { + lua_pushcfunction(L, lib->func); + lua_pushstring(L, lib->name); + lua_call(L, 1, 0); + } +} + diff --git a/extern/lua-5.1.5/src/liolib.c b/extern/lua-5.1.5/src/liolib.c new file mode 100644 index 00000000..649f9a59 --- /dev/null +++ b/extern/lua-5.1.5/src/liolib.c @@ -0,0 +1,556 @@ +/* +** $Id: liolib.c,v 2.73.1.4 2010/05/14 15:33:51 roberto Exp $ +** Standard I/O (and system) library +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include +#include + +#define liolib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + + +#define IO_INPUT 1 +#define IO_OUTPUT 2 + + +static const char *const fnames[] = {"input", "output"}; + + +static int pushresult (lua_State *L, int i, const char *filename) { + int en = errno; /* calls to Lua API may change this value */ + if (i) { + lua_pushboolean(L, 1); + return 1; + } + else { + lua_pushnil(L); + if (filename) + lua_pushfstring(L, "%s: %s", filename, strerror(en)); + else + lua_pushfstring(L, "%s", strerror(en)); + lua_pushinteger(L, en); + return 3; + } +} + + +static void fileerror (lua_State *L, int arg, const char *filename) { + lua_pushfstring(L, "%s: %s", filename, strerror(errno)); + luaL_argerror(L, arg, lua_tostring(L, -1)); +} + + +#define tofilep(L) ((FILE **)luaL_checkudata(L, 1, LUA_FILEHANDLE)) + + +static int io_type (lua_State *L) { + void *ud; + luaL_checkany(L, 1); + ud = lua_touserdata(L, 1); + lua_getfield(L, LUA_REGISTRYINDEX, LUA_FILEHANDLE); + if (ud == NULL || !lua_getmetatable(L, 1) || !lua_rawequal(L, -2, -1)) + lua_pushnil(L); /* not a file */ + else if (*((FILE **)ud) == NULL) + lua_pushliteral(L, "closed file"); + else + lua_pushliteral(L, "file"); + return 1; +} + + +static FILE *tofile (lua_State *L) { + FILE **f = tofilep(L); + if (*f == NULL) + luaL_error(L, "attempt to use a closed file"); + return *f; +} + + + +/* +** When creating file handles, always creates a `closed' file handle +** before opening the actual file; so, if there is a memory error, the +** file is not left opened. +*/ +static FILE **newfile (lua_State *L) { + FILE **pf = (FILE **)lua_newuserdata(L, sizeof(FILE *)); + *pf = NULL; /* file handle is currently `closed' */ + luaL_getmetatable(L, LUA_FILEHANDLE); + lua_setmetatable(L, -2); + return pf; +} + + +/* +** function to (not) close the standard files stdin, stdout, and stderr +*/ +static int io_noclose (lua_State *L) { + lua_pushnil(L); + lua_pushliteral(L, "cannot close standard file"); + return 2; +} + + +/* +** function to close 'popen' files +*/ +static int io_pclose (lua_State *L) { + FILE **p = tofilep(L); + int ok = lua_pclose(L, *p); + *p = NULL; + return pushresult(L, ok, NULL); +} + + +/* +** function to close regular files +*/ +static int io_fclose (lua_State *L) { + FILE **p = tofilep(L); + int ok = (fclose(*p) == 0); + *p = NULL; + return pushresult(L, ok, NULL); +} + + +static int aux_close (lua_State *L) { + lua_getfenv(L, 1); + lua_getfield(L, -1, "__close"); + return (lua_tocfunction(L, -1))(L); +} + + +static int io_close (lua_State *L) { + if (lua_isnone(L, 1)) + lua_rawgeti(L, LUA_ENVIRONINDEX, IO_OUTPUT); + tofile(L); /* make sure argument is a file */ + return aux_close(L); +} + + +static int io_gc (lua_State *L) { + FILE *f = *tofilep(L); + /* ignore closed files */ + if (f != NULL) + aux_close(L); + return 0; +} + + +static int io_tostring (lua_State *L) { + FILE *f = *tofilep(L); + if (f == NULL) + lua_pushliteral(L, "file (closed)"); + else + lua_pushfstring(L, "file (%p)", f); + return 1; +} + + +static int io_open (lua_State *L) { + const char *filename = luaL_checkstring(L, 1); + const char *mode = luaL_optstring(L, 2, "r"); + FILE **pf = newfile(L); + *pf = fopen(filename, mode); + return (*pf == NULL) ? pushresult(L, 0, filename) : 1; +} + + +/* +** this function has a separated environment, which defines the +** correct __close for 'popen' files +*/ +static int io_popen (lua_State *L) { + const char *filename = luaL_checkstring(L, 1); + const char *mode = luaL_optstring(L, 2, "r"); + FILE **pf = newfile(L); + *pf = lua_popen(L, filename, mode); + return (*pf == NULL) ? pushresult(L, 0, filename) : 1; +} + + +static int io_tmpfile (lua_State *L) { + FILE **pf = newfile(L); + *pf = tmpfile(); + return (*pf == NULL) ? pushresult(L, 0, NULL) : 1; +} + + +static FILE *getiofile (lua_State *L, int findex) { + FILE *f; + lua_rawgeti(L, LUA_ENVIRONINDEX, findex); + f = *(FILE **)lua_touserdata(L, -1); + if (f == NULL) + luaL_error(L, "standard %s file is closed", fnames[findex - 1]); + return f; +} + + +static int g_iofile (lua_State *L, int f, const char *mode) { + if (!lua_isnoneornil(L, 1)) { + const char *filename = lua_tostring(L, 1); + if (filename) { + FILE **pf = newfile(L); + *pf = fopen(filename, mode); + if (*pf == NULL) + fileerror(L, 1, filename); + } + else { + tofile(L); /* check that it's a valid file handle */ + lua_pushvalue(L, 1); + } + lua_rawseti(L, LUA_ENVIRONINDEX, f); + } + /* return current value */ + lua_rawgeti(L, LUA_ENVIRONINDEX, f); + return 1; +} + + +static int io_input (lua_State *L) { + return g_iofile(L, IO_INPUT, "r"); +} + + +static int io_output (lua_State *L) { + return g_iofile(L, IO_OUTPUT, "w"); +} + + +static int io_readline (lua_State *L); + + +static void aux_lines (lua_State *L, int idx, int toclose) { + lua_pushvalue(L, idx); + lua_pushboolean(L, toclose); /* close/not close file when finished */ + lua_pushcclosure(L, io_readline, 2); +} + + +static int f_lines (lua_State *L) { + tofile(L); /* check that it's a valid file handle */ + aux_lines(L, 1, 0); + return 1; +} + + +static int io_lines (lua_State *L) { + if (lua_isnoneornil(L, 1)) { /* no arguments? */ + /* will iterate over default input */ + lua_rawgeti(L, LUA_ENVIRONINDEX, IO_INPUT); + return f_lines(L); + } + else { + const char *filename = luaL_checkstring(L, 1); + FILE **pf = newfile(L); + *pf = fopen(filename, "r"); + if (*pf == NULL) + fileerror(L, 1, filename); + aux_lines(L, lua_gettop(L), 1); + return 1; + } +} + + +/* +** {====================================================== +** READ +** ======================================================= +*/ + + +static int read_number (lua_State *L, FILE *f) { + lua_Number d; + if (fscanf(f, LUA_NUMBER_SCAN, &d) == 1) { + lua_pushnumber(L, d); + return 1; + } + else { + lua_pushnil(L); /* "result" to be removed */ + return 0; /* read fails */ + } +} + + +static int test_eof (lua_State *L, FILE *f) { + int c = getc(f); + ungetc(c, f); + lua_pushlstring(L, NULL, 0); + return (c != EOF); +} + + +static int read_line (lua_State *L, FILE *f) { + luaL_Buffer b; + luaL_buffinit(L, &b); + for (;;) { + size_t l; + char *p = luaL_prepbuffer(&b); + if (fgets(p, LUAL_BUFFERSIZE, f) == NULL) { /* eof? */ + luaL_pushresult(&b); /* close buffer */ + return (lua_objlen(L, -1) > 0); /* check whether read something */ + } + l = strlen(p); + if (l == 0 || p[l-1] != '\n') + luaL_addsize(&b, l); + else { + luaL_addsize(&b, l - 1); /* do not include `eol' */ + luaL_pushresult(&b); /* close buffer */ + return 1; /* read at least an `eol' */ + } + } +} + + +static int read_chars (lua_State *L, FILE *f, size_t n) { + size_t rlen; /* how much to read */ + size_t nr; /* number of chars actually read */ + luaL_Buffer b; + luaL_buffinit(L, &b); + rlen = LUAL_BUFFERSIZE; /* try to read that much each time */ + do { + char *p = luaL_prepbuffer(&b); + if (rlen > n) rlen = n; /* cannot read more than asked */ + nr = fread(p, sizeof(char), rlen, f); + luaL_addsize(&b, nr); + n -= nr; /* still have to read `n' chars */ + } while (n > 0 && nr == rlen); /* until end of count or eof */ + luaL_pushresult(&b); /* close buffer */ + return (n == 0 || lua_objlen(L, -1) > 0); +} + + +static int g_read (lua_State *L, FILE *f, int first) { + int nargs = lua_gettop(L) - 1; + int success; + int n; + clearerr(f); + if (nargs == 0) { /* no arguments? */ + success = read_line(L, f); + n = first+1; /* to return 1 result */ + } + else { /* ensure stack space for all results and for auxlib's buffer */ + luaL_checkstack(L, nargs+LUA_MINSTACK, "too many arguments"); + success = 1; + for (n = first; nargs-- && success; n++) { + if (lua_type(L, n) == LUA_TNUMBER) { + size_t l = (size_t)lua_tointeger(L, n); + success = (l == 0) ? test_eof(L, f) : read_chars(L, f, l); + } + else { + const char *p = lua_tostring(L, n); + luaL_argcheck(L, p && p[0] == '*', n, "invalid option"); + switch (p[1]) { + case 'n': /* number */ + success = read_number(L, f); + break; + case 'l': /* line */ + success = read_line(L, f); + break; + case 'a': /* file */ + read_chars(L, f, ~((size_t)0)); /* read MAX_SIZE_T chars */ + success = 1; /* always success */ + break; + default: + return luaL_argerror(L, n, "invalid format"); + } + } + } + } + if (ferror(f)) + return pushresult(L, 0, NULL); + if (!success) { + lua_pop(L, 1); /* remove last result */ + lua_pushnil(L); /* push nil instead */ + } + return n - first; +} + + +static int io_read (lua_State *L) { + return g_read(L, getiofile(L, IO_INPUT), 1); +} + + +static int f_read (lua_State *L) { + return g_read(L, tofile(L), 2); +} + + +static int io_readline (lua_State *L) { + FILE *f = *(FILE **)lua_touserdata(L, lua_upvalueindex(1)); + int sucess; + if (f == NULL) /* file is already closed? */ + luaL_error(L, "file is already closed"); + sucess = read_line(L, f); + if (ferror(f)) + return luaL_error(L, "%s", strerror(errno)); + if (sucess) return 1; + else { /* EOF */ + if (lua_toboolean(L, lua_upvalueindex(2))) { /* generator created file? */ + lua_settop(L, 0); + lua_pushvalue(L, lua_upvalueindex(1)); + aux_close(L); /* close it */ + } + return 0; + } +} + +/* }====================================================== */ + + +static int g_write (lua_State *L, FILE *f, int arg) { + int nargs = lua_gettop(L) - 1; + int status = 1; + for (; nargs--; arg++) { + if (lua_type(L, arg) == LUA_TNUMBER) { + /* optimization: could be done exactly as for strings */ + status = status && + fprintf(f, LUA_NUMBER_FMT, lua_tonumber(L, arg)) > 0; + } + else { + size_t l; + const char *s = luaL_checklstring(L, arg, &l); + status = status && (fwrite(s, sizeof(char), l, f) == l); + } + } + return pushresult(L, status, NULL); +} + + +static int io_write (lua_State *L) { + return g_write(L, getiofile(L, IO_OUTPUT), 1); +} + + +static int f_write (lua_State *L) { + return g_write(L, tofile(L), 2); +} + + +static int f_seek (lua_State *L) { + static const int mode[] = {SEEK_SET, SEEK_CUR, SEEK_END}; + static const char *const modenames[] = {"set", "cur", "end", NULL}; + FILE *f = tofile(L); + int op = luaL_checkoption(L, 2, "cur", modenames); + long offset = luaL_optlong(L, 3, 0); + op = fseek(f, offset, mode[op]); + if (op) + return pushresult(L, 0, NULL); /* error */ + else { + lua_pushinteger(L, ftell(f)); + return 1; + } +} + + +static int f_setvbuf (lua_State *L) { + static const int mode[] = {_IONBF, _IOFBF, _IOLBF}; + static const char *const modenames[] = {"no", "full", "line", NULL}; + FILE *f = tofile(L); + int op = luaL_checkoption(L, 2, NULL, modenames); + lua_Integer sz = luaL_optinteger(L, 3, LUAL_BUFFERSIZE); + int res = setvbuf(f, NULL, mode[op], sz); + return pushresult(L, res == 0, NULL); +} + + + +static int io_flush (lua_State *L) { + return pushresult(L, fflush(getiofile(L, IO_OUTPUT)) == 0, NULL); +} + + +static int f_flush (lua_State *L) { + return pushresult(L, fflush(tofile(L)) == 0, NULL); +} + + +static const luaL_Reg iolib[] = { + {"close", io_close}, + {"flush", io_flush}, + {"input", io_input}, + {"lines", io_lines}, + {"open", io_open}, + {"output", io_output}, + {"popen", io_popen}, + {"read", io_read}, + {"tmpfile", io_tmpfile}, + {"type", io_type}, + {"write", io_write}, + {NULL, NULL} +}; + + +static const luaL_Reg flib[] = { + {"close", io_close}, + {"flush", f_flush}, + {"lines", f_lines}, + {"read", f_read}, + {"seek", f_seek}, + {"setvbuf", f_setvbuf}, + {"write", f_write}, + {"__gc", io_gc}, + {"__tostring", io_tostring}, + {NULL, NULL} +}; + + +static void createmeta (lua_State *L) { + luaL_newmetatable(L, LUA_FILEHANDLE); /* create metatable for file handles */ + lua_pushvalue(L, -1); /* push metatable */ + lua_setfield(L, -2, "__index"); /* metatable.__index = metatable */ + luaL_register(L, NULL, flib); /* file methods */ +} + + +static void createstdfile (lua_State *L, FILE *f, int k, const char *fname) { + *newfile(L) = f; + if (k > 0) { + lua_pushvalue(L, -1); + lua_rawseti(L, LUA_ENVIRONINDEX, k); + } + lua_pushvalue(L, -2); /* copy environment */ + lua_setfenv(L, -2); /* set it */ + lua_setfield(L, -3, fname); +} + + +static void newfenv (lua_State *L, lua_CFunction cls) { + lua_createtable(L, 0, 1); + lua_pushcfunction(L, cls); + lua_setfield(L, -2, "__close"); +} + + +LUALIB_API int luaopen_io (lua_State *L) { + createmeta(L); + /* create (private) environment (with fields IO_INPUT, IO_OUTPUT, __close) */ + newfenv(L, io_fclose); + lua_replace(L, LUA_ENVIRONINDEX); + /* open library */ + luaL_register(L, LUA_IOLIBNAME, iolib); + /* create (and set) default files */ + newfenv(L, io_noclose); /* close function for default files */ + createstdfile(L, stdin, IO_INPUT, "stdin"); + createstdfile(L, stdout, IO_OUTPUT, "stdout"); + createstdfile(L, stderr, 0, "stderr"); + lua_pop(L, 1); /* pop environment for default files */ + lua_getfield(L, -1, "popen"); + newfenv(L, io_pclose); /* create environment for 'popen' */ + lua_setfenv(L, -2); /* set fenv for 'popen' */ + lua_pop(L, 1); /* pop 'popen' */ + return 1; +} + diff --git a/extern/lua-5.1.5/src/llex.c b/extern/lua-5.1.5/src/llex.c new file mode 100644 index 00000000..88c6790c --- /dev/null +++ b/extern/lua-5.1.5/src/llex.c @@ -0,0 +1,463 @@ +/* +** $Id: llex.c,v 2.20.1.2 2009/11/23 14:58:22 roberto Exp $ +** Lexical Analyzer +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include + +#define llex_c +#define LUA_CORE + +#include "lua.h" + +#include "ldo.h" +#include "llex.h" +#include "lobject.h" +#include "lparser.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "lzio.h" + + + +#define next(ls) (ls->current = zgetc(ls->z)) + + + + +#define currIsNewline(ls) (ls->current == '\n' || ls->current == '\r') + + +/* ORDER RESERVED */ +const char *const luaX_tokens [] = { + "and", "break", "do", "else", "elseif", + "end", "false", "for", "function", "if", + "in", "local", "nil", "not", "or", "repeat", + "return", "then", "true", "until", "while", + "..", "...", "==", ">=", "<=", "~=", + "", "", "", "", + NULL +}; + + +#define save_and_next(ls) (save(ls, ls->current), next(ls)) + + +static void save (LexState *ls, int c) { + Mbuffer *b = ls->buff; + if (b->n + 1 > b->buffsize) { + size_t newsize; + if (b->buffsize >= MAX_SIZET/2) + luaX_lexerror(ls, "lexical element too long", 0); + newsize = b->buffsize * 2; + luaZ_resizebuffer(ls->L, b, newsize); + } + b->buffer[b->n++] = cast(char, c); +} + + +void luaX_init (lua_State *L) { + int i; + for (i=0; itsv.reserved = cast_byte(i+1); /* reserved word */ + } +} + + +#define MAXSRC 80 + + +const char *luaX_token2str (LexState *ls, int token) { + if (token < FIRST_RESERVED) { + lua_assert(token == cast(unsigned char, token)); + return (iscntrl(token)) ? luaO_pushfstring(ls->L, "char(%d)", token) : + luaO_pushfstring(ls->L, "%c", token); + } + else + return luaX_tokens[token-FIRST_RESERVED]; +} + + +static const char *txtToken (LexState *ls, int token) { + switch (token) { + case TK_NAME: + case TK_STRING: + case TK_NUMBER: + save(ls, '\0'); + return luaZ_buffer(ls->buff); + default: + return luaX_token2str(ls, token); + } +} + + +void luaX_lexerror (LexState *ls, const char *msg, int token) { + char buff[MAXSRC]; + luaO_chunkid(buff, getstr(ls->source), MAXSRC); + msg = luaO_pushfstring(ls->L, "%s:%d: %s", buff, ls->linenumber, msg); + if (token) + luaO_pushfstring(ls->L, "%s near " LUA_QS, msg, txtToken(ls, token)); + luaD_throw(ls->L, LUA_ERRSYNTAX); +} + + +void luaX_syntaxerror (LexState *ls, const char *msg) { + luaX_lexerror(ls, msg, ls->t.token); +} + + +TString *luaX_newstring (LexState *ls, const char *str, size_t l) { + lua_State *L = ls->L; + TString *ts = luaS_newlstr(L, str, l); + TValue *o = luaH_setstr(L, ls->fs->h, ts); /* entry for `str' */ + if (ttisnil(o)) { + setbvalue(o, 1); /* make sure `str' will not be collected */ + luaC_checkGC(L); + } + return ts; +} + + +static void inclinenumber (LexState *ls) { + int old = ls->current; + lua_assert(currIsNewline(ls)); + next(ls); /* skip `\n' or `\r' */ + if (currIsNewline(ls) && ls->current != old) + next(ls); /* skip `\n\r' or `\r\n' */ + if (++ls->linenumber >= MAX_INT) + luaX_syntaxerror(ls, "chunk has too many lines"); +} + + +void luaX_setinput (lua_State *L, LexState *ls, ZIO *z, TString *source) { + ls->decpoint = '.'; + ls->L = L; + ls->lookahead.token = TK_EOS; /* no look-ahead token */ + ls->z = z; + ls->fs = NULL; + ls->linenumber = 1; + ls->lastline = 1; + ls->source = source; + luaZ_resizebuffer(ls->L, ls->buff, LUA_MINBUFFER); /* initialize buffer */ + next(ls); /* read first char */ +} + + + +/* +** ======================================================= +** LEXICAL ANALYZER +** ======================================================= +*/ + + + +static int check_next (LexState *ls, const char *set) { + if (!strchr(set, ls->current)) + return 0; + save_and_next(ls); + return 1; +} + + +static void buffreplace (LexState *ls, char from, char to) { + size_t n = luaZ_bufflen(ls->buff); + char *p = luaZ_buffer(ls->buff); + while (n--) + if (p[n] == from) p[n] = to; +} + + +static void trydecpoint (LexState *ls, SemInfo *seminfo) { + /* format error: try to update decimal point separator */ + struct lconv *cv = localeconv(); + char old = ls->decpoint; + ls->decpoint = (cv ? cv->decimal_point[0] : '.'); + buffreplace(ls, old, ls->decpoint); /* try updated decimal separator */ + if (!luaO_str2d(luaZ_buffer(ls->buff), &seminfo->r)) { + /* format error with correct decimal point: no more options */ + buffreplace(ls, ls->decpoint, '.'); /* undo change (for error message) */ + luaX_lexerror(ls, "malformed number", TK_NUMBER); + } +} + + +/* LUA_NUMBER */ +static void read_numeral (LexState *ls, SemInfo *seminfo) { + lua_assert(isdigit(ls->current)); + do { + save_and_next(ls); + } while (isdigit(ls->current) || ls->current == '.'); + if (check_next(ls, "Ee")) /* `E'? */ + check_next(ls, "+-"); /* optional exponent sign */ + while (isalnum(ls->current) || ls->current == '_') + save_and_next(ls); + save(ls, '\0'); + buffreplace(ls, '.', ls->decpoint); /* follow locale for decimal point */ + if (!luaO_str2d(luaZ_buffer(ls->buff), &seminfo->r)) /* format error? */ + trydecpoint(ls, seminfo); /* try to update decimal point separator */ +} + + +static int skip_sep (LexState *ls) { + int count = 0; + int s = ls->current; + lua_assert(s == '[' || s == ']'); + save_and_next(ls); + while (ls->current == '=') { + save_and_next(ls); + count++; + } + return (ls->current == s) ? count : (-count) - 1; +} + + +static void read_long_string (LexState *ls, SemInfo *seminfo, int sep) { + int cont = 0; + (void)(cont); /* avoid warnings when `cont' is not used */ + save_and_next(ls); /* skip 2nd `[' */ + if (currIsNewline(ls)) /* string starts with a newline? */ + inclinenumber(ls); /* skip it */ + for (;;) { + switch (ls->current) { + case EOZ: + luaX_lexerror(ls, (seminfo) ? "unfinished long string" : + "unfinished long comment", TK_EOS); + break; /* to avoid warnings */ +#if defined(LUA_COMPAT_LSTR) + case '[': { + if (skip_sep(ls) == sep) { + save_and_next(ls); /* skip 2nd `[' */ + cont++; +#if LUA_COMPAT_LSTR == 1 + if (sep == 0) + luaX_lexerror(ls, "nesting of [[...]] is deprecated", '['); +#endif + } + break; + } +#endif + case ']': { + if (skip_sep(ls) == sep) { + save_and_next(ls); /* skip 2nd `]' */ +#if defined(LUA_COMPAT_LSTR) && LUA_COMPAT_LSTR == 2 + cont--; + if (sep == 0 && cont >= 0) break; +#endif + goto endloop; + } + break; + } + case '\n': + case '\r': { + save(ls, '\n'); + inclinenumber(ls); + if (!seminfo) luaZ_resetbuffer(ls->buff); /* avoid wasting space */ + break; + } + default: { + if (seminfo) save_and_next(ls); + else next(ls); + } + } + } endloop: + if (seminfo) + seminfo->ts = luaX_newstring(ls, luaZ_buffer(ls->buff) + (2 + sep), + luaZ_bufflen(ls->buff) - 2*(2 + sep)); +} + + +static void read_string (LexState *ls, int del, SemInfo *seminfo) { + save_and_next(ls); + while (ls->current != del) { + switch (ls->current) { + case EOZ: + luaX_lexerror(ls, "unfinished string", TK_EOS); + continue; /* to avoid warnings */ + case '\n': + case '\r': + luaX_lexerror(ls, "unfinished string", TK_STRING); + continue; /* to avoid warnings */ + case '\\': { + int c; + next(ls); /* do not save the `\' */ + switch (ls->current) { + case 'a': c = '\a'; break; + case 'b': c = '\b'; break; + case 'f': c = '\f'; break; + case 'n': c = '\n'; break; + case 'r': c = '\r'; break; + case 't': c = '\t'; break; + case 'v': c = '\v'; break; + case '\n': /* go through */ + case '\r': save(ls, '\n'); inclinenumber(ls); continue; + case EOZ: continue; /* will raise an error next loop */ + default: { + if (!isdigit(ls->current)) + save_and_next(ls); /* handles \\, \", \', and \? */ + else { /* \xxx */ + int i = 0; + c = 0; + do { + c = 10*c + (ls->current-'0'); + next(ls); + } while (++i<3 && isdigit(ls->current)); + if (c > UCHAR_MAX) + luaX_lexerror(ls, "escape sequence too large", TK_STRING); + save(ls, c); + } + continue; + } + } + save(ls, c); + next(ls); + continue; + } + default: + save_and_next(ls); + } + } + save_and_next(ls); /* skip delimiter */ + seminfo->ts = luaX_newstring(ls, luaZ_buffer(ls->buff) + 1, + luaZ_bufflen(ls->buff) - 2); +} + + +static int llex (LexState *ls, SemInfo *seminfo) { + luaZ_resetbuffer(ls->buff); + for (;;) { + switch (ls->current) { + case '\n': + case '\r': { + inclinenumber(ls); + continue; + } + case '-': { + next(ls); + if (ls->current != '-') return '-'; + /* else is a comment */ + next(ls); + if (ls->current == '[') { + int sep = skip_sep(ls); + luaZ_resetbuffer(ls->buff); /* `skip_sep' may dirty the buffer */ + if (sep >= 0) { + read_long_string(ls, NULL, sep); /* long comment */ + luaZ_resetbuffer(ls->buff); + continue; + } + } + /* else short comment */ + while (!currIsNewline(ls) && ls->current != EOZ) + next(ls); + continue; + } + case '[': { + int sep = skip_sep(ls); + if (sep >= 0) { + read_long_string(ls, seminfo, sep); + return TK_STRING; + } + else if (sep == -1) return '['; + else luaX_lexerror(ls, "invalid long string delimiter", TK_STRING); + } + case '=': { + next(ls); + if (ls->current != '=') return '='; + else { next(ls); return TK_EQ; } + } + case '<': { + next(ls); + if (ls->current != '=') return '<'; + else { next(ls); return TK_LE; } + } + case '>': { + next(ls); + if (ls->current != '=') return '>'; + else { next(ls); return TK_GE; } + } + case '~': { + next(ls); + if (ls->current != '=') return '~'; + else { next(ls); return TK_NE; } + } + case '"': + case '\'': { + read_string(ls, ls->current, seminfo); + return TK_STRING; + } + case '.': { + save_and_next(ls); + if (check_next(ls, ".")) { + if (check_next(ls, ".")) + return TK_DOTS; /* ... */ + else return TK_CONCAT; /* .. */ + } + else if (!isdigit(ls->current)) return '.'; + else { + read_numeral(ls, seminfo); + return TK_NUMBER; + } + } + case EOZ: { + return TK_EOS; + } + default: { + if (isspace(ls->current)) { + lua_assert(!currIsNewline(ls)); + next(ls); + continue; + } + else if (isdigit(ls->current)) { + read_numeral(ls, seminfo); + return TK_NUMBER; + } + else if (isalpha(ls->current) || ls->current == '_') { + /* identifier or reserved word */ + TString *ts; + do { + save_and_next(ls); + } while (isalnum(ls->current) || ls->current == '_'); + ts = luaX_newstring(ls, luaZ_buffer(ls->buff), + luaZ_bufflen(ls->buff)); + if (ts->tsv.reserved > 0) /* reserved word? */ + return ts->tsv.reserved - 1 + FIRST_RESERVED; + else { + seminfo->ts = ts; + return TK_NAME; + } + } + else { + int c = ls->current; + next(ls); + return c; /* single-char tokens (+ - / ...) */ + } + } + } + } +} + + +void luaX_next (LexState *ls) { + ls->lastline = ls->linenumber; + if (ls->lookahead.token != TK_EOS) { /* is there a look-ahead token? */ + ls->t = ls->lookahead; /* use this one */ + ls->lookahead.token = TK_EOS; /* and discharge it */ + } + else + ls->t.token = llex(ls, &ls->t.seminfo); /* read next token */ +} + + +void luaX_lookahead (LexState *ls) { + lua_assert(ls->lookahead.token == TK_EOS); + ls->lookahead.token = llex(ls, &ls->lookahead.seminfo); +} + diff --git a/extern/lua-5.1.5/src/llex.h b/extern/lua-5.1.5/src/llex.h new file mode 100644 index 00000000..a9201cee --- /dev/null +++ b/extern/lua-5.1.5/src/llex.h @@ -0,0 +1,81 @@ +/* +** $Id: llex.h,v 1.58.1.1 2007/12/27 13:02:25 roberto Exp $ +** Lexical Analyzer +** See Copyright Notice in lua.h +*/ + +#ifndef llex_h +#define llex_h + +#include "lobject.h" +#include "lzio.h" + + +#define FIRST_RESERVED 257 + +/* maximum length of a reserved word */ +#define TOKEN_LEN (sizeof("function")/sizeof(char)) + + +/* +* WARNING: if you change the order of this enumeration, +* grep "ORDER RESERVED" +*/ +enum RESERVED { + /* terminal symbols denoted by reserved words */ + TK_AND = FIRST_RESERVED, TK_BREAK, + TK_DO, TK_ELSE, TK_ELSEIF, TK_END, TK_FALSE, TK_FOR, TK_FUNCTION, + TK_IF, TK_IN, TK_LOCAL, TK_NIL, TK_NOT, TK_OR, TK_REPEAT, + TK_RETURN, TK_THEN, TK_TRUE, TK_UNTIL, TK_WHILE, + /* other terminal symbols */ + TK_CONCAT, TK_DOTS, TK_EQ, TK_GE, TK_LE, TK_NE, TK_NUMBER, + TK_NAME, TK_STRING, TK_EOS +}; + +/* number of reserved words */ +#define NUM_RESERVED (cast(int, TK_WHILE-FIRST_RESERVED+1)) + + +/* array with token `names' */ +LUAI_DATA const char *const luaX_tokens []; + + +typedef union { + lua_Number r; + TString *ts; +} SemInfo; /* semantics information */ + + +typedef struct Token { + int token; + SemInfo seminfo; +} Token; + + +typedef struct LexState { + int current; /* current character (charint) */ + int linenumber; /* input line counter */ + int lastline; /* line of last token `consumed' */ + Token t; /* current token */ + Token lookahead; /* look ahead token */ + struct FuncState *fs; /* `FuncState' is private to the parser */ + struct lua_State *L; + ZIO *z; /* input stream */ + Mbuffer *buff; /* buffer for tokens */ + TString *source; /* current source name */ + char decpoint; /* locale decimal point */ +} LexState; + + +LUAI_FUNC void luaX_init (lua_State *L); +LUAI_FUNC void luaX_setinput (lua_State *L, LexState *ls, ZIO *z, + TString *source); +LUAI_FUNC TString *luaX_newstring (LexState *ls, const char *str, size_t l); +LUAI_FUNC void luaX_next (LexState *ls); +LUAI_FUNC void luaX_lookahead (LexState *ls); +LUAI_FUNC void luaX_lexerror (LexState *ls, const char *msg, int token); +LUAI_FUNC void luaX_syntaxerror (LexState *ls, const char *s); +LUAI_FUNC const char *luaX_token2str (LexState *ls, int token); + + +#endif diff --git a/extern/lua-5.1.5/src/llimits.h b/extern/lua-5.1.5/src/llimits.h new file mode 100644 index 00000000..ca8dcb72 --- /dev/null +++ b/extern/lua-5.1.5/src/llimits.h @@ -0,0 +1,128 @@ +/* +** $Id: llimits.h,v 1.69.1.1 2007/12/27 13:02:25 roberto Exp $ +** Limits, basic types, and some other `installation-dependent' definitions +** See Copyright Notice in lua.h +*/ + +#ifndef llimits_h +#define llimits_h + + +#include +#include + + +#include "lua.h" + + +typedef LUAI_UINT32 lu_int32; + +typedef LUAI_UMEM lu_mem; + +typedef LUAI_MEM l_mem; + + + +/* chars used as small naturals (so that `char' is reserved for characters) */ +typedef unsigned char lu_byte; + + +#define MAX_SIZET ((size_t)(~(size_t)0)-2) + +#define MAX_LUMEM ((lu_mem)(~(lu_mem)0)-2) + + +#define MAX_INT (INT_MAX-2) /* maximum value of an int (-2 for safety) */ + +/* +** conversion of pointer to integer +** this is for hashing only; there is no problem if the integer +** cannot hold the whole pointer value +*/ +#define IntPoint(p) ((unsigned int)(lu_mem)(p)) + + + +/* type to ensure maximum alignment */ +typedef LUAI_USER_ALIGNMENT_T L_Umaxalign; + + +/* result of a `usual argument conversion' over lua_Number */ +typedef LUAI_UACNUMBER l_uacNumber; + + +/* internal assertions for in-house debugging */ +#ifdef lua_assert + +#define check_exp(c,e) (lua_assert(c), (e)) +#define api_check(l,e) lua_assert(e) + +#else + +#define lua_assert(c) ((void)0) +#define check_exp(c,e) (e) +#define api_check luai_apicheck + +#endif + + +#ifndef UNUSED +#define UNUSED(x) ((void)(x)) /* to avoid warnings */ +#endif + + +#ifndef cast +#define cast(t, exp) ((t)(exp)) +#endif + +#define cast_byte(i) cast(lu_byte, (i)) +#define cast_num(i) cast(lua_Number, (i)) +#define cast_int(i) cast(int, (i)) + + + +/* +** type for virtual-machine instructions +** must be an unsigned with (at least) 4 bytes (see details in lopcodes.h) +*/ +typedef lu_int32 Instruction; + + + +/* maximum stack for a Lua function */ +#define MAXSTACK 250 + + + +/* minimum size for the string table (must be power of 2) */ +#ifndef MINSTRTABSIZE +#define MINSTRTABSIZE 32 +#endif + + +/* minimum size for string buffer */ +#ifndef LUA_MINBUFFER +#define LUA_MINBUFFER 32 +#endif + + +#ifndef lua_lock +#define lua_lock(L) ((void) 0) +#define lua_unlock(L) ((void) 0) +#endif + +#ifndef luai_threadyield +#define luai_threadyield(L) {lua_unlock(L); lua_lock(L);} +#endif + + +/* +** macro to control inclusion of some hard tests on stack reallocation +*/ +#ifndef HARDSTACKTESTS +#define condhardstacktests(x) ((void)0) +#else +#define condhardstacktests(x) x +#endif + +#endif diff --git a/extern/lua-5.1.5/src/lmathlib.c b/extern/lua-5.1.5/src/lmathlib.c new file mode 100644 index 00000000..441fbf73 --- /dev/null +++ b/extern/lua-5.1.5/src/lmathlib.c @@ -0,0 +1,263 @@ +/* +** $Id: lmathlib.c,v 1.67.1.1 2007/12/27 13:02:25 roberto Exp $ +** Standard mathematical library +** See Copyright Notice in lua.h +*/ + + +#include +#include + +#define lmathlib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + +#undef PI +#define PI (3.14159265358979323846) +#define RADIANS_PER_DEGREE (PI/180.0) + + + +static int math_abs (lua_State *L) { + lua_pushnumber(L, fabs(luaL_checknumber(L, 1))); + return 1; +} + +static int math_sin (lua_State *L) { + lua_pushnumber(L, sin(luaL_checknumber(L, 1))); + return 1; +} + +static int math_sinh (lua_State *L) { + lua_pushnumber(L, sinh(luaL_checknumber(L, 1))); + return 1; +} + +static int math_cos (lua_State *L) { + lua_pushnumber(L, cos(luaL_checknumber(L, 1))); + return 1; +} + +static int math_cosh (lua_State *L) { + lua_pushnumber(L, cosh(luaL_checknumber(L, 1))); + return 1; +} + +static int math_tan (lua_State *L) { + lua_pushnumber(L, tan(luaL_checknumber(L, 1))); + return 1; +} + +static int math_tanh (lua_State *L) { + lua_pushnumber(L, tanh(luaL_checknumber(L, 1))); + return 1; +} + +static int math_asin (lua_State *L) { + lua_pushnumber(L, asin(luaL_checknumber(L, 1))); + return 1; +} + +static int math_acos (lua_State *L) { + lua_pushnumber(L, acos(luaL_checknumber(L, 1))); + return 1; +} + +static int math_atan (lua_State *L) { + lua_pushnumber(L, atan(luaL_checknumber(L, 1))); + return 1; +} + +static int math_atan2 (lua_State *L) { + lua_pushnumber(L, atan2(luaL_checknumber(L, 1), luaL_checknumber(L, 2))); + return 1; +} + +static int math_ceil (lua_State *L) { + lua_pushnumber(L, ceil(luaL_checknumber(L, 1))); + return 1; +} + +static int math_floor (lua_State *L) { + lua_pushnumber(L, floor(luaL_checknumber(L, 1))); + return 1; +} + +static int math_fmod (lua_State *L) { + lua_pushnumber(L, fmod(luaL_checknumber(L, 1), luaL_checknumber(L, 2))); + return 1; +} + +static int math_modf (lua_State *L) { + double ip; + double fp = modf(luaL_checknumber(L, 1), &ip); + lua_pushnumber(L, ip); + lua_pushnumber(L, fp); + return 2; +} + +static int math_sqrt (lua_State *L) { + lua_pushnumber(L, sqrt(luaL_checknumber(L, 1))); + return 1; +} + +static int math_pow (lua_State *L) { + lua_pushnumber(L, pow(luaL_checknumber(L, 1), luaL_checknumber(L, 2))); + return 1; +} + +static int math_log (lua_State *L) { + lua_pushnumber(L, log(luaL_checknumber(L, 1))); + return 1; +} + +static int math_log10 (lua_State *L) { + lua_pushnumber(L, log10(luaL_checknumber(L, 1))); + return 1; +} + +static int math_exp (lua_State *L) { + lua_pushnumber(L, exp(luaL_checknumber(L, 1))); + return 1; +} + +static int math_deg (lua_State *L) { + lua_pushnumber(L, luaL_checknumber(L, 1)/RADIANS_PER_DEGREE); + return 1; +} + +static int math_rad (lua_State *L) { + lua_pushnumber(L, luaL_checknumber(L, 1)*RADIANS_PER_DEGREE); + return 1; +} + +static int math_frexp (lua_State *L) { + int e; + lua_pushnumber(L, frexp(luaL_checknumber(L, 1), &e)); + lua_pushinteger(L, e); + return 2; +} + +static int math_ldexp (lua_State *L) { + lua_pushnumber(L, ldexp(luaL_checknumber(L, 1), luaL_checkint(L, 2))); + return 1; +} + + + +static int math_min (lua_State *L) { + int n = lua_gettop(L); /* number of arguments */ + lua_Number dmin = luaL_checknumber(L, 1); + int i; + for (i=2; i<=n; i++) { + lua_Number d = luaL_checknumber(L, i); + if (d < dmin) + dmin = d; + } + lua_pushnumber(L, dmin); + return 1; +} + + +static int math_max (lua_State *L) { + int n = lua_gettop(L); /* number of arguments */ + lua_Number dmax = luaL_checknumber(L, 1); + int i; + for (i=2; i<=n; i++) { + lua_Number d = luaL_checknumber(L, i); + if (d > dmax) + dmax = d; + } + lua_pushnumber(L, dmax); + return 1; +} + + +static int math_random (lua_State *L) { + /* the `%' avoids the (rare) case of r==1, and is needed also because on + some systems (SunOS!) `rand()' may return a value larger than RAND_MAX */ + lua_Number r = (lua_Number)(rand()%RAND_MAX) / (lua_Number)RAND_MAX; + switch (lua_gettop(L)) { /* check number of arguments */ + case 0: { /* no arguments */ + lua_pushnumber(L, r); /* Number between 0 and 1 */ + break; + } + case 1: { /* only upper limit */ + int u = luaL_checkint(L, 1); + luaL_argcheck(L, 1<=u, 1, "interval is empty"); + lua_pushnumber(L, floor(r*u)+1); /* int between 1 and `u' */ + break; + } + case 2: { /* lower and upper limits */ + int l = luaL_checkint(L, 1); + int u = luaL_checkint(L, 2); + luaL_argcheck(L, l<=u, 2, "interval is empty"); + lua_pushnumber(L, floor(r*(u-l+1))+l); /* int between `l' and `u' */ + break; + } + default: return luaL_error(L, "wrong number of arguments"); + } + return 1; +} + + +static int math_randomseed (lua_State *L) { + srand(luaL_checkint(L, 1)); + return 0; +} + + +static const luaL_Reg mathlib[] = { + {"abs", math_abs}, + {"acos", math_acos}, + {"asin", math_asin}, + {"atan2", math_atan2}, + {"atan", math_atan}, + {"ceil", math_ceil}, + {"cosh", math_cosh}, + {"cos", math_cos}, + {"deg", math_deg}, + {"exp", math_exp}, + {"floor", math_floor}, + {"fmod", math_fmod}, + {"frexp", math_frexp}, + {"ldexp", math_ldexp}, + {"log10", math_log10}, + {"log", math_log}, + {"max", math_max}, + {"min", math_min}, + {"modf", math_modf}, + {"pow", math_pow}, + {"rad", math_rad}, + {"random", math_random}, + {"randomseed", math_randomseed}, + {"sinh", math_sinh}, + {"sin", math_sin}, + {"sqrt", math_sqrt}, + {"tanh", math_tanh}, + {"tan", math_tan}, + {NULL, NULL} +}; + + +/* +** Open math library +*/ +LUALIB_API int luaopen_math (lua_State *L) { + luaL_register(L, LUA_MATHLIBNAME, mathlib); + lua_pushnumber(L, PI); + lua_setfield(L, -2, "pi"); + lua_pushnumber(L, HUGE_VAL); + lua_setfield(L, -2, "huge"); +#if defined(LUA_COMPAT_MOD) + lua_getfield(L, -1, "fmod"); + lua_setfield(L, -2, "mod"); +#endif + return 1; +} + diff --git a/extern/lua-5.1.5/src/lmem.c b/extern/lua-5.1.5/src/lmem.c new file mode 100644 index 00000000..ae7d8c96 --- /dev/null +++ b/extern/lua-5.1.5/src/lmem.c @@ -0,0 +1,86 @@ +/* +** $Id: lmem.c,v 1.70.1.1 2007/12/27 13:02:25 roberto Exp $ +** Interface to Memory Manager +** See Copyright Notice in lua.h +*/ + + +#include + +#define lmem_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" + + + +/* +** About the realloc function: +** void * frealloc (void *ud, void *ptr, size_t osize, size_t nsize); +** (`osize' is the old size, `nsize' is the new size) +** +** Lua ensures that (ptr == NULL) iff (osize == 0). +** +** * frealloc(ud, NULL, 0, x) creates a new block of size `x' +** +** * frealloc(ud, p, x, 0) frees the block `p' +** (in this specific case, frealloc must return NULL). +** particularly, frealloc(ud, NULL, 0, 0) does nothing +** (which is equivalent to free(NULL) in ANSI C) +** +** frealloc returns NULL if it cannot create or reallocate the area +** (any reallocation to an equal or smaller size cannot fail!) +*/ + + + +#define MINSIZEARRAY 4 + + +void *luaM_growaux_ (lua_State *L, void *block, int *size, size_t size_elems, + int limit, const char *errormsg) { + void *newblock; + int newsize; + if (*size >= limit/2) { /* cannot double it? */ + if (*size >= limit) /* cannot grow even a little? */ + luaG_runerror(L, errormsg); + newsize = limit; /* still have at least one free place */ + } + else { + newsize = (*size)*2; + if (newsize < MINSIZEARRAY) + newsize = MINSIZEARRAY; /* minimum size */ + } + newblock = luaM_reallocv(L, block, *size, newsize, size_elems); + *size = newsize; /* update only when everything else is OK */ + return newblock; +} + + +void *luaM_toobig (lua_State *L) { + luaG_runerror(L, "memory allocation error: block too big"); + return NULL; /* to avoid warnings */ +} + + + +/* +** generic allocation routine. +*/ +void *luaM_realloc_ (lua_State *L, void *block, size_t osize, size_t nsize) { + global_State *g = G(L); + lua_assert((osize == 0) == (block == NULL)); + block = (*g->frealloc)(g->ud, block, osize, nsize); + if (block == NULL && nsize > 0) + luaD_throw(L, LUA_ERRMEM); + lua_assert((nsize == 0) == (block == NULL)); + g->totalbytes = (g->totalbytes - osize) + nsize; + return block; +} + diff --git a/extern/lua-5.1.5/src/lmem.h b/extern/lua-5.1.5/src/lmem.h new file mode 100644 index 00000000..7c2dcb32 --- /dev/null +++ b/extern/lua-5.1.5/src/lmem.h @@ -0,0 +1,49 @@ +/* +** $Id: lmem.h,v 1.31.1.1 2007/12/27 13:02:25 roberto Exp $ +** Interface to Memory Manager +** See Copyright Notice in lua.h +*/ + +#ifndef lmem_h +#define lmem_h + + +#include + +#include "llimits.h" +#include "lua.h" + +#define MEMERRMSG "not enough memory" + + +#define luaM_reallocv(L,b,on,n,e) \ + ((cast(size_t, (n)+1) <= MAX_SIZET/(e)) ? /* +1 to avoid warnings */ \ + luaM_realloc_(L, (b), (on)*(e), (n)*(e)) : \ + luaM_toobig(L)) + +#define luaM_freemem(L, b, s) luaM_realloc_(L, (b), (s), 0) +#define luaM_free(L, b) luaM_realloc_(L, (b), sizeof(*(b)), 0) +#define luaM_freearray(L, b, n, t) luaM_reallocv(L, (b), n, 0, sizeof(t)) + +#define luaM_malloc(L,t) luaM_realloc_(L, NULL, 0, (t)) +#define luaM_new(L,t) cast(t *, luaM_malloc(L, sizeof(t))) +#define luaM_newvector(L,n,t) \ + cast(t *, luaM_reallocv(L, NULL, 0, n, sizeof(t))) + +#define luaM_growvector(L,v,nelems,size,t,limit,e) \ + if ((nelems)+1 > (size)) \ + ((v)=cast(t *, luaM_growaux_(L,v,&(size),sizeof(t),limit,e))) + +#define luaM_reallocvector(L, v,oldn,n,t) \ + ((v)=cast(t *, luaM_reallocv(L, v, oldn, n, sizeof(t)))) + + +LUAI_FUNC void *luaM_realloc_ (lua_State *L, void *block, size_t oldsize, + size_t size); +LUAI_FUNC void *luaM_toobig (lua_State *L); +LUAI_FUNC void *luaM_growaux_ (lua_State *L, void *block, int *size, + size_t size_elem, int limit, + const char *errormsg); + +#endif + diff --git a/extern/lua-5.1.5/src/loadlib.c b/extern/lua-5.1.5/src/loadlib.c new file mode 100644 index 00000000..6158c535 --- /dev/null +++ b/extern/lua-5.1.5/src/loadlib.c @@ -0,0 +1,666 @@ +/* +** $Id: loadlib.c,v 1.52.1.4 2009/09/09 13:17:16 roberto Exp $ +** Dynamic library loader for Lua +** See Copyright Notice in lua.h +** +** This module contains an implementation of loadlib for Unix systems +** that have dlfcn, an implementation for Darwin (Mac OS X), an +** implementation for Windows, and a stub for other systems. +*/ + + +#include +#include + + +#define loadlib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + +/* prefix for open functions in C libraries */ +#define LUA_POF "luaopen_" + +/* separator for open functions in C libraries */ +#define LUA_OFSEP "_" + + +#define LIBPREFIX "LOADLIB: " + +#define POF LUA_POF +#define LIB_FAIL "open" + + +/* error codes for ll_loadfunc */ +#define ERRLIB 1 +#define ERRFUNC 2 + +#define setprogdir(L) ((void)0) + + +static void ll_unloadlib (void *lib); +static void *ll_load (lua_State *L, const char *path); +static lua_CFunction ll_sym (lua_State *L, void *lib, const char *sym); + + + +#if defined(LUA_DL_DLOPEN) +/* +** {======================================================================== +** This is an implementation of loadlib based on the dlfcn interface. +** The dlfcn interface is available in Linux, SunOS, Solaris, IRIX, FreeBSD, +** NetBSD, AIX 4.2, HPUX 11, and probably most other Unix flavors, at least +** as an emulation layer on top of native functions. +** ========================================================================= +*/ + +#include + +static void ll_unloadlib (void *lib) { + dlclose(lib); +} + + +static void *ll_load (lua_State *L, const char *path) { + void *lib = dlopen(path, RTLD_NOW); + if (lib == NULL) lua_pushstring(L, dlerror()); + return lib; +} + + +static lua_CFunction ll_sym (lua_State *L, void *lib, const char *sym) { + lua_CFunction f = (lua_CFunction)dlsym(lib, sym); + if (f == NULL) lua_pushstring(L, dlerror()); + return f; +} + +/* }====================================================== */ + + + +#elif defined(LUA_DL_DLL) +/* +** {====================================================================== +** This is an implementation of loadlib for Windows using native functions. +** ======================================================================= +*/ + +#include + + +#undef setprogdir + +static void setprogdir (lua_State *L) { + char buff[MAX_PATH + 1]; + char *lb; + DWORD nsize = sizeof(buff)/sizeof(char); + DWORD n = GetModuleFileNameA(NULL, buff, nsize); + if (n == 0 || n == nsize || (lb = strrchr(buff, '\\')) == NULL) + luaL_error(L, "unable to get ModuleFileName"); + else { + *lb = '\0'; + luaL_gsub(L, lua_tostring(L, -1), LUA_EXECDIR, buff); + lua_remove(L, -2); /* remove original string */ + } +} + + +static void pusherror (lua_State *L) { + int error = GetLastError(); + char buffer[128]; + if (FormatMessageA(FORMAT_MESSAGE_IGNORE_INSERTS | FORMAT_MESSAGE_FROM_SYSTEM, + NULL, error, 0, buffer, sizeof(buffer), NULL)) + lua_pushstring(L, buffer); + else + lua_pushfstring(L, "system error %d\n", error); +} + +static void ll_unloadlib (void *lib) { + FreeLibrary((HINSTANCE)lib); +} + + +static void *ll_load (lua_State *L, const char *path) { + HINSTANCE lib = LoadLibraryA(path); + if (lib == NULL) pusherror(L); + return lib; +} + + +static lua_CFunction ll_sym (lua_State *L, void *lib, const char *sym) { + lua_CFunction f = (lua_CFunction)GetProcAddress((HINSTANCE)lib, sym); + if (f == NULL) pusherror(L); + return f; +} + +/* }====================================================== */ + + + +#elif defined(LUA_DL_DYLD) +/* +** {====================================================================== +** Native Mac OS X / Darwin Implementation +** ======================================================================= +*/ + +#include + + +/* Mac appends a `_' before C function names */ +#undef POF +#define POF "_" LUA_POF + + +static void pusherror (lua_State *L) { + const char *err_str; + const char *err_file; + NSLinkEditErrors err; + int err_num; + NSLinkEditError(&err, &err_num, &err_file, &err_str); + lua_pushstring(L, err_str); +} + + +static const char *errorfromcode (NSObjectFileImageReturnCode ret) { + switch (ret) { + case NSObjectFileImageInappropriateFile: + return "file is not a bundle"; + case NSObjectFileImageArch: + return "library is for wrong CPU type"; + case NSObjectFileImageFormat: + return "bad format"; + case NSObjectFileImageAccess: + return "cannot access file"; + case NSObjectFileImageFailure: + default: + return "unable to load library"; + } +} + + +static void ll_unloadlib (void *lib) { + NSUnLinkModule((NSModule)lib, NSUNLINKMODULE_OPTION_RESET_LAZY_REFERENCES); +} + + +static void *ll_load (lua_State *L, const char *path) { + NSObjectFileImage img; + NSObjectFileImageReturnCode ret; + /* this would be a rare case, but prevents crashing if it happens */ + if(!_dyld_present()) { + lua_pushliteral(L, "dyld not present"); + return NULL; + } + ret = NSCreateObjectFileImageFromFile(path, &img); + if (ret == NSObjectFileImageSuccess) { + NSModule mod = NSLinkModule(img, path, NSLINKMODULE_OPTION_PRIVATE | + NSLINKMODULE_OPTION_RETURN_ON_ERROR); + NSDestroyObjectFileImage(img); + if (mod == NULL) pusherror(L); + return mod; + } + lua_pushstring(L, errorfromcode(ret)); + return NULL; +} + + +static lua_CFunction ll_sym (lua_State *L, void *lib, const char *sym) { + NSSymbol nss = NSLookupSymbolInModule((NSModule)lib, sym); + if (nss == NULL) { + lua_pushfstring(L, "symbol " LUA_QS " not found", sym); + return NULL; + } + return (lua_CFunction)NSAddressOfSymbol(nss); +} + +/* }====================================================== */ + + + +#else +/* +** {====================================================== +** Fallback for other systems +** ======================================================= +*/ + +#undef LIB_FAIL +#define LIB_FAIL "absent" + + +#define DLMSG "dynamic libraries not enabled; check your Lua installation" + + +static void ll_unloadlib (void *lib) { + (void)lib; /* to avoid warnings */ +} + + +static void *ll_load (lua_State *L, const char *path) { + (void)path; /* to avoid warnings */ + lua_pushliteral(L, DLMSG); + return NULL; +} + + +static lua_CFunction ll_sym (lua_State *L, void *lib, const char *sym) { + (void)lib; (void)sym; /* to avoid warnings */ + lua_pushliteral(L, DLMSG); + return NULL; +} + +/* }====================================================== */ +#endif + + + +static void **ll_register (lua_State *L, const char *path) { + void **plib; + lua_pushfstring(L, "%s%s", LIBPREFIX, path); + lua_gettable(L, LUA_REGISTRYINDEX); /* check library in registry? */ + if (!lua_isnil(L, -1)) /* is there an entry? */ + plib = (void **)lua_touserdata(L, -1); + else { /* no entry yet; create one */ + lua_pop(L, 1); + plib = (void **)lua_newuserdata(L, sizeof(const void *)); + *plib = NULL; + luaL_getmetatable(L, "_LOADLIB"); + lua_setmetatable(L, -2); + lua_pushfstring(L, "%s%s", LIBPREFIX, path); + lua_pushvalue(L, -2); + lua_settable(L, LUA_REGISTRYINDEX); + } + return plib; +} + + +/* +** __gc tag method: calls library's `ll_unloadlib' function with the lib +** handle +*/ +static int gctm (lua_State *L) { + void **lib = (void **)luaL_checkudata(L, 1, "_LOADLIB"); + if (*lib) ll_unloadlib(*lib); + *lib = NULL; /* mark library as closed */ + return 0; +} + + +static int ll_loadfunc (lua_State *L, const char *path, const char *sym) { + void **reg = ll_register(L, path); + if (*reg == NULL) *reg = ll_load(L, path); + if (*reg == NULL) + return ERRLIB; /* unable to load library */ + else { + lua_CFunction f = ll_sym(L, *reg, sym); + if (f == NULL) + return ERRFUNC; /* unable to find function */ + lua_pushcfunction(L, f); + return 0; /* return function */ + } +} + + +static int ll_loadlib (lua_State *L) { + const char *path = luaL_checkstring(L, 1); + const char *init = luaL_checkstring(L, 2); + int stat = ll_loadfunc(L, path, init); + if (stat == 0) /* no errors? */ + return 1; /* return the loaded function */ + else { /* error; error message is on stack top */ + lua_pushnil(L); + lua_insert(L, -2); + lua_pushstring(L, (stat == ERRLIB) ? LIB_FAIL : "init"); + return 3; /* return nil, error message, and where */ + } +} + + + +/* +** {====================================================== +** 'require' function +** ======================================================= +*/ + + +static int readable (const char *filename) { + FILE *f = fopen(filename, "r"); /* try to open file */ + if (f == NULL) return 0; /* open failed */ + fclose(f); + return 1; +} + + +static const char *pushnexttemplate (lua_State *L, const char *path) { + const char *l; + while (*path == *LUA_PATHSEP) path++; /* skip separators */ + if (*path == '\0') return NULL; /* no more templates */ + l = strchr(path, *LUA_PATHSEP); /* find next separator */ + if (l == NULL) l = path + strlen(path); + lua_pushlstring(L, path, l - path); /* template */ + return l; +} + + +static const char *findfile (lua_State *L, const char *name, + const char *pname) { + const char *path; + name = luaL_gsub(L, name, ".", LUA_DIRSEP); + lua_getfield(L, LUA_ENVIRONINDEX, pname); + path = lua_tostring(L, -1); + if (path == NULL) + luaL_error(L, LUA_QL("package.%s") " must be a string", pname); + lua_pushliteral(L, ""); /* error accumulator */ + while ((path = pushnexttemplate(L, path)) != NULL) { + const char *filename; + filename = luaL_gsub(L, lua_tostring(L, -1), LUA_PATH_MARK, name); + lua_remove(L, -2); /* remove path template */ + if (readable(filename)) /* does file exist and is readable? */ + return filename; /* return that file name */ + lua_pushfstring(L, "\n\tno file " LUA_QS, filename); + lua_remove(L, -2); /* remove file name */ + lua_concat(L, 2); /* add entry to possible error message */ + } + return NULL; /* not found */ +} + + +static void loaderror (lua_State *L, const char *filename) { + luaL_error(L, "error loading module " LUA_QS " from file " LUA_QS ":\n\t%s", + lua_tostring(L, 1), filename, lua_tostring(L, -1)); +} + + +static int loader_Lua (lua_State *L) { + const char *filename; + const char *name = luaL_checkstring(L, 1); + filename = findfile(L, name, "path"); + if (filename == NULL) return 1; /* library not found in this path */ + if (luaL_loadfile(L, filename) != 0) + loaderror(L, filename); + return 1; /* library loaded successfully */ +} + + +static const char *mkfuncname (lua_State *L, const char *modname) { + const char *funcname; + const char *mark = strchr(modname, *LUA_IGMARK); + if (mark) modname = mark + 1; + funcname = luaL_gsub(L, modname, ".", LUA_OFSEP); + funcname = lua_pushfstring(L, POF"%s", funcname); + lua_remove(L, -2); /* remove 'gsub' result */ + return funcname; +} + + +static int loader_C (lua_State *L) { + const char *funcname; + const char *name = luaL_checkstring(L, 1); + const char *filename = findfile(L, name, "cpath"); + if (filename == NULL) return 1; /* library not found in this path */ + funcname = mkfuncname(L, name); + if (ll_loadfunc(L, filename, funcname) != 0) + loaderror(L, filename); + return 1; /* library loaded successfully */ +} + + +static int loader_Croot (lua_State *L) { + const char *funcname; + const char *filename; + const char *name = luaL_checkstring(L, 1); + const char *p = strchr(name, '.'); + int stat; + if (p == NULL) return 0; /* is root */ + lua_pushlstring(L, name, p - name); + filename = findfile(L, lua_tostring(L, -1), "cpath"); + if (filename == NULL) return 1; /* root not found */ + funcname = mkfuncname(L, name); + if ((stat = ll_loadfunc(L, filename, funcname)) != 0) { + if (stat != ERRFUNC) loaderror(L, filename); /* real error */ + lua_pushfstring(L, "\n\tno module " LUA_QS " in file " LUA_QS, + name, filename); + return 1; /* function not found */ + } + return 1; +} + + +static int loader_preload (lua_State *L) { + const char *name = luaL_checkstring(L, 1); + lua_getfield(L, LUA_ENVIRONINDEX, "preload"); + if (!lua_istable(L, -1)) + luaL_error(L, LUA_QL("package.preload") " must be a table"); + lua_getfield(L, -1, name); + if (lua_isnil(L, -1)) /* not found? */ + lua_pushfstring(L, "\n\tno field package.preload['%s']", name); + return 1; +} + + +static const int sentinel_ = 0; +#define sentinel ((void *)&sentinel_) + + +static int ll_require (lua_State *L) { + const char *name = luaL_checkstring(L, 1); + int i; + lua_settop(L, 1); /* _LOADED table will be at index 2 */ + lua_getfield(L, LUA_REGISTRYINDEX, "_LOADED"); + lua_getfield(L, 2, name); + if (lua_toboolean(L, -1)) { /* is it there? */ + if (lua_touserdata(L, -1) == sentinel) /* check loops */ + luaL_error(L, "loop or previous error loading module " LUA_QS, name); + return 1; /* package is already loaded */ + } + /* else must load it; iterate over available loaders */ + lua_getfield(L, LUA_ENVIRONINDEX, "loaders"); + if (!lua_istable(L, -1)) + luaL_error(L, LUA_QL("package.loaders") " must be a table"); + lua_pushliteral(L, ""); /* error message accumulator */ + for (i=1; ; i++) { + lua_rawgeti(L, -2, i); /* get a loader */ + if (lua_isnil(L, -1)) + luaL_error(L, "module " LUA_QS " not found:%s", + name, lua_tostring(L, -2)); + lua_pushstring(L, name); + lua_call(L, 1, 1); /* call it */ + if (lua_isfunction(L, -1)) /* did it find module? */ + break; /* module loaded successfully */ + else if (lua_isstring(L, -1)) /* loader returned error message? */ + lua_concat(L, 2); /* accumulate it */ + else + lua_pop(L, 1); + } + lua_pushlightuserdata(L, sentinel); + lua_setfield(L, 2, name); /* _LOADED[name] = sentinel */ + lua_pushstring(L, name); /* pass name as argument to module */ + lua_call(L, 1, 1); /* run loaded module */ + if (!lua_isnil(L, -1)) /* non-nil return? */ + lua_setfield(L, 2, name); /* _LOADED[name] = returned value */ + lua_getfield(L, 2, name); + if (lua_touserdata(L, -1) == sentinel) { /* module did not set a value? */ + lua_pushboolean(L, 1); /* use true as result */ + lua_pushvalue(L, -1); /* extra copy to be returned */ + lua_setfield(L, 2, name); /* _LOADED[name] = true */ + } + return 1; +} + +/* }====================================================== */ + + + +/* +** {====================================================== +** 'module' function +** ======================================================= +*/ + + +static void setfenv (lua_State *L) { + lua_Debug ar; + if (lua_getstack(L, 1, &ar) == 0 || + lua_getinfo(L, "f", &ar) == 0 || /* get calling function */ + lua_iscfunction(L, -1)) + luaL_error(L, LUA_QL("module") " not called from a Lua function"); + lua_pushvalue(L, -2); + lua_setfenv(L, -2); + lua_pop(L, 1); +} + + +static void dooptions (lua_State *L, int n) { + int i; + for (i = 2; i <= n; i++) { + lua_pushvalue(L, i); /* get option (a function) */ + lua_pushvalue(L, -2); /* module */ + lua_call(L, 1, 0); + } +} + + +static void modinit (lua_State *L, const char *modname) { + const char *dot; + lua_pushvalue(L, -1); + lua_setfield(L, -2, "_M"); /* module._M = module */ + lua_pushstring(L, modname); + lua_setfield(L, -2, "_NAME"); + dot = strrchr(modname, '.'); /* look for last dot in module name */ + if (dot == NULL) dot = modname; + else dot++; + /* set _PACKAGE as package name (full module name minus last part) */ + lua_pushlstring(L, modname, dot - modname); + lua_setfield(L, -2, "_PACKAGE"); +} + + +static int ll_module (lua_State *L) { + const char *modname = luaL_checkstring(L, 1); + int loaded = lua_gettop(L) + 1; /* index of _LOADED table */ + lua_getfield(L, LUA_REGISTRYINDEX, "_LOADED"); + lua_getfield(L, loaded, modname); /* get _LOADED[modname] */ + if (!lua_istable(L, -1)) { /* not found? */ + lua_pop(L, 1); /* remove previous result */ + /* try global variable (and create one if it does not exist) */ + if (luaL_findtable(L, LUA_GLOBALSINDEX, modname, 1) != NULL) + return luaL_error(L, "name conflict for module " LUA_QS, modname); + lua_pushvalue(L, -1); + lua_setfield(L, loaded, modname); /* _LOADED[modname] = new table */ + } + /* check whether table already has a _NAME field */ + lua_getfield(L, -1, "_NAME"); + if (!lua_isnil(L, -1)) /* is table an initialized module? */ + lua_pop(L, 1); + else { /* no; initialize it */ + lua_pop(L, 1); + modinit(L, modname); + } + lua_pushvalue(L, -1); + setfenv(L); + dooptions(L, loaded - 1); + return 0; +} + + +static int ll_seeall (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (!lua_getmetatable(L, 1)) { + lua_createtable(L, 0, 1); /* create new metatable */ + lua_pushvalue(L, -1); + lua_setmetatable(L, 1); + } + lua_pushvalue(L, LUA_GLOBALSINDEX); + lua_setfield(L, -2, "__index"); /* mt.__index = _G */ + return 0; +} + + +/* }====================================================== */ + + + +/* auxiliary mark (for internal use) */ +#define AUXMARK "\1" + +static void setpath (lua_State *L, const char *fieldname, const char *envname, + const char *def) { + const char *path = getenv(envname); + if (path == NULL) /* no environment variable? */ + lua_pushstring(L, def); /* use default */ + else { + /* replace ";;" by ";AUXMARK;" and then AUXMARK by default path */ + path = luaL_gsub(L, path, LUA_PATHSEP LUA_PATHSEP, + LUA_PATHSEP AUXMARK LUA_PATHSEP); + luaL_gsub(L, path, AUXMARK, def); + lua_remove(L, -2); + } + setprogdir(L); + lua_setfield(L, -2, fieldname); +} + + +static const luaL_Reg pk_funcs[] = { + {"loadlib", ll_loadlib}, + {"seeall", ll_seeall}, + {NULL, NULL} +}; + + +static const luaL_Reg ll_funcs[] = { + {"module", ll_module}, + {"require", ll_require}, + {NULL, NULL} +}; + + +static const lua_CFunction loaders[] = + {loader_preload, loader_Lua, loader_C, loader_Croot, NULL}; + + +LUALIB_API int luaopen_package (lua_State *L) { + int i; + /* create new type _LOADLIB */ + luaL_newmetatable(L, "_LOADLIB"); + lua_pushcfunction(L, gctm); + lua_setfield(L, -2, "__gc"); + /* create `package' table */ + luaL_register(L, LUA_LOADLIBNAME, pk_funcs); +#if defined(LUA_COMPAT_LOADLIB) + lua_getfield(L, -1, "loadlib"); + lua_setfield(L, LUA_GLOBALSINDEX, "loadlib"); +#endif + lua_pushvalue(L, -1); + lua_replace(L, LUA_ENVIRONINDEX); + /* create `loaders' table */ + lua_createtable(L, sizeof(loaders)/sizeof(loaders[0]) - 1, 0); + /* fill it with pre-defined loaders */ + for (i=0; loaders[i] != NULL; i++) { + lua_pushcfunction(L, loaders[i]); + lua_rawseti(L, -2, i+1); + } + lua_setfield(L, -2, "loaders"); /* put it in field `loaders' */ + setpath(L, "path", LUA_PATH, LUA_PATH_DEFAULT); /* set field `path' */ + setpath(L, "cpath", LUA_CPATH, LUA_CPATH_DEFAULT); /* set field `cpath' */ + /* store config information */ + lua_pushliteral(L, LUA_DIRSEP "\n" LUA_PATHSEP "\n" LUA_PATH_MARK "\n" + LUA_EXECDIR "\n" LUA_IGMARK); + lua_setfield(L, -2, "config"); + /* set field `loaded' */ + luaL_findtable(L, LUA_REGISTRYINDEX, "_LOADED", 2); + lua_setfield(L, -2, "loaded"); + /* set field `preload' */ + lua_newtable(L); + lua_setfield(L, -2, "preload"); + lua_pushvalue(L, LUA_GLOBALSINDEX); + luaL_register(L, NULL, ll_funcs); /* open lib into global table */ + lua_pop(L, 1); + return 1; /* return 'package' table */ +} + diff --git a/extern/lua-5.1.5/src/lobject.c b/extern/lua-5.1.5/src/lobject.c new file mode 100644 index 00000000..4ff50732 --- /dev/null +++ b/extern/lua-5.1.5/src/lobject.c @@ -0,0 +1,214 @@ +/* +** $Id: lobject.c,v 2.22.1.1 2007/12/27 13:02:25 roberto Exp $ +** Some generic functions over Lua objects +** See Copyright Notice in lua.h +*/ + +#include +#include +#include +#include +#include + +#define lobject_c +#define LUA_CORE + +#include "lua.h" + +#include "ldo.h" +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" +#include "lstring.h" +#include "lvm.h" + + + +const TValue luaO_nilobject_ = {{NULL}, LUA_TNIL}; + + +/* +** converts an integer to a "floating point byte", represented as +** (eeeeexxx), where the real value is (1xxx) * 2^(eeeee - 1) if +** eeeee != 0 and (xxx) otherwise. +*/ +int luaO_int2fb (unsigned int x) { + int e = 0; /* expoent */ + while (x >= 16) { + x = (x+1) >> 1; + e++; + } + if (x < 8) return x; + else return ((e+1) << 3) | (cast_int(x) - 8); +} + + +/* converts back */ +int luaO_fb2int (int x) { + int e = (x >> 3) & 31; + if (e == 0) return x; + else return ((x & 7)+8) << (e - 1); +} + + +int luaO_log2 (unsigned int x) { + static const lu_byte log_2[256] = { + 0,1,2,2,3,3,3,3,4,4,4,4,4,4,4,4,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,5, + 6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,6, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, + 7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8, + 8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,8 + }; + int l = -1; + while (x >= 256) { l += 8; x >>= 8; } + return l + log_2[x]; + +} + + +int luaO_rawequalObj (const TValue *t1, const TValue *t2) { + if (ttype(t1) != ttype(t2)) return 0; + else switch (ttype(t1)) { + case LUA_TNIL: + return 1; + case LUA_TNUMBER: + return luai_numeq(nvalue(t1), nvalue(t2)); + case LUA_TBOOLEAN: + return bvalue(t1) == bvalue(t2); /* boolean true must be 1 !! */ + case LUA_TLIGHTUSERDATA: + return pvalue(t1) == pvalue(t2); + default: + lua_assert(iscollectable(t1)); + return gcvalue(t1) == gcvalue(t2); + } +} + + +int luaO_str2d (const char *s, lua_Number *result) { + char *endptr; + *result = lua_str2number(s, &endptr); + if (endptr == s) return 0; /* conversion failed */ + if (*endptr == 'x' || *endptr == 'X') /* maybe an hexadecimal constant? */ + *result = cast_num(strtoul(s, &endptr, 16)); + if (*endptr == '\0') return 1; /* most common case */ + while (isspace(cast(unsigned char, *endptr))) endptr++; + if (*endptr != '\0') return 0; /* invalid trailing characters? */ + return 1; +} + + + +static void pushstr (lua_State *L, const char *str) { + setsvalue2s(L, L->top, luaS_new(L, str)); + incr_top(L); +} + + +/* this function handles only `%d', `%c', %f, %p, and `%s' formats */ +const char *luaO_pushvfstring (lua_State *L, const char *fmt, va_list argp) { + int n = 1; + pushstr(L, ""); + for (;;) { + const char *e = strchr(fmt, '%'); + if (e == NULL) break; + setsvalue2s(L, L->top, luaS_newlstr(L, fmt, e-fmt)); + incr_top(L); + switch (*(e+1)) { + case 's': { + const char *s = va_arg(argp, char *); + if (s == NULL) s = "(null)"; + pushstr(L, s); + break; + } + case 'c': { + char buff[2]; + buff[0] = cast(char, va_arg(argp, int)); + buff[1] = '\0'; + pushstr(L, buff); + break; + } + case 'd': { + setnvalue(L->top, cast_num(va_arg(argp, int))); + incr_top(L); + break; + } + case 'f': { + setnvalue(L->top, cast_num(va_arg(argp, l_uacNumber))); + incr_top(L); + break; + } + case 'p': { + char buff[4*sizeof(void *) + 8]; /* should be enough space for a `%p' */ + sprintf(buff, "%p", va_arg(argp, void *)); + pushstr(L, buff); + break; + } + case '%': { + pushstr(L, "%"); + break; + } + default: { + char buff[3]; + buff[0] = '%'; + buff[1] = *(e+1); + buff[2] = '\0'; + pushstr(L, buff); + break; + } + } + n += 2; + fmt = e+2; + } + pushstr(L, fmt); + luaV_concat(L, n+1, cast_int(L->top - L->base) - 1); + L->top -= n; + return svalue(L->top - 1); +} + + +const char *luaO_pushfstring (lua_State *L, const char *fmt, ...) { + const char *msg; + va_list argp; + va_start(argp, fmt); + msg = luaO_pushvfstring(L, fmt, argp); + va_end(argp); + return msg; +} + + +void luaO_chunkid (char *out, const char *source, size_t bufflen) { + if (*source == '=') { + strncpy(out, source+1, bufflen); /* remove first char */ + out[bufflen-1] = '\0'; /* ensures null termination */ + } + else { /* out = "source", or "...source" */ + if (*source == '@') { + size_t l; + source++; /* skip the `@' */ + bufflen -= sizeof(" '...' "); + l = strlen(source); + strcpy(out, ""); + if (l > bufflen) { + source += (l-bufflen); /* get last part of file name */ + strcat(out, "..."); + } + strcat(out, source); + } + else { /* out = [string "string"] */ + size_t len = strcspn(source, "\n\r"); /* stop at first newline */ + bufflen -= sizeof(" [string \"...\"] "); + if (len > bufflen) len = bufflen; + strcpy(out, "[string \""); + if (source[len] != '\0') { /* must truncate? */ + strncat(out, source, len); + strcat(out, "..."); + } + else + strcat(out, source); + strcat(out, "\"]"); + } + } +} diff --git a/extern/lua-5.1.5/src/lobject.h b/extern/lua-5.1.5/src/lobject.h new file mode 100644 index 00000000..f1e447ef --- /dev/null +++ b/extern/lua-5.1.5/src/lobject.h @@ -0,0 +1,381 @@ +/* +** $Id: lobject.h,v 2.20.1.2 2008/08/06 13:29:48 roberto Exp $ +** Type definitions for Lua objects +** See Copyright Notice in lua.h +*/ + + +#ifndef lobject_h +#define lobject_h + + +#include + + +#include "llimits.h" +#include "lua.h" + + +/* tags for values visible from Lua */ +#define LAST_TAG LUA_TTHREAD + +#define NUM_TAGS (LAST_TAG+1) + + +/* +** Extra tags for non-values +*/ +#define LUA_TPROTO (LAST_TAG+1) +#define LUA_TUPVAL (LAST_TAG+2) +#define LUA_TDEADKEY (LAST_TAG+3) + + +/* +** Union of all collectable objects +*/ +typedef union GCObject GCObject; + + +/* +** Common Header for all collectable objects (in macro form, to be +** included in other objects) +*/ +#define CommonHeader GCObject *next; lu_byte tt; lu_byte marked + + +/* +** Common header in struct form +*/ +typedef struct GCheader { + CommonHeader; +} GCheader; + + + + +/* +** Union of all Lua values +*/ +typedef union { + GCObject *gc; + void *p; + lua_Number n; + int b; +} Value; + + +/* +** Tagged Values +*/ + +#define TValuefields Value value; int tt + +typedef struct lua_TValue { + TValuefields; +} TValue; + + +/* Macros to test type */ +#define ttisnil(o) (ttype(o) == LUA_TNIL) +#define ttisnumber(o) (ttype(o) == LUA_TNUMBER) +#define ttisstring(o) (ttype(o) == LUA_TSTRING) +#define ttistable(o) (ttype(o) == LUA_TTABLE) +#define ttisfunction(o) (ttype(o) == LUA_TFUNCTION) +#define ttisboolean(o) (ttype(o) == LUA_TBOOLEAN) +#define ttisuserdata(o) (ttype(o) == LUA_TUSERDATA) +#define ttisthread(o) (ttype(o) == LUA_TTHREAD) +#define ttislightuserdata(o) (ttype(o) == LUA_TLIGHTUSERDATA) + +/* Macros to access values */ +#define ttype(o) ((o)->tt) +#define gcvalue(o) check_exp(iscollectable(o), (o)->value.gc) +#define pvalue(o) check_exp(ttislightuserdata(o), (o)->value.p) +#define nvalue(o) check_exp(ttisnumber(o), (o)->value.n) +#define rawtsvalue(o) check_exp(ttisstring(o), &(o)->value.gc->ts) +#define tsvalue(o) (&rawtsvalue(o)->tsv) +#define rawuvalue(o) check_exp(ttisuserdata(o), &(o)->value.gc->u) +#define uvalue(o) (&rawuvalue(o)->uv) +#define clvalue(o) check_exp(ttisfunction(o), &(o)->value.gc->cl) +#define hvalue(o) check_exp(ttistable(o), &(o)->value.gc->h) +#define bvalue(o) check_exp(ttisboolean(o), (o)->value.b) +#define thvalue(o) check_exp(ttisthread(o), &(o)->value.gc->th) + +#define l_isfalse(o) (ttisnil(o) || (ttisboolean(o) && bvalue(o) == 0)) + +/* +** for internal debug only +*/ +#define checkconsistency(obj) \ + lua_assert(!iscollectable(obj) || (ttype(obj) == (obj)->value.gc->gch.tt)) + +#define checkliveness(g,obj) \ + lua_assert(!iscollectable(obj) || \ + ((ttype(obj) == (obj)->value.gc->gch.tt) && !isdead(g, (obj)->value.gc))) + + +/* Macros to set values */ +#define setnilvalue(obj) ((obj)->tt=LUA_TNIL) + +#define setnvalue(obj,x) \ + { TValue *i_o=(obj); i_o->value.n=(x); i_o->tt=LUA_TNUMBER; } + +#define setpvalue(obj,x) \ + { TValue *i_o=(obj); i_o->value.p=(x); i_o->tt=LUA_TLIGHTUSERDATA; } + +#define setbvalue(obj,x) \ + { TValue *i_o=(obj); i_o->value.b=(x); i_o->tt=LUA_TBOOLEAN; } + +#define setsvalue(L,obj,x) \ + { TValue *i_o=(obj); \ + i_o->value.gc=cast(GCObject *, (x)); i_o->tt=LUA_TSTRING; \ + checkliveness(G(L),i_o); } + +#define setuvalue(L,obj,x) \ + { TValue *i_o=(obj); \ + i_o->value.gc=cast(GCObject *, (x)); i_o->tt=LUA_TUSERDATA; \ + checkliveness(G(L),i_o); } + +#define setthvalue(L,obj,x) \ + { TValue *i_o=(obj); \ + i_o->value.gc=cast(GCObject *, (x)); i_o->tt=LUA_TTHREAD; \ + checkliveness(G(L),i_o); } + +#define setclvalue(L,obj,x) \ + { TValue *i_o=(obj); \ + i_o->value.gc=cast(GCObject *, (x)); i_o->tt=LUA_TFUNCTION; \ + checkliveness(G(L),i_o); } + +#define sethvalue(L,obj,x) \ + { TValue *i_o=(obj); \ + i_o->value.gc=cast(GCObject *, (x)); i_o->tt=LUA_TTABLE; \ + checkliveness(G(L),i_o); } + +#define setptvalue(L,obj,x) \ + { TValue *i_o=(obj); \ + i_o->value.gc=cast(GCObject *, (x)); i_o->tt=LUA_TPROTO; \ + checkliveness(G(L),i_o); } + + + + +#define setobj(L,obj1,obj2) \ + { const TValue *o2=(obj2); TValue *o1=(obj1); \ + o1->value = o2->value; o1->tt=o2->tt; \ + checkliveness(G(L),o1); } + + +/* +** different types of sets, according to destination +*/ + +/* from stack to (same) stack */ +#define setobjs2s setobj +/* to stack (not from same stack) */ +#define setobj2s setobj +#define setsvalue2s setsvalue +#define sethvalue2s sethvalue +#define setptvalue2s setptvalue +/* from table to same table */ +#define setobjt2t setobj +/* to table */ +#define setobj2t setobj +/* to new object */ +#define setobj2n setobj +#define setsvalue2n setsvalue + +#define setttype(obj, tt) (ttype(obj) = (tt)) + + +#define iscollectable(o) (ttype(o) >= LUA_TSTRING) + + + +typedef TValue *StkId; /* index to stack elements */ + + +/* +** String headers for string table +*/ +typedef union TString { + L_Umaxalign dummy; /* ensures maximum alignment for strings */ + struct { + CommonHeader; + lu_byte reserved; + unsigned int hash; + size_t len; + } tsv; +} TString; + + +#define getstr(ts) cast(const char *, (ts) + 1) +#define svalue(o) getstr(rawtsvalue(o)) + + + +typedef union Udata { + L_Umaxalign dummy; /* ensures maximum alignment for `local' udata */ + struct { + CommonHeader; + struct Table *metatable; + struct Table *env; + size_t len; + } uv; +} Udata; + + + + +/* +** Function Prototypes +*/ +typedef struct Proto { + CommonHeader; + TValue *k; /* constants used by the function */ + Instruction *code; + struct Proto **p; /* functions defined inside the function */ + int *lineinfo; /* map from opcodes to source lines */ + struct LocVar *locvars; /* information about local variables */ + TString **upvalues; /* upvalue names */ + TString *source; + int sizeupvalues; + int sizek; /* size of `k' */ + int sizecode; + int sizelineinfo; + int sizep; /* size of `p' */ + int sizelocvars; + int linedefined; + int lastlinedefined; + GCObject *gclist; + lu_byte nups; /* number of upvalues */ + lu_byte numparams; + lu_byte is_vararg; + lu_byte maxstacksize; +} Proto; + + +/* masks for new-style vararg */ +#define VARARG_HASARG 1 +#define VARARG_ISVARARG 2 +#define VARARG_NEEDSARG 4 + + +typedef struct LocVar { + TString *varname; + int startpc; /* first point where variable is active */ + int endpc; /* first point where variable is dead */ +} LocVar; + + + +/* +** Upvalues +*/ + +typedef struct UpVal { + CommonHeader; + TValue *v; /* points to stack or to its own value */ + union { + TValue value; /* the value (when closed) */ + struct { /* double linked list (when open) */ + struct UpVal *prev; + struct UpVal *next; + } l; + } u; +} UpVal; + + +/* +** Closures +*/ + +#define ClosureHeader \ + CommonHeader; lu_byte isC; lu_byte nupvalues; GCObject *gclist; \ + struct Table *env + +typedef struct CClosure { + ClosureHeader; + lua_CFunction f; + TValue upvalue[1]; +} CClosure; + + +typedef struct LClosure { + ClosureHeader; + struct Proto *p; + UpVal *upvals[1]; +} LClosure; + + +typedef union Closure { + CClosure c; + LClosure l; +} Closure; + + +#define iscfunction(o) (ttype(o) == LUA_TFUNCTION && clvalue(o)->c.isC) +#define isLfunction(o) (ttype(o) == LUA_TFUNCTION && !clvalue(o)->c.isC) + + +/* +** Tables +*/ + +typedef union TKey { + struct { + TValuefields; + struct Node *next; /* for chaining */ + } nk; + TValue tvk; +} TKey; + + +typedef struct Node { + TValue i_val; + TKey i_key; +} Node; + + +typedef struct Table { + CommonHeader; + lu_byte flags; /* 1<

lsizenode)) + + +#define luaO_nilobject (&luaO_nilobject_) + +LUAI_DATA const TValue luaO_nilobject_; + +#define ceillog2(x) (luaO_log2((x)-1) + 1) + +LUAI_FUNC int luaO_log2 (unsigned int x); +LUAI_FUNC int luaO_int2fb (unsigned int x); +LUAI_FUNC int luaO_fb2int (int x); +LUAI_FUNC int luaO_rawequalObj (const TValue *t1, const TValue *t2); +LUAI_FUNC int luaO_str2d (const char *s, lua_Number *result); +LUAI_FUNC const char *luaO_pushvfstring (lua_State *L, const char *fmt, + va_list argp); +LUAI_FUNC const char *luaO_pushfstring (lua_State *L, const char *fmt, ...); +LUAI_FUNC void luaO_chunkid (char *out, const char *source, size_t len); + + +#endif + diff --git a/extern/lua-5.1.5/src/lopcodes.c b/extern/lua-5.1.5/src/lopcodes.c new file mode 100644 index 00000000..4cc74523 --- /dev/null +++ b/extern/lua-5.1.5/src/lopcodes.c @@ -0,0 +1,102 @@ +/* +** $Id: lopcodes.c,v 1.37.1.1 2007/12/27 13:02:25 roberto Exp $ +** See Copyright Notice in lua.h +*/ + + +#define lopcodes_c +#define LUA_CORE + + +#include "lopcodes.h" + + +/* ORDER OP */ + +const char *const luaP_opnames[NUM_OPCODES+1] = { + "MOVE", + "LOADK", + "LOADBOOL", + "LOADNIL", + "GETUPVAL", + "GETGLOBAL", + "GETTABLE", + "SETGLOBAL", + "SETUPVAL", + "SETTABLE", + "NEWTABLE", + "SELF", + "ADD", + "SUB", + "MUL", + "DIV", + "MOD", + "POW", + "UNM", + "NOT", + "LEN", + "CONCAT", + "JMP", + "EQ", + "LT", + "LE", + "TEST", + "TESTSET", + "CALL", + "TAILCALL", + "RETURN", + "FORLOOP", + "FORPREP", + "TFORLOOP", + "SETLIST", + "CLOSE", + "CLOSURE", + "VARARG", + NULL +}; + + +#define opmode(t,a,b,c,m) (((t)<<7) | ((a)<<6) | ((b)<<4) | ((c)<<2) | (m)) + +const lu_byte luaP_opmodes[NUM_OPCODES] = { +/* T A B C mode opcode */ + opmode(0, 1, OpArgR, OpArgN, iABC) /* OP_MOVE */ + ,opmode(0, 1, OpArgK, OpArgN, iABx) /* OP_LOADK */ + ,opmode(0, 1, OpArgU, OpArgU, iABC) /* OP_LOADBOOL */ + ,opmode(0, 1, OpArgR, OpArgN, iABC) /* OP_LOADNIL */ + ,opmode(0, 1, OpArgU, OpArgN, iABC) /* OP_GETUPVAL */ + ,opmode(0, 1, OpArgK, OpArgN, iABx) /* OP_GETGLOBAL */ + ,opmode(0, 1, OpArgR, OpArgK, iABC) /* OP_GETTABLE */ + ,opmode(0, 0, OpArgK, OpArgN, iABx) /* OP_SETGLOBAL */ + ,opmode(0, 0, OpArgU, OpArgN, iABC) /* OP_SETUPVAL */ + ,opmode(0, 0, OpArgK, OpArgK, iABC) /* OP_SETTABLE */ + ,opmode(0, 1, OpArgU, OpArgU, iABC) /* OP_NEWTABLE */ + ,opmode(0, 1, OpArgR, OpArgK, iABC) /* OP_SELF */ + ,opmode(0, 1, OpArgK, OpArgK, iABC) /* OP_ADD */ + ,opmode(0, 1, OpArgK, OpArgK, iABC) /* OP_SUB */ + ,opmode(0, 1, OpArgK, OpArgK, iABC) /* OP_MUL */ + ,opmode(0, 1, OpArgK, OpArgK, iABC) /* OP_DIV */ + ,opmode(0, 1, OpArgK, OpArgK, iABC) /* OP_MOD */ + ,opmode(0, 1, OpArgK, OpArgK, iABC) /* OP_POW */ + ,opmode(0, 1, OpArgR, OpArgN, iABC) /* OP_UNM */ + ,opmode(0, 1, OpArgR, OpArgN, iABC) /* OP_NOT */ + ,opmode(0, 1, OpArgR, OpArgN, iABC) /* OP_LEN */ + ,opmode(0, 1, OpArgR, OpArgR, iABC) /* OP_CONCAT */ + ,opmode(0, 0, OpArgR, OpArgN, iAsBx) /* OP_JMP */ + ,opmode(1, 0, OpArgK, OpArgK, iABC) /* OP_EQ */ + ,opmode(1, 0, OpArgK, OpArgK, iABC) /* OP_LT */ + ,opmode(1, 0, OpArgK, OpArgK, iABC) /* OP_LE */ + ,opmode(1, 1, OpArgR, OpArgU, iABC) /* OP_TEST */ + ,opmode(1, 1, OpArgR, OpArgU, iABC) /* OP_TESTSET */ + ,opmode(0, 1, OpArgU, OpArgU, iABC) /* OP_CALL */ + ,opmode(0, 1, OpArgU, OpArgU, iABC) /* OP_TAILCALL */ + ,opmode(0, 0, OpArgU, OpArgN, iABC) /* OP_RETURN */ + ,opmode(0, 1, OpArgR, OpArgN, iAsBx) /* OP_FORLOOP */ + ,opmode(0, 1, OpArgR, OpArgN, iAsBx) /* OP_FORPREP */ + ,opmode(1, 0, OpArgN, OpArgU, iABC) /* OP_TFORLOOP */ + ,opmode(0, 0, OpArgU, OpArgU, iABC) /* OP_SETLIST */ + ,opmode(0, 0, OpArgN, OpArgN, iABC) /* OP_CLOSE */ + ,opmode(0, 1, OpArgU, OpArgN, iABx) /* OP_CLOSURE */ + ,opmode(0, 1, OpArgU, OpArgN, iABC) /* OP_VARARG */ +}; + diff --git a/extern/lua-5.1.5/src/lopcodes.h b/extern/lua-5.1.5/src/lopcodes.h new file mode 100644 index 00000000..41224d6e --- /dev/null +++ b/extern/lua-5.1.5/src/lopcodes.h @@ -0,0 +1,268 @@ +/* +** $Id: lopcodes.h,v 1.125.1.1 2007/12/27 13:02:25 roberto Exp $ +** Opcodes for Lua virtual machine +** See Copyright Notice in lua.h +*/ + +#ifndef lopcodes_h +#define lopcodes_h + +#include "llimits.h" + + +/*=========================================================================== + We assume that instructions are unsigned numbers. + All instructions have an opcode in the first 6 bits. + Instructions can have the following fields: + `A' : 8 bits + `B' : 9 bits + `C' : 9 bits + `Bx' : 18 bits (`B' and `C' together) + `sBx' : signed Bx + + A signed argument is represented in excess K; that is, the number + value is the unsigned value minus K. K is exactly the maximum value + for that argument (so that -max is represented by 0, and +max is + represented by 2*max), which is half the maximum for the corresponding + unsigned argument. +===========================================================================*/ + + +enum OpMode {iABC, iABx, iAsBx}; /* basic instruction format */ + + +/* +** size and position of opcode arguments. +*/ +#define SIZE_C 9 +#define SIZE_B 9 +#define SIZE_Bx (SIZE_C + SIZE_B) +#define SIZE_A 8 + +#define SIZE_OP 6 + +#define POS_OP 0 +#define POS_A (POS_OP + SIZE_OP) +#define POS_C (POS_A + SIZE_A) +#define POS_B (POS_C + SIZE_C) +#define POS_Bx POS_C + + +/* +** limits for opcode arguments. +** we use (signed) int to manipulate most arguments, +** so they must fit in LUAI_BITSINT-1 bits (-1 for sign) +*/ +#if SIZE_Bx < LUAI_BITSINT-1 +#define MAXARG_Bx ((1<>1) /* `sBx' is signed */ +#else +#define MAXARG_Bx MAX_INT +#define MAXARG_sBx MAX_INT +#endif + + +#define MAXARG_A ((1<>POS_OP) & MASK1(SIZE_OP,0))) +#define SET_OPCODE(i,o) ((i) = (((i)&MASK0(SIZE_OP,POS_OP)) | \ + ((cast(Instruction, o)<>POS_A) & MASK1(SIZE_A,0))) +#define SETARG_A(i,u) ((i) = (((i)&MASK0(SIZE_A,POS_A)) | \ + ((cast(Instruction, u)<>POS_B) & MASK1(SIZE_B,0))) +#define SETARG_B(i,b) ((i) = (((i)&MASK0(SIZE_B,POS_B)) | \ + ((cast(Instruction, b)<>POS_C) & MASK1(SIZE_C,0))) +#define SETARG_C(i,b) ((i) = (((i)&MASK0(SIZE_C,POS_C)) | \ + ((cast(Instruction, b)<>POS_Bx) & MASK1(SIZE_Bx,0))) +#define SETARG_Bx(i,b) ((i) = (((i)&MASK0(SIZE_Bx,POS_Bx)) | \ + ((cast(Instruction, b)< C) then pc++ */ +OP_TESTSET,/* A B C if (R(B) <=> C) then R(A) := R(B) else pc++ */ + +OP_CALL,/* A B C R(A), ... ,R(A+C-2) := R(A)(R(A+1), ... ,R(A+B-1)) */ +OP_TAILCALL,/* A B C return R(A)(R(A+1), ... ,R(A+B-1)) */ +OP_RETURN,/* A B return R(A), ... ,R(A+B-2) (see note) */ + +OP_FORLOOP,/* A sBx R(A)+=R(A+2); + if R(A) =) R(A)*/ +OP_CLOSURE,/* A Bx R(A) := closure(KPROTO[Bx], R(A), ... ,R(A+n)) */ + +OP_VARARG/* A B R(A), R(A+1), ..., R(A+B-1) = vararg */ +} OpCode; + + +#define NUM_OPCODES (cast(int, OP_VARARG) + 1) + + + +/*=========================================================================== + Notes: + (*) In OP_CALL, if (B == 0) then B = top. C is the number of returns - 1, + and can be 0: OP_CALL then sets `top' to last_result+1, so + next open instruction (OP_CALL, OP_RETURN, OP_SETLIST) may use `top'. + + (*) In OP_VARARG, if (B == 0) then use actual number of varargs and + set top (like in OP_CALL with C == 0). + + (*) In OP_RETURN, if (B == 0) then return up to `top' + + (*) In OP_SETLIST, if (B == 0) then B = `top'; + if (C == 0) then next `instruction' is real C + + (*) For comparisons, A specifies what condition the test should accept + (true or false). + + (*) All `skips' (pc++) assume that next instruction is a jump +===========================================================================*/ + + +/* +** masks for instruction properties. The format is: +** bits 0-1: op mode +** bits 2-3: C arg mode +** bits 4-5: B arg mode +** bit 6: instruction set register A +** bit 7: operator is a test +*/ + +enum OpArgMask { + OpArgN, /* argument is not used */ + OpArgU, /* argument is used */ + OpArgR, /* argument is a register or a jump offset */ + OpArgK /* argument is a constant or register/constant */ +}; + +LUAI_DATA const lu_byte luaP_opmodes[NUM_OPCODES]; + +#define getOpMode(m) (cast(enum OpMode, luaP_opmodes[m] & 3)) +#define getBMode(m) (cast(enum OpArgMask, (luaP_opmodes[m] >> 4) & 3)) +#define getCMode(m) (cast(enum OpArgMask, (luaP_opmodes[m] >> 2) & 3)) +#define testAMode(m) (luaP_opmodes[m] & (1 << 6)) +#define testTMode(m) (luaP_opmodes[m] & (1 << 7)) + + +LUAI_DATA const char *const luaP_opnames[NUM_OPCODES+1]; /* opcode names */ + + +/* number of list items to accumulate before a SETLIST instruction */ +#define LFIELDS_PER_FLUSH 50 + + +#endif diff --git a/extern/lua-5.1.5/src/loslib.c b/extern/lua-5.1.5/src/loslib.c new file mode 100644 index 00000000..da06a572 --- /dev/null +++ b/extern/lua-5.1.5/src/loslib.c @@ -0,0 +1,243 @@ +/* +** $Id: loslib.c,v 1.19.1.3 2008/01/18 16:38:18 roberto Exp $ +** Standard Operating System library +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include +#include +#include + +#define loslib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + +static int os_pushresult (lua_State *L, int i, const char *filename) { + int en = errno; /* calls to Lua API may change this value */ + if (i) { + lua_pushboolean(L, 1); + return 1; + } + else { + lua_pushnil(L); + lua_pushfstring(L, "%s: %s", filename, strerror(en)); + lua_pushinteger(L, en); + return 3; + } +} + + +static int os_execute (lua_State *L) { + lua_pushinteger(L, system(luaL_optstring(L, 1, NULL))); + return 1; +} + + +static int os_remove (lua_State *L) { + const char *filename = luaL_checkstring(L, 1); + return os_pushresult(L, remove(filename) == 0, filename); +} + + +static int os_rename (lua_State *L) { + const char *fromname = luaL_checkstring(L, 1); + const char *toname = luaL_checkstring(L, 2); + return os_pushresult(L, rename(fromname, toname) == 0, fromname); +} + + +static int os_tmpname (lua_State *L) { + char buff[LUA_TMPNAMBUFSIZE]; + int err; + lua_tmpnam(buff, err); + if (err) + return luaL_error(L, "unable to generate a unique filename"); + lua_pushstring(L, buff); + return 1; +} + + +static int os_getenv (lua_State *L) { + lua_pushstring(L, getenv(luaL_checkstring(L, 1))); /* if NULL push nil */ + return 1; +} + + +static int os_clock (lua_State *L) { + lua_pushnumber(L, ((lua_Number)clock())/(lua_Number)CLOCKS_PER_SEC); + return 1; +} + + +/* +** {====================================================== +** Time/Date operations +** { year=%Y, month=%m, day=%d, hour=%H, min=%M, sec=%S, +** wday=%w+1, yday=%j, isdst=? } +** ======================================================= +*/ + +static void setfield (lua_State *L, const char *key, int value) { + lua_pushinteger(L, value); + lua_setfield(L, -2, key); +} + +static void setboolfield (lua_State *L, const char *key, int value) { + if (value < 0) /* undefined? */ + return; /* does not set field */ + lua_pushboolean(L, value); + lua_setfield(L, -2, key); +} + +static int getboolfield (lua_State *L, const char *key) { + int res; + lua_getfield(L, -1, key); + res = lua_isnil(L, -1) ? -1 : lua_toboolean(L, -1); + lua_pop(L, 1); + return res; +} + + +static int getfield (lua_State *L, const char *key, int d) { + int res; + lua_getfield(L, -1, key); + if (lua_isnumber(L, -1)) + res = (int)lua_tointeger(L, -1); + else { + if (d < 0) + return luaL_error(L, "field " LUA_QS " missing in date table", key); + res = d; + } + lua_pop(L, 1); + return res; +} + + +static int os_date (lua_State *L) { + const char *s = luaL_optstring(L, 1, "%c"); + time_t t = luaL_opt(L, (time_t)luaL_checknumber, 2, time(NULL)); + struct tm *stm; + if (*s == '!') { /* UTC? */ + stm = gmtime(&t); + s++; /* skip `!' */ + } + else + stm = localtime(&t); + if (stm == NULL) /* invalid date? */ + lua_pushnil(L); + else if (strcmp(s, "*t") == 0) { + lua_createtable(L, 0, 9); /* 9 = number of fields */ + setfield(L, "sec", stm->tm_sec); + setfield(L, "min", stm->tm_min); + setfield(L, "hour", stm->tm_hour); + setfield(L, "day", stm->tm_mday); + setfield(L, "month", stm->tm_mon+1); + setfield(L, "year", stm->tm_year+1900); + setfield(L, "wday", stm->tm_wday+1); + setfield(L, "yday", stm->tm_yday+1); + setboolfield(L, "isdst", stm->tm_isdst); + } + else { + char cc[3]; + luaL_Buffer b; + cc[0] = '%'; cc[2] = '\0'; + luaL_buffinit(L, &b); + for (; *s; s++) { + if (*s != '%' || *(s + 1) == '\0') /* no conversion specifier? */ + luaL_addchar(&b, *s); + else { + size_t reslen; + char buff[200]; /* should be big enough for any conversion result */ + cc[1] = *(++s); + reslen = strftime(buff, sizeof(buff), cc, stm); + luaL_addlstring(&b, buff, reslen); + } + } + luaL_pushresult(&b); + } + return 1; +} + + +static int os_time (lua_State *L) { + time_t t; + if (lua_isnoneornil(L, 1)) /* called without args? */ + t = time(NULL); /* get current time */ + else { + struct tm ts; + luaL_checktype(L, 1, LUA_TTABLE); + lua_settop(L, 1); /* make sure table is at the top */ + ts.tm_sec = getfield(L, "sec", 0); + ts.tm_min = getfield(L, "min", 0); + ts.tm_hour = getfield(L, "hour", 12); + ts.tm_mday = getfield(L, "day", -1); + ts.tm_mon = getfield(L, "month", -1) - 1; + ts.tm_year = getfield(L, "year", -1) - 1900; + ts.tm_isdst = getboolfield(L, "isdst"); + t = mktime(&ts); + } + if (t == (time_t)(-1)) + lua_pushnil(L); + else + lua_pushnumber(L, (lua_Number)t); + return 1; +} + + +static int os_difftime (lua_State *L) { + lua_pushnumber(L, difftime((time_t)(luaL_checknumber(L, 1)), + (time_t)(luaL_optnumber(L, 2, 0)))); + return 1; +} + +/* }====================================================== */ + + +static int os_setlocale (lua_State *L) { + static const int cat[] = {LC_ALL, LC_COLLATE, LC_CTYPE, LC_MONETARY, + LC_NUMERIC, LC_TIME}; + static const char *const catnames[] = {"all", "collate", "ctype", "monetary", + "numeric", "time", NULL}; + const char *l = luaL_optstring(L, 1, NULL); + int op = luaL_checkoption(L, 2, "all", catnames); + lua_pushstring(L, setlocale(cat[op], l)); + return 1; +} + + +static int os_exit (lua_State *L) { + exit(luaL_optint(L, 1, EXIT_SUCCESS)); +} + +static const luaL_Reg syslib[] = { + {"clock", os_clock}, + {"date", os_date}, + {"difftime", os_difftime}, + {"execute", os_execute}, + {"exit", os_exit}, + {"getenv", os_getenv}, + {"remove", os_remove}, + {"rename", os_rename}, + {"setlocale", os_setlocale}, + {"time", os_time}, + {"tmpname", os_tmpname}, + {NULL, NULL} +}; + +/* }====================================================== */ + + + +LUALIB_API int luaopen_os (lua_State *L) { + luaL_register(L, LUA_OSLIBNAME, syslib); + return 1; +} + diff --git a/extern/lua-5.1.5/src/lparser.c b/extern/lua-5.1.5/src/lparser.c new file mode 100644 index 00000000..dda7488d --- /dev/null +++ b/extern/lua-5.1.5/src/lparser.c @@ -0,0 +1,1339 @@ +/* +** $Id: lparser.c,v 2.42.1.4 2011/10/21 19:31:42 roberto Exp $ +** Lua Parser +** See Copyright Notice in lua.h +*/ + + +#include + +#define lparser_c +#define LUA_CORE + +#include "lua.h" + +#include "lcode.h" +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "llex.h" +#include "lmem.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lparser.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" + + + +#define hasmultret(k) ((k) == VCALL || (k) == VVARARG) + +#define getlocvar(fs, i) ((fs)->f->locvars[(fs)->actvar[i]]) + +#define luaY_checklimit(fs,v,l,m) if ((v)>(l)) errorlimit(fs,l,m) + + +/* +** nodes for block list (list of active blocks) +*/ +typedef struct BlockCnt { + struct BlockCnt *previous; /* chain */ + int breaklist; /* list of jumps out of this loop */ + lu_byte nactvar; /* # active locals outside the breakable structure */ + lu_byte upval; /* true if some variable in the block is an upvalue */ + lu_byte isbreakable; /* true if `block' is a loop */ +} BlockCnt; + + + +/* +** prototypes for recursive non-terminal functions +*/ +static void chunk (LexState *ls); +static void expr (LexState *ls, expdesc *v); + + +static void anchor_token (LexState *ls) { + if (ls->t.token == TK_NAME || ls->t.token == TK_STRING) { + TString *ts = ls->t.seminfo.ts; + luaX_newstring(ls, getstr(ts), ts->tsv.len); + } +} + + +static void error_expected (LexState *ls, int token) { + luaX_syntaxerror(ls, + luaO_pushfstring(ls->L, LUA_QS " expected", luaX_token2str(ls, token))); +} + + +static void errorlimit (FuncState *fs, int limit, const char *what) { + const char *msg = (fs->f->linedefined == 0) ? + luaO_pushfstring(fs->L, "main function has more than %d %s", limit, what) : + luaO_pushfstring(fs->L, "function at line %d has more than %d %s", + fs->f->linedefined, limit, what); + luaX_lexerror(fs->ls, msg, 0); +} + + +static int testnext (LexState *ls, int c) { + if (ls->t.token == c) { + luaX_next(ls); + return 1; + } + else return 0; +} + + +static void check (LexState *ls, int c) { + if (ls->t.token != c) + error_expected(ls, c); +} + +static void checknext (LexState *ls, int c) { + check(ls, c); + luaX_next(ls); +} + + +#define check_condition(ls,c,msg) { if (!(c)) luaX_syntaxerror(ls, msg); } + + + +static void check_match (LexState *ls, int what, int who, int where) { + if (!testnext(ls, what)) { + if (where == ls->linenumber) + error_expected(ls, what); + else { + luaX_syntaxerror(ls, luaO_pushfstring(ls->L, + LUA_QS " expected (to close " LUA_QS " at line %d)", + luaX_token2str(ls, what), luaX_token2str(ls, who), where)); + } + } +} + + +static TString *str_checkname (LexState *ls) { + TString *ts; + check(ls, TK_NAME); + ts = ls->t.seminfo.ts; + luaX_next(ls); + return ts; +} + + +static void init_exp (expdesc *e, expkind k, int i) { + e->f = e->t = NO_JUMP; + e->k = k; + e->u.s.info = i; +} + + +static void codestring (LexState *ls, expdesc *e, TString *s) { + init_exp(e, VK, luaK_stringK(ls->fs, s)); +} + + +static void checkname(LexState *ls, expdesc *e) { + codestring(ls, e, str_checkname(ls)); +} + + +static int registerlocalvar (LexState *ls, TString *varname) { + FuncState *fs = ls->fs; + Proto *f = fs->f; + int oldsize = f->sizelocvars; + luaM_growvector(ls->L, f->locvars, fs->nlocvars, f->sizelocvars, + LocVar, SHRT_MAX, "too many local variables"); + while (oldsize < f->sizelocvars) f->locvars[oldsize++].varname = NULL; + f->locvars[fs->nlocvars].varname = varname; + luaC_objbarrier(ls->L, f, varname); + return fs->nlocvars++; +} + + +#define new_localvarliteral(ls,v,n) \ + new_localvar(ls, luaX_newstring(ls, "" v, (sizeof(v)/sizeof(char))-1), n) + + +static void new_localvar (LexState *ls, TString *name, int n) { + FuncState *fs = ls->fs; + luaY_checklimit(fs, fs->nactvar+n+1, LUAI_MAXVARS, "local variables"); + fs->actvar[fs->nactvar+n] = cast(unsigned short, registerlocalvar(ls, name)); +} + + +static void adjustlocalvars (LexState *ls, int nvars) { + FuncState *fs = ls->fs; + fs->nactvar = cast_byte(fs->nactvar + nvars); + for (; nvars; nvars--) { + getlocvar(fs, fs->nactvar - nvars).startpc = fs->pc; + } +} + + +static void removevars (LexState *ls, int tolevel) { + FuncState *fs = ls->fs; + while (fs->nactvar > tolevel) + getlocvar(fs, --fs->nactvar).endpc = fs->pc; +} + + +static int indexupvalue (FuncState *fs, TString *name, expdesc *v) { + int i; + Proto *f = fs->f; + int oldsize = f->sizeupvalues; + for (i=0; inups; i++) { + if (fs->upvalues[i].k == v->k && fs->upvalues[i].info == v->u.s.info) { + lua_assert(f->upvalues[i] == name); + return i; + } + } + /* new one */ + luaY_checklimit(fs, f->nups + 1, LUAI_MAXUPVALUES, "upvalues"); + luaM_growvector(fs->L, f->upvalues, f->nups, f->sizeupvalues, + TString *, MAX_INT, ""); + while (oldsize < f->sizeupvalues) f->upvalues[oldsize++] = NULL; + f->upvalues[f->nups] = name; + luaC_objbarrier(fs->L, f, name); + lua_assert(v->k == VLOCAL || v->k == VUPVAL); + fs->upvalues[f->nups].k = cast_byte(v->k); + fs->upvalues[f->nups].info = cast_byte(v->u.s.info); + return f->nups++; +} + + +static int searchvar (FuncState *fs, TString *n) { + int i; + for (i=fs->nactvar-1; i >= 0; i--) { + if (n == getlocvar(fs, i).varname) + return i; + } + return -1; /* not found */ +} + + +static void markupval (FuncState *fs, int level) { + BlockCnt *bl = fs->bl; + while (bl && bl->nactvar > level) bl = bl->previous; + if (bl) bl->upval = 1; +} + + +static int singlevaraux (FuncState *fs, TString *n, expdesc *var, int base) { + if (fs == NULL) { /* no more levels? */ + init_exp(var, VGLOBAL, NO_REG); /* default is global variable */ + return VGLOBAL; + } + else { + int v = searchvar(fs, n); /* look up at current level */ + if (v >= 0) { + init_exp(var, VLOCAL, v); + if (!base) + markupval(fs, v); /* local will be used as an upval */ + return VLOCAL; + } + else { /* not found at current level; try upper one */ + if (singlevaraux(fs->prev, n, var, 0) == VGLOBAL) + return VGLOBAL; + var->u.s.info = indexupvalue(fs, n, var); /* else was LOCAL or UPVAL */ + var->k = VUPVAL; /* upvalue in this level */ + return VUPVAL; + } + } +} + + +static void singlevar (LexState *ls, expdesc *var) { + TString *varname = str_checkname(ls); + FuncState *fs = ls->fs; + if (singlevaraux(fs, varname, var, 1) == VGLOBAL) + var->u.s.info = luaK_stringK(fs, varname); /* info points to global name */ +} + + +static void adjust_assign (LexState *ls, int nvars, int nexps, expdesc *e) { + FuncState *fs = ls->fs; + int extra = nvars - nexps; + if (hasmultret(e->k)) { + extra++; /* includes call itself */ + if (extra < 0) extra = 0; + luaK_setreturns(fs, e, extra); /* last exp. provides the difference */ + if (extra > 1) luaK_reserveregs(fs, extra-1); + } + else { + if (e->k != VVOID) luaK_exp2nextreg(fs, e); /* close last expression */ + if (extra > 0) { + int reg = fs->freereg; + luaK_reserveregs(fs, extra); + luaK_nil(fs, reg, extra); + } + } +} + + +static void enterlevel (LexState *ls) { + if (++ls->L->nCcalls > LUAI_MAXCCALLS) + luaX_lexerror(ls, "chunk has too many syntax levels", 0); +} + + +#define leavelevel(ls) ((ls)->L->nCcalls--) + + +static void enterblock (FuncState *fs, BlockCnt *bl, lu_byte isbreakable) { + bl->breaklist = NO_JUMP; + bl->isbreakable = isbreakable; + bl->nactvar = fs->nactvar; + bl->upval = 0; + bl->previous = fs->bl; + fs->bl = bl; + lua_assert(fs->freereg == fs->nactvar); +} + + +static void leaveblock (FuncState *fs) { + BlockCnt *bl = fs->bl; + fs->bl = bl->previous; + removevars(fs->ls, bl->nactvar); + if (bl->upval) + luaK_codeABC(fs, OP_CLOSE, bl->nactvar, 0, 0); + /* a block either controls scope or breaks (never both) */ + lua_assert(!bl->isbreakable || !bl->upval); + lua_assert(bl->nactvar == fs->nactvar); + fs->freereg = fs->nactvar; /* free registers */ + luaK_patchtohere(fs, bl->breaklist); +} + + +static void pushclosure (LexState *ls, FuncState *func, expdesc *v) { + FuncState *fs = ls->fs; + Proto *f = fs->f; + int oldsize = f->sizep; + int i; + luaM_growvector(ls->L, f->p, fs->np, f->sizep, Proto *, + MAXARG_Bx, "constant table overflow"); + while (oldsize < f->sizep) f->p[oldsize++] = NULL; + f->p[fs->np++] = func->f; + luaC_objbarrier(ls->L, f, func->f); + init_exp(v, VRELOCABLE, luaK_codeABx(fs, OP_CLOSURE, 0, fs->np-1)); + for (i=0; if->nups; i++) { + OpCode o = (func->upvalues[i].k == VLOCAL) ? OP_MOVE : OP_GETUPVAL; + luaK_codeABC(fs, o, 0, func->upvalues[i].info, 0); + } +} + + +static void open_func (LexState *ls, FuncState *fs) { + lua_State *L = ls->L; + Proto *f = luaF_newproto(L); + fs->f = f; + fs->prev = ls->fs; /* linked list of funcstates */ + fs->ls = ls; + fs->L = L; + ls->fs = fs; + fs->pc = 0; + fs->lasttarget = -1; + fs->jpc = NO_JUMP; + fs->freereg = 0; + fs->nk = 0; + fs->np = 0; + fs->nlocvars = 0; + fs->nactvar = 0; + fs->bl = NULL; + f->source = ls->source; + f->maxstacksize = 2; /* registers 0/1 are always valid */ + fs->h = luaH_new(L, 0, 0); + /* anchor table of constants and prototype (to avoid being collected) */ + sethvalue2s(L, L->top, fs->h); + incr_top(L); + setptvalue2s(L, L->top, f); + incr_top(L); +} + + +static void close_func (LexState *ls) { + lua_State *L = ls->L; + FuncState *fs = ls->fs; + Proto *f = fs->f; + removevars(ls, 0); + luaK_ret(fs, 0, 0); /* final return */ + luaM_reallocvector(L, f->code, f->sizecode, fs->pc, Instruction); + f->sizecode = fs->pc; + luaM_reallocvector(L, f->lineinfo, f->sizelineinfo, fs->pc, int); + f->sizelineinfo = fs->pc; + luaM_reallocvector(L, f->k, f->sizek, fs->nk, TValue); + f->sizek = fs->nk; + luaM_reallocvector(L, f->p, f->sizep, fs->np, Proto *); + f->sizep = fs->np; + luaM_reallocvector(L, f->locvars, f->sizelocvars, fs->nlocvars, LocVar); + f->sizelocvars = fs->nlocvars; + luaM_reallocvector(L, f->upvalues, f->sizeupvalues, f->nups, TString *); + f->sizeupvalues = f->nups; + lua_assert(luaG_checkcode(f)); + lua_assert(fs->bl == NULL); + ls->fs = fs->prev; + /* last token read was anchored in defunct function; must reanchor it */ + if (fs) anchor_token(ls); + L->top -= 2; /* remove table and prototype from the stack */ +} + + +Proto *luaY_parser (lua_State *L, ZIO *z, Mbuffer *buff, const char *name) { + struct LexState lexstate; + struct FuncState funcstate; + lexstate.buff = buff; + luaX_setinput(L, &lexstate, z, luaS_new(L, name)); + open_func(&lexstate, &funcstate); + funcstate.f->is_vararg = VARARG_ISVARARG; /* main func. is always vararg */ + luaX_next(&lexstate); /* read first token */ + chunk(&lexstate); + check(&lexstate, TK_EOS); + close_func(&lexstate); + lua_assert(funcstate.prev == NULL); + lua_assert(funcstate.f->nups == 0); + lua_assert(lexstate.fs == NULL); + return funcstate.f; +} + + + +/*============================================================*/ +/* GRAMMAR RULES */ +/*============================================================*/ + + +static void field (LexState *ls, expdesc *v) { + /* field -> ['.' | ':'] NAME */ + FuncState *fs = ls->fs; + expdesc key; + luaK_exp2anyreg(fs, v); + luaX_next(ls); /* skip the dot or colon */ + checkname(ls, &key); + luaK_indexed(fs, v, &key); +} + + +static void yindex (LexState *ls, expdesc *v) { + /* index -> '[' expr ']' */ + luaX_next(ls); /* skip the '[' */ + expr(ls, v); + luaK_exp2val(ls->fs, v); + checknext(ls, ']'); +} + + +/* +** {====================================================================== +** Rules for Constructors +** ======================================================================= +*/ + + +struct ConsControl { + expdesc v; /* last list item read */ + expdesc *t; /* table descriptor */ + int nh; /* total number of `record' elements */ + int na; /* total number of array elements */ + int tostore; /* number of array elements pending to be stored */ +}; + + +static void recfield (LexState *ls, struct ConsControl *cc) { + /* recfield -> (NAME | `['exp1`]') = exp1 */ + FuncState *fs = ls->fs; + int reg = ls->fs->freereg; + expdesc key, val; + int rkkey; + if (ls->t.token == TK_NAME) { + luaY_checklimit(fs, cc->nh, MAX_INT, "items in a constructor"); + checkname(ls, &key); + } + else /* ls->t.token == '[' */ + yindex(ls, &key); + cc->nh++; + checknext(ls, '='); + rkkey = luaK_exp2RK(fs, &key); + expr(ls, &val); + luaK_codeABC(fs, OP_SETTABLE, cc->t->u.s.info, rkkey, luaK_exp2RK(fs, &val)); + fs->freereg = reg; /* free registers */ +} + + +static void closelistfield (FuncState *fs, struct ConsControl *cc) { + if (cc->v.k == VVOID) return; /* there is no list item */ + luaK_exp2nextreg(fs, &cc->v); + cc->v.k = VVOID; + if (cc->tostore == LFIELDS_PER_FLUSH) { + luaK_setlist(fs, cc->t->u.s.info, cc->na, cc->tostore); /* flush */ + cc->tostore = 0; /* no more items pending */ + } +} + + +static void lastlistfield (FuncState *fs, struct ConsControl *cc) { + if (cc->tostore == 0) return; + if (hasmultret(cc->v.k)) { + luaK_setmultret(fs, &cc->v); + luaK_setlist(fs, cc->t->u.s.info, cc->na, LUA_MULTRET); + cc->na--; /* do not count last expression (unknown number of elements) */ + } + else { + if (cc->v.k != VVOID) + luaK_exp2nextreg(fs, &cc->v); + luaK_setlist(fs, cc->t->u.s.info, cc->na, cc->tostore); + } +} + + +static void listfield (LexState *ls, struct ConsControl *cc) { + expr(ls, &cc->v); + luaY_checklimit(ls->fs, cc->na, MAX_INT, "items in a constructor"); + cc->na++; + cc->tostore++; +} + + +static void constructor (LexState *ls, expdesc *t) { + /* constructor -> ?? */ + FuncState *fs = ls->fs; + int line = ls->linenumber; + int pc = luaK_codeABC(fs, OP_NEWTABLE, 0, 0, 0); + struct ConsControl cc; + cc.na = cc.nh = cc.tostore = 0; + cc.t = t; + init_exp(t, VRELOCABLE, pc); + init_exp(&cc.v, VVOID, 0); /* no value (yet) */ + luaK_exp2nextreg(ls->fs, t); /* fix it at stack top (for gc) */ + checknext(ls, '{'); + do { + lua_assert(cc.v.k == VVOID || cc.tostore > 0); + if (ls->t.token == '}') break; + closelistfield(fs, &cc); + switch(ls->t.token) { + case TK_NAME: { /* may be listfields or recfields */ + luaX_lookahead(ls); + if (ls->lookahead.token != '=') /* expression? */ + listfield(ls, &cc); + else + recfield(ls, &cc); + break; + } + case '[': { /* constructor_item -> recfield */ + recfield(ls, &cc); + break; + } + default: { /* constructor_part -> listfield */ + listfield(ls, &cc); + break; + } + } + } while (testnext(ls, ',') || testnext(ls, ';')); + check_match(ls, '}', '{', line); + lastlistfield(fs, &cc); + SETARG_B(fs->f->code[pc], luaO_int2fb(cc.na)); /* set initial array size */ + SETARG_C(fs->f->code[pc], luaO_int2fb(cc.nh)); /* set initial table size */ +} + +/* }====================================================================== */ + + + +static void parlist (LexState *ls) { + /* parlist -> [ param { `,' param } ] */ + FuncState *fs = ls->fs; + Proto *f = fs->f; + int nparams = 0; + f->is_vararg = 0; + if (ls->t.token != ')') { /* is `parlist' not empty? */ + do { + switch (ls->t.token) { + case TK_NAME: { /* param -> NAME */ + new_localvar(ls, str_checkname(ls), nparams++); + break; + } + case TK_DOTS: { /* param -> `...' */ + luaX_next(ls); +#if defined(LUA_COMPAT_VARARG) + /* use `arg' as default name */ + new_localvarliteral(ls, "arg", nparams++); + f->is_vararg = VARARG_HASARG | VARARG_NEEDSARG; +#endif + f->is_vararg |= VARARG_ISVARARG; + break; + } + default: luaX_syntaxerror(ls, " or " LUA_QL("...") " expected"); + } + } while (!f->is_vararg && testnext(ls, ',')); + } + adjustlocalvars(ls, nparams); + f->numparams = cast_byte(fs->nactvar - (f->is_vararg & VARARG_HASARG)); + luaK_reserveregs(fs, fs->nactvar); /* reserve register for parameters */ +} + + +static void body (LexState *ls, expdesc *e, int needself, int line) { + /* body -> `(' parlist `)' chunk END */ + FuncState new_fs; + open_func(ls, &new_fs); + new_fs.f->linedefined = line; + checknext(ls, '('); + if (needself) { + new_localvarliteral(ls, "self", 0); + adjustlocalvars(ls, 1); + } + parlist(ls); + checknext(ls, ')'); + chunk(ls); + new_fs.f->lastlinedefined = ls->linenumber; + check_match(ls, TK_END, TK_FUNCTION, line); + close_func(ls); + pushclosure(ls, &new_fs, e); +} + + +static int explist1 (LexState *ls, expdesc *v) { + /* explist1 -> expr { `,' expr } */ + int n = 1; /* at least one expression */ + expr(ls, v); + while (testnext(ls, ',')) { + luaK_exp2nextreg(ls->fs, v); + expr(ls, v); + n++; + } + return n; +} + + +static void funcargs (LexState *ls, expdesc *f) { + FuncState *fs = ls->fs; + expdesc args; + int base, nparams; + int line = ls->linenumber; + switch (ls->t.token) { + case '(': { /* funcargs -> `(' [ explist1 ] `)' */ + if (line != ls->lastline) + luaX_syntaxerror(ls,"ambiguous syntax (function call x new statement)"); + luaX_next(ls); + if (ls->t.token == ')') /* arg list is empty? */ + args.k = VVOID; + else { + explist1(ls, &args); + luaK_setmultret(fs, &args); + } + check_match(ls, ')', '(', line); + break; + } + case '{': { /* funcargs -> constructor */ + constructor(ls, &args); + break; + } + case TK_STRING: { /* funcargs -> STRING */ + codestring(ls, &args, ls->t.seminfo.ts); + luaX_next(ls); /* must use `seminfo' before `next' */ + break; + } + default: { + luaX_syntaxerror(ls, "function arguments expected"); + return; + } + } + lua_assert(f->k == VNONRELOC); + base = f->u.s.info; /* base register for call */ + if (hasmultret(args.k)) + nparams = LUA_MULTRET; /* open call */ + else { + if (args.k != VVOID) + luaK_exp2nextreg(fs, &args); /* close last argument */ + nparams = fs->freereg - (base+1); + } + init_exp(f, VCALL, luaK_codeABC(fs, OP_CALL, base, nparams+1, 2)); + luaK_fixline(fs, line); + fs->freereg = base+1; /* call remove function and arguments and leaves + (unless changed) one result */ +} + + + + +/* +** {====================================================================== +** Expression parsing +** ======================================================================= +*/ + + +static void prefixexp (LexState *ls, expdesc *v) { + /* prefixexp -> NAME | '(' expr ')' */ + switch (ls->t.token) { + case '(': { + int line = ls->linenumber; + luaX_next(ls); + expr(ls, v); + check_match(ls, ')', '(', line); + luaK_dischargevars(ls->fs, v); + return; + } + case TK_NAME: { + singlevar(ls, v); + return; + } + default: { + luaX_syntaxerror(ls, "unexpected symbol"); + return; + } + } +} + + +static void primaryexp (LexState *ls, expdesc *v) { + /* primaryexp -> + prefixexp { `.' NAME | `[' exp `]' | `:' NAME funcargs | funcargs } */ + FuncState *fs = ls->fs; + prefixexp(ls, v); + for (;;) { + switch (ls->t.token) { + case '.': { /* field */ + field(ls, v); + break; + } + case '[': { /* `[' exp1 `]' */ + expdesc key; + luaK_exp2anyreg(fs, v); + yindex(ls, &key); + luaK_indexed(fs, v, &key); + break; + } + case ':': { /* `:' NAME funcargs */ + expdesc key; + luaX_next(ls); + checkname(ls, &key); + luaK_self(fs, v, &key); + funcargs(ls, v); + break; + } + case '(': case TK_STRING: case '{': { /* funcargs */ + luaK_exp2nextreg(fs, v); + funcargs(ls, v); + break; + } + default: return; + } + } +} + + +static void simpleexp (LexState *ls, expdesc *v) { + /* simpleexp -> NUMBER | STRING | NIL | true | false | ... | + constructor | FUNCTION body | primaryexp */ + switch (ls->t.token) { + case TK_NUMBER: { + init_exp(v, VKNUM, 0); + v->u.nval = ls->t.seminfo.r; + break; + } + case TK_STRING: { + codestring(ls, v, ls->t.seminfo.ts); + break; + } + case TK_NIL: { + init_exp(v, VNIL, 0); + break; + } + case TK_TRUE: { + init_exp(v, VTRUE, 0); + break; + } + case TK_FALSE: { + init_exp(v, VFALSE, 0); + break; + } + case TK_DOTS: { /* vararg */ + FuncState *fs = ls->fs; + check_condition(ls, fs->f->is_vararg, + "cannot use " LUA_QL("...") " outside a vararg function"); + fs->f->is_vararg &= ~VARARG_NEEDSARG; /* don't need 'arg' */ + init_exp(v, VVARARG, luaK_codeABC(fs, OP_VARARG, 0, 1, 0)); + break; + } + case '{': { /* constructor */ + constructor(ls, v); + return; + } + case TK_FUNCTION: { + luaX_next(ls); + body(ls, v, 0, ls->linenumber); + return; + } + default: { + primaryexp(ls, v); + return; + } + } + luaX_next(ls); +} + + +static UnOpr getunopr (int op) { + switch (op) { + case TK_NOT: return OPR_NOT; + case '-': return OPR_MINUS; + case '#': return OPR_LEN; + default: return OPR_NOUNOPR; + } +} + + +static BinOpr getbinopr (int op) { + switch (op) { + case '+': return OPR_ADD; + case '-': return OPR_SUB; + case '*': return OPR_MUL; + case '/': return OPR_DIV; + case '%': return OPR_MOD; + case '^': return OPR_POW; + case TK_CONCAT: return OPR_CONCAT; + case TK_NE: return OPR_NE; + case TK_EQ: return OPR_EQ; + case '<': return OPR_LT; + case TK_LE: return OPR_LE; + case '>': return OPR_GT; + case TK_GE: return OPR_GE; + case TK_AND: return OPR_AND; + case TK_OR: return OPR_OR; + default: return OPR_NOBINOPR; + } +} + + +static const struct { + lu_byte left; /* left priority for each binary operator */ + lu_byte right; /* right priority */ +} priority[] = { /* ORDER OPR */ + {6, 6}, {6, 6}, {7, 7}, {7, 7}, {7, 7}, /* `+' `-' `/' `%' */ + {10, 9}, {5, 4}, /* power and concat (right associative) */ + {3, 3}, {3, 3}, /* equality and inequality */ + {3, 3}, {3, 3}, {3, 3}, {3, 3}, /* order */ + {2, 2}, {1, 1} /* logical (and/or) */ +}; + +#define UNARY_PRIORITY 8 /* priority for unary operators */ + + +/* +** subexpr -> (simpleexp | unop subexpr) { binop subexpr } +** where `binop' is any binary operator with a priority higher than `limit' +*/ +static BinOpr subexpr (LexState *ls, expdesc *v, unsigned int limit) { + BinOpr op; + UnOpr uop; + enterlevel(ls); + uop = getunopr(ls->t.token); + if (uop != OPR_NOUNOPR) { + luaX_next(ls); + subexpr(ls, v, UNARY_PRIORITY); + luaK_prefix(ls->fs, uop, v); + } + else simpleexp(ls, v); + /* expand while operators have priorities higher than `limit' */ + op = getbinopr(ls->t.token); + while (op != OPR_NOBINOPR && priority[op].left > limit) { + expdesc v2; + BinOpr nextop; + luaX_next(ls); + luaK_infix(ls->fs, op, v); + /* read sub-expression with higher priority */ + nextop = subexpr(ls, &v2, priority[op].right); + luaK_posfix(ls->fs, op, v, &v2); + op = nextop; + } + leavelevel(ls); + return op; /* return first untreated operator */ +} + + +static void expr (LexState *ls, expdesc *v) { + subexpr(ls, v, 0); +} + +/* }==================================================================== */ + + + +/* +** {====================================================================== +** Rules for Statements +** ======================================================================= +*/ + + +static int block_follow (int token) { + switch (token) { + case TK_ELSE: case TK_ELSEIF: case TK_END: + case TK_UNTIL: case TK_EOS: + return 1; + default: return 0; + } +} + + +static void block (LexState *ls) { + /* block -> chunk */ + FuncState *fs = ls->fs; + BlockCnt bl; + enterblock(fs, &bl, 0); + chunk(ls); + lua_assert(bl.breaklist == NO_JUMP); + leaveblock(fs); +} + + +/* +** structure to chain all variables in the left-hand side of an +** assignment +*/ +struct LHS_assign { + struct LHS_assign *prev; + expdesc v; /* variable (global, local, upvalue, or indexed) */ +}; + + +/* +** check whether, in an assignment to a local variable, the local variable +** is needed in a previous assignment (to a table). If so, save original +** local value in a safe place and use this safe copy in the previous +** assignment. +*/ +static void check_conflict (LexState *ls, struct LHS_assign *lh, expdesc *v) { + FuncState *fs = ls->fs; + int extra = fs->freereg; /* eventual position to save local variable */ + int conflict = 0; + for (; lh; lh = lh->prev) { + if (lh->v.k == VINDEXED) { + if (lh->v.u.s.info == v->u.s.info) { /* conflict? */ + conflict = 1; + lh->v.u.s.info = extra; /* previous assignment will use safe copy */ + } + if (lh->v.u.s.aux == v->u.s.info) { /* conflict? */ + conflict = 1; + lh->v.u.s.aux = extra; /* previous assignment will use safe copy */ + } + } + } + if (conflict) { + luaK_codeABC(fs, OP_MOVE, fs->freereg, v->u.s.info, 0); /* make copy */ + luaK_reserveregs(fs, 1); + } +} + + +static void assignment (LexState *ls, struct LHS_assign *lh, int nvars) { + expdesc e; + check_condition(ls, VLOCAL <= lh->v.k && lh->v.k <= VINDEXED, + "syntax error"); + if (testnext(ls, ',')) { /* assignment -> `,' primaryexp assignment */ + struct LHS_assign nv; + nv.prev = lh; + primaryexp(ls, &nv.v); + if (nv.v.k == VLOCAL) + check_conflict(ls, lh, &nv.v); + luaY_checklimit(ls->fs, nvars, LUAI_MAXCCALLS - ls->L->nCcalls, + "variables in assignment"); + assignment(ls, &nv, nvars+1); + } + else { /* assignment -> `=' explist1 */ + int nexps; + checknext(ls, '='); + nexps = explist1(ls, &e); + if (nexps != nvars) { + adjust_assign(ls, nvars, nexps, &e); + if (nexps > nvars) + ls->fs->freereg -= nexps - nvars; /* remove extra values */ + } + else { + luaK_setoneret(ls->fs, &e); /* close last expression */ + luaK_storevar(ls->fs, &lh->v, &e); + return; /* avoid default */ + } + } + init_exp(&e, VNONRELOC, ls->fs->freereg-1); /* default assignment */ + luaK_storevar(ls->fs, &lh->v, &e); +} + + +static int cond (LexState *ls) { + /* cond -> exp */ + expdesc v; + expr(ls, &v); /* read condition */ + if (v.k == VNIL) v.k = VFALSE; /* `falses' are all equal here */ + luaK_goiftrue(ls->fs, &v); + return v.f; +} + + +static void breakstat (LexState *ls) { + FuncState *fs = ls->fs; + BlockCnt *bl = fs->bl; + int upval = 0; + while (bl && !bl->isbreakable) { + upval |= bl->upval; + bl = bl->previous; + } + if (!bl) + luaX_syntaxerror(ls, "no loop to break"); + if (upval) + luaK_codeABC(fs, OP_CLOSE, bl->nactvar, 0, 0); + luaK_concat(fs, &bl->breaklist, luaK_jump(fs)); +} + + +static void whilestat (LexState *ls, int line) { + /* whilestat -> WHILE cond DO block END */ + FuncState *fs = ls->fs; + int whileinit; + int condexit; + BlockCnt bl; + luaX_next(ls); /* skip WHILE */ + whileinit = luaK_getlabel(fs); + condexit = cond(ls); + enterblock(fs, &bl, 1); + checknext(ls, TK_DO); + block(ls); + luaK_patchlist(fs, luaK_jump(fs), whileinit); + check_match(ls, TK_END, TK_WHILE, line); + leaveblock(fs); + luaK_patchtohere(fs, condexit); /* false conditions finish the loop */ +} + + +static void repeatstat (LexState *ls, int line) { + /* repeatstat -> REPEAT block UNTIL cond */ + int condexit; + FuncState *fs = ls->fs; + int repeat_init = luaK_getlabel(fs); + BlockCnt bl1, bl2; + enterblock(fs, &bl1, 1); /* loop block */ + enterblock(fs, &bl2, 0); /* scope block */ + luaX_next(ls); /* skip REPEAT */ + chunk(ls); + check_match(ls, TK_UNTIL, TK_REPEAT, line); + condexit = cond(ls); /* read condition (inside scope block) */ + if (!bl2.upval) { /* no upvalues? */ + leaveblock(fs); /* finish scope */ + luaK_patchlist(ls->fs, condexit, repeat_init); /* close the loop */ + } + else { /* complete semantics when there are upvalues */ + breakstat(ls); /* if condition then break */ + luaK_patchtohere(ls->fs, condexit); /* else... */ + leaveblock(fs); /* finish scope... */ + luaK_patchlist(ls->fs, luaK_jump(fs), repeat_init); /* and repeat */ + } + leaveblock(fs); /* finish loop */ +} + + +static int exp1 (LexState *ls) { + expdesc e; + int k; + expr(ls, &e); + k = e.k; + luaK_exp2nextreg(ls->fs, &e); + return k; +} + + +static void forbody (LexState *ls, int base, int line, int nvars, int isnum) { + /* forbody -> DO block */ + BlockCnt bl; + FuncState *fs = ls->fs; + int prep, endfor; + adjustlocalvars(ls, 3); /* control variables */ + checknext(ls, TK_DO); + prep = isnum ? luaK_codeAsBx(fs, OP_FORPREP, base, NO_JUMP) : luaK_jump(fs); + enterblock(fs, &bl, 0); /* scope for declared variables */ + adjustlocalvars(ls, nvars); + luaK_reserveregs(fs, nvars); + block(ls); + leaveblock(fs); /* end of scope for declared variables */ + luaK_patchtohere(fs, prep); + endfor = (isnum) ? luaK_codeAsBx(fs, OP_FORLOOP, base, NO_JUMP) : + luaK_codeABC(fs, OP_TFORLOOP, base, 0, nvars); + luaK_fixline(fs, line); /* pretend that `OP_FOR' starts the loop */ + luaK_patchlist(fs, (isnum ? endfor : luaK_jump(fs)), prep + 1); +} + + +static void fornum (LexState *ls, TString *varname, int line) { + /* fornum -> NAME = exp1,exp1[,exp1] forbody */ + FuncState *fs = ls->fs; + int base = fs->freereg; + new_localvarliteral(ls, "(for index)", 0); + new_localvarliteral(ls, "(for limit)", 1); + new_localvarliteral(ls, "(for step)", 2); + new_localvar(ls, varname, 3); + checknext(ls, '='); + exp1(ls); /* initial value */ + checknext(ls, ','); + exp1(ls); /* limit */ + if (testnext(ls, ',')) + exp1(ls); /* optional step */ + else { /* default step = 1 */ + luaK_codeABx(fs, OP_LOADK, fs->freereg, luaK_numberK(fs, 1)); + luaK_reserveregs(fs, 1); + } + forbody(ls, base, line, 1, 1); +} + + +static void forlist (LexState *ls, TString *indexname) { + /* forlist -> NAME {,NAME} IN explist1 forbody */ + FuncState *fs = ls->fs; + expdesc e; + int nvars = 0; + int line; + int base = fs->freereg; + /* create control variables */ + new_localvarliteral(ls, "(for generator)", nvars++); + new_localvarliteral(ls, "(for state)", nvars++); + new_localvarliteral(ls, "(for control)", nvars++); + /* create declared variables */ + new_localvar(ls, indexname, nvars++); + while (testnext(ls, ',')) + new_localvar(ls, str_checkname(ls), nvars++); + checknext(ls, TK_IN); + line = ls->linenumber; + adjust_assign(ls, 3, explist1(ls, &e), &e); + luaK_checkstack(fs, 3); /* extra space to call generator */ + forbody(ls, base, line, nvars - 3, 0); +} + + +static void forstat (LexState *ls, int line) { + /* forstat -> FOR (fornum | forlist) END */ + FuncState *fs = ls->fs; + TString *varname; + BlockCnt bl; + enterblock(fs, &bl, 1); /* scope for loop and control variables */ + luaX_next(ls); /* skip `for' */ + varname = str_checkname(ls); /* first variable name */ + switch (ls->t.token) { + case '=': fornum(ls, varname, line); break; + case ',': case TK_IN: forlist(ls, varname); break; + default: luaX_syntaxerror(ls, LUA_QL("=") " or " LUA_QL("in") " expected"); + } + check_match(ls, TK_END, TK_FOR, line); + leaveblock(fs); /* loop scope (`break' jumps to this point) */ +} + + +static int test_then_block (LexState *ls) { + /* test_then_block -> [IF | ELSEIF] cond THEN block */ + int condexit; + luaX_next(ls); /* skip IF or ELSEIF */ + condexit = cond(ls); + checknext(ls, TK_THEN); + block(ls); /* `then' part */ + return condexit; +} + + +static void ifstat (LexState *ls, int line) { + /* ifstat -> IF cond THEN block {ELSEIF cond THEN block} [ELSE block] END */ + FuncState *fs = ls->fs; + int flist; + int escapelist = NO_JUMP; + flist = test_then_block(ls); /* IF cond THEN block */ + while (ls->t.token == TK_ELSEIF) { + luaK_concat(fs, &escapelist, luaK_jump(fs)); + luaK_patchtohere(fs, flist); + flist = test_then_block(ls); /* ELSEIF cond THEN block */ + } + if (ls->t.token == TK_ELSE) { + luaK_concat(fs, &escapelist, luaK_jump(fs)); + luaK_patchtohere(fs, flist); + luaX_next(ls); /* skip ELSE (after patch, for correct line info) */ + block(ls); /* `else' part */ + } + else + luaK_concat(fs, &escapelist, flist); + luaK_patchtohere(fs, escapelist); + check_match(ls, TK_END, TK_IF, line); +} + + +static void localfunc (LexState *ls) { + expdesc v, b; + FuncState *fs = ls->fs; + new_localvar(ls, str_checkname(ls), 0); + init_exp(&v, VLOCAL, fs->freereg); + luaK_reserveregs(fs, 1); + adjustlocalvars(ls, 1); + body(ls, &b, 0, ls->linenumber); + luaK_storevar(fs, &v, &b); + /* debug information will only see the variable after this point! */ + getlocvar(fs, fs->nactvar - 1).startpc = fs->pc; +} + + +static void localstat (LexState *ls) { + /* stat -> LOCAL NAME {`,' NAME} [`=' explist1] */ + int nvars = 0; + int nexps; + expdesc e; + do { + new_localvar(ls, str_checkname(ls), nvars++); + } while (testnext(ls, ',')); + if (testnext(ls, '=')) + nexps = explist1(ls, &e); + else { + e.k = VVOID; + nexps = 0; + } + adjust_assign(ls, nvars, nexps, &e); + adjustlocalvars(ls, nvars); +} + + +static int funcname (LexState *ls, expdesc *v) { + /* funcname -> NAME {field} [`:' NAME] */ + int needself = 0; + singlevar(ls, v); + while (ls->t.token == '.') + field(ls, v); + if (ls->t.token == ':') { + needself = 1; + field(ls, v); + } + return needself; +} + + +static void funcstat (LexState *ls, int line) { + /* funcstat -> FUNCTION funcname body */ + int needself; + expdesc v, b; + luaX_next(ls); /* skip FUNCTION */ + needself = funcname(ls, &v); + body(ls, &b, needself, line); + luaK_storevar(ls->fs, &v, &b); + luaK_fixline(ls->fs, line); /* definition `happens' in the first line */ +} + + +static void exprstat (LexState *ls) { + /* stat -> func | assignment */ + FuncState *fs = ls->fs; + struct LHS_assign v; + primaryexp(ls, &v.v); + if (v.v.k == VCALL) /* stat -> func */ + SETARG_C(getcode(fs, &v.v), 1); /* call statement uses no results */ + else { /* stat -> assignment */ + v.prev = NULL; + assignment(ls, &v, 1); + } +} + + +static void retstat (LexState *ls) { + /* stat -> RETURN explist */ + FuncState *fs = ls->fs; + expdesc e; + int first, nret; /* registers with returned values */ + luaX_next(ls); /* skip RETURN */ + if (block_follow(ls->t.token) || ls->t.token == ';') + first = nret = 0; /* return no values */ + else { + nret = explist1(ls, &e); /* optional return values */ + if (hasmultret(e.k)) { + luaK_setmultret(fs, &e); + if (e.k == VCALL && nret == 1) { /* tail call? */ + SET_OPCODE(getcode(fs,&e), OP_TAILCALL); + lua_assert(GETARG_A(getcode(fs,&e)) == fs->nactvar); + } + first = fs->nactvar; + nret = LUA_MULTRET; /* return all values */ + } + else { + if (nret == 1) /* only one single value? */ + first = luaK_exp2anyreg(fs, &e); + else { + luaK_exp2nextreg(fs, &e); /* values must go to the `stack' */ + first = fs->nactvar; /* return all `active' values */ + lua_assert(nret == fs->freereg - first); + } + } + } + luaK_ret(fs, first, nret); +} + + +static int statement (LexState *ls) { + int line = ls->linenumber; /* may be needed for error messages */ + switch (ls->t.token) { + case TK_IF: { /* stat -> ifstat */ + ifstat(ls, line); + return 0; + } + case TK_WHILE: { /* stat -> whilestat */ + whilestat(ls, line); + return 0; + } + case TK_DO: { /* stat -> DO block END */ + luaX_next(ls); /* skip DO */ + block(ls); + check_match(ls, TK_END, TK_DO, line); + return 0; + } + case TK_FOR: { /* stat -> forstat */ + forstat(ls, line); + return 0; + } + case TK_REPEAT: { /* stat -> repeatstat */ + repeatstat(ls, line); + return 0; + } + case TK_FUNCTION: { + funcstat(ls, line); /* stat -> funcstat */ + return 0; + } + case TK_LOCAL: { /* stat -> localstat */ + luaX_next(ls); /* skip LOCAL */ + if (testnext(ls, TK_FUNCTION)) /* local function? */ + localfunc(ls); + else + localstat(ls); + return 0; + } + case TK_RETURN: { /* stat -> retstat */ + retstat(ls); + return 1; /* must be last statement */ + } + case TK_BREAK: { /* stat -> breakstat */ + luaX_next(ls); /* skip BREAK */ + breakstat(ls); + return 1; /* must be last statement */ + } + default: { + exprstat(ls); + return 0; /* to avoid warnings */ + } + } +} + + +static void chunk (LexState *ls) { + /* chunk -> { stat [`;'] } */ + int islast = 0; + enterlevel(ls); + while (!islast && !block_follow(ls->t.token)) { + islast = statement(ls); + testnext(ls, ';'); + lua_assert(ls->fs->f->maxstacksize >= ls->fs->freereg && + ls->fs->freereg >= ls->fs->nactvar); + ls->fs->freereg = ls->fs->nactvar; /* free registers */ + } + leavelevel(ls); +} + +/* }====================================================================== */ diff --git a/extern/lua-5.1.5/src/lparser.h b/extern/lua-5.1.5/src/lparser.h new file mode 100644 index 00000000..18836afd --- /dev/null +++ b/extern/lua-5.1.5/src/lparser.h @@ -0,0 +1,82 @@ +/* +** $Id: lparser.h,v 1.57.1.1 2007/12/27 13:02:25 roberto Exp $ +** Lua Parser +** See Copyright Notice in lua.h +*/ + +#ifndef lparser_h +#define lparser_h + +#include "llimits.h" +#include "lobject.h" +#include "lzio.h" + + +/* +** Expression descriptor +*/ + +typedef enum { + VVOID, /* no value */ + VNIL, + VTRUE, + VFALSE, + VK, /* info = index of constant in `k' */ + VKNUM, /* nval = numerical value */ + VLOCAL, /* info = local register */ + VUPVAL, /* info = index of upvalue in `upvalues' */ + VGLOBAL, /* info = index of table; aux = index of global name in `k' */ + VINDEXED, /* info = table register; aux = index register (or `k') */ + VJMP, /* info = instruction pc */ + VRELOCABLE, /* info = instruction pc */ + VNONRELOC, /* info = result register */ + VCALL, /* info = instruction pc */ + VVARARG /* info = instruction pc */ +} expkind; + +typedef struct expdesc { + expkind k; + union { + struct { int info, aux; } s; + lua_Number nval; + } u; + int t; /* patch list of `exit when true' */ + int f; /* patch list of `exit when false' */ +} expdesc; + + +typedef struct upvaldesc { + lu_byte k; + lu_byte info; +} upvaldesc; + + +struct BlockCnt; /* defined in lparser.c */ + + +/* state needed to generate code for a given function */ +typedef struct FuncState { + Proto *f; /* current function header */ + Table *h; /* table to find (and reuse) elements in `k' */ + struct FuncState *prev; /* enclosing function */ + struct LexState *ls; /* lexical state */ + struct lua_State *L; /* copy of the Lua state */ + struct BlockCnt *bl; /* chain of current blocks */ + int pc; /* next position to code (equivalent to `ncode') */ + int lasttarget; /* `pc' of last `jump target' */ + int jpc; /* list of pending jumps to `pc' */ + int freereg; /* first free register */ + int nk; /* number of elements in `k' */ + int np; /* number of elements in `p' */ + short nlocvars; /* number of elements in `locvars' */ + lu_byte nactvar; /* number of active local variables */ + upvaldesc upvalues[LUAI_MAXUPVALUES]; /* upvalues */ + unsigned short actvar[LUAI_MAXVARS]; /* declared-variable stack */ +} FuncState; + + +LUAI_FUNC Proto *luaY_parser (lua_State *L, ZIO *z, Mbuffer *buff, + const char *name); + + +#endif diff --git a/extern/lua-5.1.5/src/lstate.c b/extern/lua-5.1.5/src/lstate.c new file mode 100644 index 00000000..4313b83a --- /dev/null +++ b/extern/lua-5.1.5/src/lstate.c @@ -0,0 +1,214 @@ +/* +** $Id: lstate.c,v 2.36.1.2 2008/01/03 15:20:39 roberto Exp $ +** Global State +** See Copyright Notice in lua.h +*/ + + +#include + +#define lstate_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lgc.h" +#include "llex.h" +#include "lmem.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" + + +#define state_size(x) (sizeof(x) + LUAI_EXTRASPACE) +#define fromstate(l) (cast(lu_byte *, (l)) - LUAI_EXTRASPACE) +#define tostate(l) (cast(lua_State *, cast(lu_byte *, l) + LUAI_EXTRASPACE)) + + +/* +** Main thread combines a thread state and the global state +*/ +typedef struct LG { + lua_State l; + global_State g; +} LG; + + + +static void stack_init (lua_State *L1, lua_State *L) { + /* initialize CallInfo array */ + L1->base_ci = luaM_newvector(L, BASIC_CI_SIZE, CallInfo); + L1->ci = L1->base_ci; + L1->size_ci = BASIC_CI_SIZE; + L1->end_ci = L1->base_ci + L1->size_ci - 1; + /* initialize stack array */ + L1->stack = luaM_newvector(L, BASIC_STACK_SIZE + EXTRA_STACK, TValue); + L1->stacksize = BASIC_STACK_SIZE + EXTRA_STACK; + L1->top = L1->stack; + L1->stack_last = L1->stack+(L1->stacksize - EXTRA_STACK)-1; + /* initialize first ci */ + L1->ci->func = L1->top; + setnilvalue(L1->top++); /* `function' entry for this `ci' */ + L1->base = L1->ci->base = L1->top; + L1->ci->top = L1->top + LUA_MINSTACK; +} + + +static void freestack (lua_State *L, lua_State *L1) { + luaM_freearray(L, L1->base_ci, L1->size_ci, CallInfo); + luaM_freearray(L, L1->stack, L1->stacksize, TValue); +} + + +/* +** open parts that may cause memory-allocation errors +*/ +static void f_luaopen (lua_State *L, void *ud) { + global_State *g = G(L); + UNUSED(ud); + stack_init(L, L); /* init stack */ + sethvalue(L, gt(L), luaH_new(L, 0, 2)); /* table of globals */ + sethvalue(L, registry(L), luaH_new(L, 0, 2)); /* registry */ + luaS_resize(L, MINSTRTABSIZE); /* initial size of string table */ + luaT_init(L); + luaX_init(L); + luaS_fix(luaS_newliteral(L, MEMERRMSG)); + g->GCthreshold = 4*g->totalbytes; +} + + +static void preinit_state (lua_State *L, global_State *g) { + G(L) = g; + L->stack = NULL; + L->stacksize = 0; + L->errorJmp = NULL; + L->hook = NULL; + L->hookmask = 0; + L->basehookcount = 0; + L->allowhook = 1; + resethookcount(L); + L->openupval = NULL; + L->size_ci = 0; + L->nCcalls = L->baseCcalls = 0; + L->status = 0; + L->base_ci = L->ci = NULL; + L->savedpc = NULL; + L->errfunc = 0; + setnilvalue(gt(L)); +} + + +static void close_state (lua_State *L) { + global_State *g = G(L); + luaF_close(L, L->stack); /* close all upvalues for this thread */ + luaC_freeall(L); /* collect all objects */ + lua_assert(g->rootgc == obj2gco(L)); + lua_assert(g->strt.nuse == 0); + luaM_freearray(L, G(L)->strt.hash, G(L)->strt.size, TString *); + luaZ_freebuffer(L, &g->buff); + freestack(L, L); + lua_assert(g->totalbytes == sizeof(LG)); + (*g->frealloc)(g->ud, fromstate(L), state_size(LG), 0); +} + + +lua_State *luaE_newthread (lua_State *L) { + lua_State *L1 = tostate(luaM_malloc(L, state_size(lua_State))); + luaC_link(L, obj2gco(L1), LUA_TTHREAD); + preinit_state(L1, G(L)); + stack_init(L1, L); /* init stack */ + setobj2n(L, gt(L1), gt(L)); /* share table of globals */ + L1->hookmask = L->hookmask; + L1->basehookcount = L->basehookcount; + L1->hook = L->hook; + resethookcount(L1); + lua_assert(iswhite(obj2gco(L1))); + return L1; +} + + +void luaE_freethread (lua_State *L, lua_State *L1) { + luaF_close(L1, L1->stack); /* close all upvalues for this thread */ + lua_assert(L1->openupval == NULL); + luai_userstatefree(L1); + freestack(L, L1); + luaM_freemem(L, fromstate(L1), state_size(lua_State)); +} + + +LUA_API lua_State *lua_newstate (lua_Alloc f, void *ud) { + int i; + lua_State *L; + global_State *g; + void *l = (*f)(ud, NULL, 0, state_size(LG)); + if (l == NULL) return NULL; + L = tostate(l); + g = &((LG *)L)->g; + L->next = NULL; + L->tt = LUA_TTHREAD; + g->currentwhite = bit2mask(WHITE0BIT, FIXEDBIT); + L->marked = luaC_white(g); + set2bits(L->marked, FIXEDBIT, SFIXEDBIT); + preinit_state(L, g); + g->frealloc = f; + g->ud = ud; + g->mainthread = L; + g->uvhead.u.l.prev = &g->uvhead; + g->uvhead.u.l.next = &g->uvhead; + g->GCthreshold = 0; /* mark it as unfinished state */ + g->strt.size = 0; + g->strt.nuse = 0; + g->strt.hash = NULL; + setnilvalue(registry(L)); + luaZ_initbuffer(L, &g->buff); + g->panic = NULL; + g->gcstate = GCSpause; + g->rootgc = obj2gco(L); + g->sweepstrgc = 0; + g->sweepgc = &g->rootgc; + g->gray = NULL; + g->grayagain = NULL; + g->weak = NULL; + g->tmudata = NULL; + g->totalbytes = sizeof(LG); + g->gcpause = LUAI_GCPAUSE; + g->gcstepmul = LUAI_GCMUL; + g->gcdept = 0; + for (i=0; imt[i] = NULL; + if (luaD_rawrunprotected(L, f_luaopen, NULL) != 0) { + /* memory allocation error: free partial state */ + close_state(L); + L = NULL; + } + else + luai_userstateopen(L); + return L; +} + + +static void callallgcTM (lua_State *L, void *ud) { + UNUSED(ud); + luaC_callGCTM(L); /* call GC metamethods for all udata */ +} + + +LUA_API void lua_close (lua_State *L) { + L = G(L)->mainthread; /* only the main thread can be closed */ + lua_lock(L); + luaF_close(L, L->stack); /* close all upvalues for this thread */ + luaC_separateudata(L, 1); /* separate udata that have GC metamethods */ + L->errfunc = 0; /* no error function during GC metamethods */ + do { /* repeat until no more errors */ + L->ci = L->base_ci; + L->base = L->top = L->ci->base; + L->nCcalls = L->baseCcalls = 0; + } while (luaD_rawrunprotected(L, callallgcTM, NULL) != 0); + lua_assert(G(L)->tmudata == NULL); + luai_userstateclose(L); + close_state(L); +} + diff --git a/extern/lua-5.1.5/src/lstate.h b/extern/lua-5.1.5/src/lstate.h new file mode 100644 index 00000000..3bc575b6 --- /dev/null +++ b/extern/lua-5.1.5/src/lstate.h @@ -0,0 +1,169 @@ +/* +** $Id: lstate.h,v 2.24.1.2 2008/01/03 15:20:39 roberto Exp $ +** Global State +** See Copyright Notice in lua.h +*/ + +#ifndef lstate_h +#define lstate_h + +#include "lua.h" + +#include "lobject.h" +#include "ltm.h" +#include "lzio.h" + + + +struct lua_longjmp; /* defined in ldo.c */ + + +/* table of globals */ +#define gt(L) (&L->l_gt) + +/* registry */ +#define registry(L) (&G(L)->l_registry) + + +/* extra stack space to handle TM calls and some other extras */ +#define EXTRA_STACK 5 + + +#define BASIC_CI_SIZE 8 + +#define BASIC_STACK_SIZE (2*LUA_MINSTACK) + + + +typedef struct stringtable { + GCObject **hash; + lu_int32 nuse; /* number of elements */ + int size; +} stringtable; + + +/* +** informations about a call +*/ +typedef struct CallInfo { + StkId base; /* base for this function */ + StkId func; /* function index in the stack */ + StkId top; /* top for this function */ + const Instruction *savedpc; + int nresults; /* expected number of results from this function */ + int tailcalls; /* number of tail calls lost under this entry */ +} CallInfo; + + + +#define curr_func(L) (clvalue(L->ci->func)) +#define ci_func(ci) (clvalue((ci)->func)) +#define f_isLua(ci) (!ci_func(ci)->c.isC) +#define isLua(ci) (ttisfunction((ci)->func) && f_isLua(ci)) + + +/* +** `global state', shared by all threads of this state +*/ +typedef struct global_State { + stringtable strt; /* hash table for strings */ + lua_Alloc frealloc; /* function to reallocate memory */ + void *ud; /* auxiliary data to `frealloc' */ + lu_byte currentwhite; + lu_byte gcstate; /* state of garbage collector */ + int sweepstrgc; /* position of sweep in `strt' */ + GCObject *rootgc; /* list of all collectable objects */ + GCObject **sweepgc; /* position of sweep in `rootgc' */ + GCObject *gray; /* list of gray objects */ + GCObject *grayagain; /* list of objects to be traversed atomically */ + GCObject *weak; /* list of weak tables (to be cleared) */ + GCObject *tmudata; /* last element of list of userdata to be GC */ + Mbuffer buff; /* temporary buffer for string concatentation */ + lu_mem GCthreshold; + lu_mem totalbytes; /* number of bytes currently allocated */ + lu_mem estimate; /* an estimate of number of bytes actually in use */ + lu_mem gcdept; /* how much GC is `behind schedule' */ + int gcpause; /* size of pause between successive GCs */ + int gcstepmul; /* GC `granularity' */ + lua_CFunction panic; /* to be called in unprotected errors */ + TValue l_registry; + struct lua_State *mainthread; + UpVal uvhead; /* head of double-linked list of all open upvalues */ + struct Table *mt[NUM_TAGS]; /* metatables for basic types */ + TString *tmname[TM_N]; /* array with tag-method names */ +} global_State; + + +/* +** `per thread' state +*/ +struct lua_State { + CommonHeader; + lu_byte status; + StkId top; /* first free slot in the stack */ + StkId base; /* base of current function */ + global_State *l_G; + CallInfo *ci; /* call info for current function */ + const Instruction *savedpc; /* `savedpc' of current function */ + StkId stack_last; /* last free slot in the stack */ + StkId stack; /* stack base */ + CallInfo *end_ci; /* points after end of ci array*/ + CallInfo *base_ci; /* array of CallInfo's */ + int stacksize; + int size_ci; /* size of array `base_ci' */ + unsigned short nCcalls; /* number of nested C calls */ + unsigned short baseCcalls; /* nested C calls when resuming coroutine */ + lu_byte hookmask; + lu_byte allowhook; + int basehookcount; + int hookcount; + lua_Hook hook; + TValue l_gt; /* table of globals */ + TValue env; /* temporary place for environments */ + GCObject *openupval; /* list of open upvalues in this stack */ + GCObject *gclist; + struct lua_longjmp *errorJmp; /* current error recover point */ + ptrdiff_t errfunc; /* current error handling function (stack index) */ +}; + + +#define G(L) (L->l_G) + + +/* +** Union of all collectable objects +*/ +union GCObject { + GCheader gch; + union TString ts; + union Udata u; + union Closure cl; + struct Table h; + struct Proto p; + struct UpVal uv; + struct lua_State th; /* thread */ +}; + + +/* macros to convert a GCObject into a specific value */ +#define rawgco2ts(o) check_exp((o)->gch.tt == LUA_TSTRING, &((o)->ts)) +#define gco2ts(o) (&rawgco2ts(o)->tsv) +#define rawgco2u(o) check_exp((o)->gch.tt == LUA_TUSERDATA, &((o)->u)) +#define gco2u(o) (&rawgco2u(o)->uv) +#define gco2cl(o) check_exp((o)->gch.tt == LUA_TFUNCTION, &((o)->cl)) +#define gco2h(o) check_exp((o)->gch.tt == LUA_TTABLE, &((o)->h)) +#define gco2p(o) check_exp((o)->gch.tt == LUA_TPROTO, &((o)->p)) +#define gco2uv(o) check_exp((o)->gch.tt == LUA_TUPVAL, &((o)->uv)) +#define ngcotouv(o) \ + check_exp((o) == NULL || (o)->gch.tt == LUA_TUPVAL, &((o)->uv)) +#define gco2th(o) check_exp((o)->gch.tt == LUA_TTHREAD, &((o)->th)) + +/* macro to convert any Lua object into a GCObject */ +#define obj2gco(v) (cast(GCObject *, (v))) + + +LUAI_FUNC lua_State *luaE_newthread (lua_State *L); +LUAI_FUNC void luaE_freethread (lua_State *L, lua_State *L1); + +#endif + diff --git a/extern/lua-5.1.5/src/lstring.c b/extern/lua-5.1.5/src/lstring.c new file mode 100644 index 00000000..49113151 --- /dev/null +++ b/extern/lua-5.1.5/src/lstring.c @@ -0,0 +1,111 @@ +/* +** $Id: lstring.c,v 2.8.1.1 2007/12/27 13:02:25 roberto Exp $ +** String table (keeps all strings handled by Lua) +** See Copyright Notice in lua.h +*/ + + +#include + +#define lstring_c +#define LUA_CORE + +#include "lua.h" + +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" +#include "lstring.h" + + + +void luaS_resize (lua_State *L, int newsize) { + GCObject **newhash; + stringtable *tb; + int i; + if (G(L)->gcstate == GCSsweepstring) + return; /* cannot resize during GC traverse */ + newhash = luaM_newvector(L, newsize, GCObject *); + tb = &G(L)->strt; + for (i=0; isize; i++) { + GCObject *p = tb->hash[i]; + while (p) { /* for each node in the list */ + GCObject *next = p->gch.next; /* save next */ + unsigned int h = gco2ts(p)->hash; + int h1 = lmod(h, newsize); /* new position */ + lua_assert(cast_int(h%newsize) == lmod(h, newsize)); + p->gch.next = newhash[h1]; /* chain it */ + newhash[h1] = p; + p = next; + } + } + luaM_freearray(L, tb->hash, tb->size, TString *); + tb->size = newsize; + tb->hash = newhash; +} + + +static TString *newlstr (lua_State *L, const char *str, size_t l, + unsigned int h) { + TString *ts; + stringtable *tb; + if (l+1 > (MAX_SIZET - sizeof(TString))/sizeof(char)) + luaM_toobig(L); + ts = cast(TString *, luaM_malloc(L, (l+1)*sizeof(char)+sizeof(TString))); + ts->tsv.len = l; + ts->tsv.hash = h; + ts->tsv.marked = luaC_white(G(L)); + ts->tsv.tt = LUA_TSTRING; + ts->tsv.reserved = 0; + memcpy(ts+1, str, l*sizeof(char)); + ((char *)(ts+1))[l] = '\0'; /* ending 0 */ + tb = &G(L)->strt; + h = lmod(h, tb->size); + ts->tsv.next = tb->hash[h]; /* chain new entry */ + tb->hash[h] = obj2gco(ts); + tb->nuse++; + if (tb->nuse > cast(lu_int32, tb->size) && tb->size <= MAX_INT/2) + luaS_resize(L, tb->size*2); /* too crowded */ + return ts; +} + + +TString *luaS_newlstr (lua_State *L, const char *str, size_t l) { + GCObject *o; + unsigned int h = cast(unsigned int, l); /* seed */ + size_t step = (l>>5)+1; /* if string is too long, don't hash all its chars */ + size_t l1; + for (l1=l; l1>=step; l1-=step) /* compute hash */ + h = h ^ ((h<<5)+(h>>2)+cast(unsigned char, str[l1-1])); + for (o = G(L)->strt.hash[lmod(h, G(L)->strt.size)]; + o != NULL; + o = o->gch.next) { + TString *ts = rawgco2ts(o); + if (ts->tsv.len == l && (memcmp(str, getstr(ts), l) == 0)) { + /* string may be dead */ + if (isdead(G(L), o)) changewhite(o); + return ts; + } + } + return newlstr(L, str, l, h); /* not found */ +} + + +Udata *luaS_newudata (lua_State *L, size_t s, Table *e) { + Udata *u; + if (s > MAX_SIZET - sizeof(Udata)) + luaM_toobig(L); + u = cast(Udata *, luaM_malloc(L, s + sizeof(Udata))); + u->uv.marked = luaC_white(G(L)); /* is not finalized */ + u->uv.tt = LUA_TUSERDATA; + u->uv.len = s; + u->uv.metatable = NULL; + u->uv.env = e; + /* chain it on udata list (after main thread) */ + u->uv.next = G(L)->mainthread->next; + G(L)->mainthread->next = obj2gco(u); + return u; +} + diff --git a/extern/lua-5.1.5/src/lstring.h b/extern/lua-5.1.5/src/lstring.h new file mode 100644 index 00000000..73a2ff8b --- /dev/null +++ b/extern/lua-5.1.5/src/lstring.h @@ -0,0 +1,31 @@ +/* +** $Id: lstring.h,v 1.43.1.1 2007/12/27 13:02:25 roberto Exp $ +** String table (keep all strings handled by Lua) +** See Copyright Notice in lua.h +*/ + +#ifndef lstring_h +#define lstring_h + + +#include "lgc.h" +#include "lobject.h" +#include "lstate.h" + + +#define sizestring(s) (sizeof(union TString)+((s)->len+1)*sizeof(char)) + +#define sizeudata(u) (sizeof(union Udata)+(u)->len) + +#define luaS_new(L, s) (luaS_newlstr(L, s, strlen(s))) +#define luaS_newliteral(L, s) (luaS_newlstr(L, "" s, \ + (sizeof(s)/sizeof(char))-1)) + +#define luaS_fix(s) l_setbit((s)->tsv.marked, FIXEDBIT) + +LUAI_FUNC void luaS_resize (lua_State *L, int newsize); +LUAI_FUNC Udata *luaS_newudata (lua_State *L, size_t s, Table *e); +LUAI_FUNC TString *luaS_newlstr (lua_State *L, const char *str, size_t l); + + +#endif diff --git a/extern/lua-5.1.5/src/lstrlib.c b/extern/lua-5.1.5/src/lstrlib.c new file mode 100644 index 00000000..7a03489b --- /dev/null +++ b/extern/lua-5.1.5/src/lstrlib.c @@ -0,0 +1,871 @@ +/* +** $Id: lstrlib.c,v 1.132.1.5 2010/05/14 15:34:19 roberto Exp $ +** Standard library for string operations and pattern-matching +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include +#include +#include + +#define lstrlib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + +/* macro to `unsign' a character */ +#define uchar(c) ((unsigned char)(c)) + + + +static int str_len (lua_State *L) { + size_t l; + luaL_checklstring(L, 1, &l); + lua_pushinteger(L, l); + return 1; +} + + +static ptrdiff_t posrelat (ptrdiff_t pos, size_t len) { + /* relative string position: negative means back from end */ + if (pos < 0) pos += (ptrdiff_t)len + 1; + return (pos >= 0) ? pos : 0; +} + + +static int str_sub (lua_State *L) { + size_t l; + const char *s = luaL_checklstring(L, 1, &l); + ptrdiff_t start = posrelat(luaL_checkinteger(L, 2), l); + ptrdiff_t end = posrelat(luaL_optinteger(L, 3, -1), l); + if (start < 1) start = 1; + if (end > (ptrdiff_t)l) end = (ptrdiff_t)l; + if (start <= end) + lua_pushlstring(L, s+start-1, end-start+1); + else lua_pushliteral(L, ""); + return 1; +} + + +static int str_reverse (lua_State *L) { + size_t l; + luaL_Buffer b; + const char *s = luaL_checklstring(L, 1, &l); + luaL_buffinit(L, &b); + while (l--) luaL_addchar(&b, s[l]); + luaL_pushresult(&b); + return 1; +} + + +static int str_lower (lua_State *L) { + size_t l; + size_t i; + luaL_Buffer b; + const char *s = luaL_checklstring(L, 1, &l); + luaL_buffinit(L, &b); + for (i=0; i 0) + luaL_addlstring(&b, s, l); + luaL_pushresult(&b); + return 1; +} + + +static int str_byte (lua_State *L) { + size_t l; + const char *s = luaL_checklstring(L, 1, &l); + ptrdiff_t posi = posrelat(luaL_optinteger(L, 2, 1), l); + ptrdiff_t pose = posrelat(luaL_optinteger(L, 3, posi), l); + int n, i; + if (posi <= 0) posi = 1; + if ((size_t)pose > l) pose = l; + if (posi > pose) return 0; /* empty interval; return no values */ + n = (int)(pose - posi + 1); + if (posi + n <= pose) /* overflow? */ + luaL_error(L, "string slice too long"); + luaL_checkstack(L, n, "string slice too long"); + for (i=0; i= ms->level || ms->capture[l].len == CAP_UNFINISHED) + return luaL_error(ms->L, "invalid capture index"); + return l; +} + + +static int capture_to_close (MatchState *ms) { + int level = ms->level; + for (level--; level>=0; level--) + if (ms->capture[level].len == CAP_UNFINISHED) return level; + return luaL_error(ms->L, "invalid pattern capture"); +} + + +static const char *classend (MatchState *ms, const char *p) { + switch (*p++) { + case L_ESC: { + if (*p == '\0') + luaL_error(ms->L, "malformed pattern (ends with " LUA_QL("%%") ")"); + return p+1; + } + case '[': { + if (*p == '^') p++; + do { /* look for a `]' */ + if (*p == '\0') + luaL_error(ms->L, "malformed pattern (missing " LUA_QL("]") ")"); + if (*(p++) == L_ESC && *p != '\0') + p++; /* skip escapes (e.g. `%]') */ + } while (*p != ']'); + return p+1; + } + default: { + return p; + } + } +} + + +static int match_class (int c, int cl) { + int res; + switch (tolower(cl)) { + case 'a' : res = isalpha(c); break; + case 'c' : res = iscntrl(c); break; + case 'd' : res = isdigit(c); break; + case 'l' : res = islower(c); break; + case 'p' : res = ispunct(c); break; + case 's' : res = isspace(c); break; + case 'u' : res = isupper(c); break; + case 'w' : res = isalnum(c); break; + case 'x' : res = isxdigit(c); break; + case 'z' : res = (c == 0); break; + default: return (cl == c); + } + return (islower(cl) ? res : !res); +} + + +static int matchbracketclass (int c, const char *p, const char *ec) { + int sig = 1; + if (*(p+1) == '^') { + sig = 0; + p++; /* skip the `^' */ + } + while (++p < ec) { + if (*p == L_ESC) { + p++; + if (match_class(c, uchar(*p))) + return sig; + } + else if ((*(p+1) == '-') && (p+2 < ec)) { + p+=2; + if (uchar(*(p-2)) <= c && c <= uchar(*p)) + return sig; + } + else if (uchar(*p) == c) return sig; + } + return !sig; +} + + +static int singlematch (int c, const char *p, const char *ep) { + switch (*p) { + case '.': return 1; /* matches any char */ + case L_ESC: return match_class(c, uchar(*(p+1))); + case '[': return matchbracketclass(c, p, ep-1); + default: return (uchar(*p) == c); + } +} + + +static const char *match (MatchState *ms, const char *s, const char *p); + + +static const char *matchbalance (MatchState *ms, const char *s, + const char *p) { + if (*p == 0 || *(p+1) == 0) + luaL_error(ms->L, "unbalanced pattern"); + if (*s != *p) return NULL; + else { + int b = *p; + int e = *(p+1); + int cont = 1; + while (++s < ms->src_end) { + if (*s == e) { + if (--cont == 0) return s+1; + } + else if (*s == b) cont++; + } + } + return NULL; /* string ends out of balance */ +} + + +static const char *max_expand (MatchState *ms, const char *s, + const char *p, const char *ep) { + ptrdiff_t i = 0; /* counts maximum expand for item */ + while ((s+i)src_end && singlematch(uchar(*(s+i)), p, ep)) + i++; + /* keeps trying to match with the maximum repetitions */ + while (i>=0) { + const char *res = match(ms, (s+i), ep+1); + if (res) return res; + i--; /* else didn't match; reduce 1 repetition to try again */ + } + return NULL; +} + + +static const char *min_expand (MatchState *ms, const char *s, + const char *p, const char *ep) { + for (;;) { + const char *res = match(ms, s, ep+1); + if (res != NULL) + return res; + else if (ssrc_end && singlematch(uchar(*s), p, ep)) + s++; /* try with one more repetition */ + else return NULL; + } +} + + +static const char *start_capture (MatchState *ms, const char *s, + const char *p, int what) { + const char *res; + int level = ms->level; + if (level >= LUA_MAXCAPTURES) luaL_error(ms->L, "too many captures"); + ms->capture[level].init = s; + ms->capture[level].len = what; + ms->level = level+1; + if ((res=match(ms, s, p)) == NULL) /* match failed? */ + ms->level--; /* undo capture */ + return res; +} + + +static const char *end_capture (MatchState *ms, const char *s, + const char *p) { + int l = capture_to_close(ms); + const char *res; + ms->capture[l].len = s - ms->capture[l].init; /* close capture */ + if ((res = match(ms, s, p)) == NULL) /* match failed? */ + ms->capture[l].len = CAP_UNFINISHED; /* undo capture */ + return res; +} + + +static const char *match_capture (MatchState *ms, const char *s, int l) { + size_t len; + l = check_capture(ms, l); + len = ms->capture[l].len; + if ((size_t)(ms->src_end-s) >= len && + memcmp(ms->capture[l].init, s, len) == 0) + return s+len; + else return NULL; +} + + +static const char *match (MatchState *ms, const char *s, const char *p) { + init: /* using goto's to optimize tail recursion */ + switch (*p) { + case '(': { /* start capture */ + if (*(p+1) == ')') /* position capture? */ + return start_capture(ms, s, p+2, CAP_POSITION); + else + return start_capture(ms, s, p+1, CAP_UNFINISHED); + } + case ')': { /* end capture */ + return end_capture(ms, s, p+1); + } + case L_ESC: { + switch (*(p+1)) { + case 'b': { /* balanced string? */ + s = matchbalance(ms, s, p+2); + if (s == NULL) return NULL; + p+=4; goto init; /* else return match(ms, s, p+4); */ + } + case 'f': { /* frontier? */ + const char *ep; char previous; + p += 2; + if (*p != '[') + luaL_error(ms->L, "missing " LUA_QL("[") " after " + LUA_QL("%%f") " in pattern"); + ep = classend(ms, p); /* points to what is next */ + previous = (s == ms->src_init) ? '\0' : *(s-1); + if (matchbracketclass(uchar(previous), p, ep-1) || + !matchbracketclass(uchar(*s), p, ep-1)) return NULL; + p=ep; goto init; /* else return match(ms, s, ep); */ + } + default: { + if (isdigit(uchar(*(p+1)))) { /* capture results (%0-%9)? */ + s = match_capture(ms, s, uchar(*(p+1))); + if (s == NULL) return NULL; + p+=2; goto init; /* else return match(ms, s, p+2) */ + } + goto dflt; /* case default */ + } + } + } + case '\0': { /* end of pattern */ + return s; /* match succeeded */ + } + case '$': { + if (*(p+1) == '\0') /* is the `$' the last char in pattern? */ + return (s == ms->src_end) ? s : NULL; /* check end of string */ + else goto dflt; + } + default: dflt: { /* it is a pattern item */ + const char *ep = classend(ms, p); /* points to what is next */ + int m = ssrc_end && singlematch(uchar(*s), p, ep); + switch (*ep) { + case '?': { /* optional */ + const char *res; + if (m && ((res=match(ms, s+1, ep+1)) != NULL)) + return res; + p=ep+1; goto init; /* else return match(ms, s, ep+1); */ + } + case '*': { /* 0 or more repetitions */ + return max_expand(ms, s, p, ep); + } + case '+': { /* 1 or more repetitions */ + return (m ? max_expand(ms, s+1, p, ep) : NULL); + } + case '-': { /* 0 or more repetitions (minimum) */ + return min_expand(ms, s, p, ep); + } + default: { + if (!m) return NULL; + s++; p=ep; goto init; /* else return match(ms, s+1, ep); */ + } + } + } + } +} + + + +static const char *lmemfind (const char *s1, size_t l1, + const char *s2, size_t l2) { + if (l2 == 0) return s1; /* empty strings are everywhere */ + else if (l2 > l1) return NULL; /* avoids a negative `l1' */ + else { + const char *init; /* to search for a `*s2' inside `s1' */ + l2--; /* 1st char will be checked by `memchr' */ + l1 = l1-l2; /* `s2' cannot be found after that */ + while (l1 > 0 && (init = (const char *)memchr(s1, *s2, l1)) != NULL) { + init++; /* 1st char is already checked */ + if (memcmp(init, s2+1, l2) == 0) + return init-1; + else { /* correct `l1' and `s1' to try again */ + l1 -= init-s1; + s1 = init; + } + } + return NULL; /* not found */ + } +} + + +static void push_onecapture (MatchState *ms, int i, const char *s, + const char *e) { + if (i >= ms->level) { + if (i == 0) /* ms->level == 0, too */ + lua_pushlstring(ms->L, s, e - s); /* add whole match */ + else + luaL_error(ms->L, "invalid capture index"); + } + else { + ptrdiff_t l = ms->capture[i].len; + if (l == CAP_UNFINISHED) luaL_error(ms->L, "unfinished capture"); + if (l == CAP_POSITION) + lua_pushinteger(ms->L, ms->capture[i].init - ms->src_init + 1); + else + lua_pushlstring(ms->L, ms->capture[i].init, l); + } +} + + +static int push_captures (MatchState *ms, const char *s, const char *e) { + int i; + int nlevels = (ms->level == 0 && s) ? 1 : ms->level; + luaL_checkstack(ms->L, nlevels, "too many captures"); + for (i = 0; i < nlevels; i++) + push_onecapture(ms, i, s, e); + return nlevels; /* number of strings pushed */ +} + + +static int str_find_aux (lua_State *L, int find) { + size_t l1, l2; + const char *s = luaL_checklstring(L, 1, &l1); + const char *p = luaL_checklstring(L, 2, &l2); + ptrdiff_t init = posrelat(luaL_optinteger(L, 3, 1), l1) - 1; + if (init < 0) init = 0; + else if ((size_t)(init) > l1) init = (ptrdiff_t)l1; + if (find && (lua_toboolean(L, 4) || /* explicit request? */ + strpbrk(p, SPECIALS) == NULL)) { /* or no special characters? */ + /* do a plain search */ + const char *s2 = lmemfind(s+init, l1-init, p, l2); + if (s2) { + lua_pushinteger(L, s2-s+1); + lua_pushinteger(L, s2-s+l2); + return 2; + } + } + else { + MatchState ms; + int anchor = (*p == '^') ? (p++, 1) : 0; + const char *s1=s+init; + ms.L = L; + ms.src_init = s; + ms.src_end = s+l1; + do { + const char *res; + ms.level = 0; + if ((res=match(&ms, s1, p)) != NULL) { + if (find) { + lua_pushinteger(L, s1-s+1); /* start */ + lua_pushinteger(L, res-s); /* end */ + return push_captures(&ms, NULL, 0) + 2; + } + else + return push_captures(&ms, s1, res); + } + } while (s1++ < ms.src_end && !anchor); + } + lua_pushnil(L); /* not found */ + return 1; +} + + +static int str_find (lua_State *L) { + return str_find_aux(L, 1); +} + + +static int str_match (lua_State *L) { + return str_find_aux(L, 0); +} + + +static int gmatch_aux (lua_State *L) { + MatchState ms; + size_t ls; + const char *s = lua_tolstring(L, lua_upvalueindex(1), &ls); + const char *p = lua_tostring(L, lua_upvalueindex(2)); + const char *src; + ms.L = L; + ms.src_init = s; + ms.src_end = s+ls; + for (src = s + (size_t)lua_tointeger(L, lua_upvalueindex(3)); + src <= ms.src_end; + src++) { + const char *e; + ms.level = 0; + if ((e = match(&ms, src, p)) != NULL) { + lua_Integer newstart = e-s; + if (e == src) newstart++; /* empty match? go at least one position */ + lua_pushinteger(L, newstart); + lua_replace(L, lua_upvalueindex(3)); + return push_captures(&ms, src, e); + } + } + return 0; /* not found */ +} + + +static int gmatch (lua_State *L) { + luaL_checkstring(L, 1); + luaL_checkstring(L, 2); + lua_settop(L, 2); + lua_pushinteger(L, 0); + lua_pushcclosure(L, gmatch_aux, 3); + return 1; +} + + +static int gfind_nodef (lua_State *L) { + return luaL_error(L, LUA_QL("string.gfind") " was renamed to " + LUA_QL("string.gmatch")); +} + + +static void add_s (MatchState *ms, luaL_Buffer *b, const char *s, + const char *e) { + size_t l, i; + const char *news = lua_tolstring(ms->L, 3, &l); + for (i = 0; i < l; i++) { + if (news[i] != L_ESC) + luaL_addchar(b, news[i]); + else { + i++; /* skip ESC */ + if (!isdigit(uchar(news[i]))) + luaL_addchar(b, news[i]); + else if (news[i] == '0') + luaL_addlstring(b, s, e - s); + else { + push_onecapture(ms, news[i] - '1', s, e); + luaL_addvalue(b); /* add capture to accumulated result */ + } + } + } +} + + +static void add_value (MatchState *ms, luaL_Buffer *b, const char *s, + const char *e) { + lua_State *L = ms->L; + switch (lua_type(L, 3)) { + case LUA_TNUMBER: + case LUA_TSTRING: { + add_s(ms, b, s, e); + return; + } + case LUA_TFUNCTION: { + int n; + lua_pushvalue(L, 3); + n = push_captures(ms, s, e); + lua_call(L, n, 1); + break; + } + case LUA_TTABLE: { + push_onecapture(ms, 0, s, e); + lua_gettable(L, 3); + break; + } + } + if (!lua_toboolean(L, -1)) { /* nil or false? */ + lua_pop(L, 1); + lua_pushlstring(L, s, e - s); /* keep original text */ + } + else if (!lua_isstring(L, -1)) + luaL_error(L, "invalid replacement value (a %s)", luaL_typename(L, -1)); + luaL_addvalue(b); /* add result to accumulator */ +} + + +static int str_gsub (lua_State *L) { + size_t srcl; + const char *src = luaL_checklstring(L, 1, &srcl); + const char *p = luaL_checkstring(L, 2); + int tr = lua_type(L, 3); + int max_s = luaL_optint(L, 4, srcl+1); + int anchor = (*p == '^') ? (p++, 1) : 0; + int n = 0; + MatchState ms; + luaL_Buffer b; + luaL_argcheck(L, tr == LUA_TNUMBER || tr == LUA_TSTRING || + tr == LUA_TFUNCTION || tr == LUA_TTABLE, 3, + "string/function/table expected"); + luaL_buffinit(L, &b); + ms.L = L; + ms.src_init = src; + ms.src_end = src+srcl; + while (n < max_s) { + const char *e; + ms.level = 0; + e = match(&ms, src, p); + if (e) { + n++; + add_value(&ms, &b, src, e); + } + if (e && e>src) /* non empty match? */ + src = e; /* skip it */ + else if (src < ms.src_end) + luaL_addchar(&b, *src++); + else break; + if (anchor) break; + } + luaL_addlstring(&b, src, ms.src_end-src); + luaL_pushresult(&b); + lua_pushinteger(L, n); /* number of substitutions */ + return 2; +} + +/* }====================================================== */ + + +/* maximum size of each formatted item (> len(format('%99.99f', -1e308))) */ +#define MAX_ITEM 512 +/* valid flags in a format specification */ +#define FLAGS "-+ #0" +/* +** maximum size of each format specification (such as '%-099.99d') +** (+10 accounts for %99.99x plus margin of error) +*/ +#define MAX_FORMAT (sizeof(FLAGS) + sizeof(LUA_INTFRMLEN) + 10) + + +static void addquoted (lua_State *L, luaL_Buffer *b, int arg) { + size_t l; + const char *s = luaL_checklstring(L, arg, &l); + luaL_addchar(b, '"'); + while (l--) { + switch (*s) { + case '"': case '\\': case '\n': { + luaL_addchar(b, '\\'); + luaL_addchar(b, *s); + break; + } + case '\r': { + luaL_addlstring(b, "\\r", 2); + break; + } + case '\0': { + luaL_addlstring(b, "\\000", 4); + break; + } + default: { + luaL_addchar(b, *s); + break; + } + } + s++; + } + luaL_addchar(b, '"'); +} + +static const char *scanformat (lua_State *L, const char *strfrmt, char *form) { + const char *p = strfrmt; + while (*p != '\0' && strchr(FLAGS, *p) != NULL) p++; /* skip flags */ + if ((size_t)(p - strfrmt) >= sizeof(FLAGS)) + luaL_error(L, "invalid format (repeated flags)"); + if (isdigit(uchar(*p))) p++; /* skip width */ + if (isdigit(uchar(*p))) p++; /* (2 digits at most) */ + if (*p == '.') { + p++; + if (isdigit(uchar(*p))) p++; /* skip precision */ + if (isdigit(uchar(*p))) p++; /* (2 digits at most) */ + } + if (isdigit(uchar(*p))) + luaL_error(L, "invalid format (width or precision too long)"); + *(form++) = '%'; + strncpy(form, strfrmt, p - strfrmt + 1); + form += p - strfrmt + 1; + *form = '\0'; + return p; +} + + +static void addintlen (char *form) { + size_t l = strlen(form); + char spec = form[l - 1]; + strcpy(form + l - 1, LUA_INTFRMLEN); + form[l + sizeof(LUA_INTFRMLEN) - 2] = spec; + form[l + sizeof(LUA_INTFRMLEN) - 1] = '\0'; +} + + +static int str_format (lua_State *L) { + int top = lua_gettop(L); + int arg = 1; + size_t sfl; + const char *strfrmt = luaL_checklstring(L, arg, &sfl); + const char *strfrmt_end = strfrmt+sfl; + luaL_Buffer b; + luaL_buffinit(L, &b); + while (strfrmt < strfrmt_end) { + if (*strfrmt != L_ESC) + luaL_addchar(&b, *strfrmt++); + else if (*++strfrmt == L_ESC) + luaL_addchar(&b, *strfrmt++); /* %% */ + else { /* format item */ + char form[MAX_FORMAT]; /* to store the format (`%...') */ + char buff[MAX_ITEM]; /* to store the formatted item */ + if (++arg > top) + luaL_argerror(L, arg, "no value"); + strfrmt = scanformat(L, strfrmt, form); + switch (*strfrmt++) { + case 'c': { + sprintf(buff, form, (int)luaL_checknumber(L, arg)); + break; + } + case 'd': case 'i': { + addintlen(form); + sprintf(buff, form, (LUA_INTFRM_T)luaL_checknumber(L, arg)); + break; + } + case 'o': case 'u': case 'x': case 'X': { + addintlen(form); + sprintf(buff, form, (unsigned LUA_INTFRM_T)luaL_checknumber(L, arg)); + break; + } + case 'e': case 'E': case 'f': + case 'g': case 'G': { + sprintf(buff, form, (double)luaL_checknumber(L, arg)); + break; + } + case 'q': { + addquoted(L, &b, arg); + continue; /* skip the 'addsize' at the end */ + } + case 's': { + size_t l; + const char *s = luaL_checklstring(L, arg, &l); + if (!strchr(form, '.') && l >= 100) { + /* no precision and string is too long to be formatted; + keep original string */ + lua_pushvalue(L, arg); + luaL_addvalue(&b); + continue; /* skip the `addsize' at the end */ + } + else { + sprintf(buff, form, s); + break; + } + } + default: { /* also treat cases `pnLlh' */ + return luaL_error(L, "invalid option " LUA_QL("%%%c") " to " + LUA_QL("format"), *(strfrmt - 1)); + } + } + luaL_addlstring(&b, buff, strlen(buff)); + } + } + luaL_pushresult(&b); + return 1; +} + + +static const luaL_Reg strlib[] = { + {"byte", str_byte}, + {"char", str_char}, + {"dump", str_dump}, + {"find", str_find}, + {"format", str_format}, + {"gfind", gfind_nodef}, + {"gmatch", gmatch}, + {"gsub", str_gsub}, + {"len", str_len}, + {"lower", str_lower}, + {"match", str_match}, + {"rep", str_rep}, + {"reverse", str_reverse}, + {"sub", str_sub}, + {"upper", str_upper}, + {NULL, NULL} +}; + + +static void createmetatable (lua_State *L) { + lua_createtable(L, 0, 1); /* create metatable for strings */ + lua_pushliteral(L, ""); /* dummy string */ + lua_pushvalue(L, -2); + lua_setmetatable(L, -2); /* set string metatable */ + lua_pop(L, 1); /* pop dummy string */ + lua_pushvalue(L, -2); /* string library... */ + lua_setfield(L, -2, "__index"); /* ...is the __index metamethod */ + lua_pop(L, 1); /* pop metatable */ +} + + +/* +** Open string library +*/ +LUALIB_API int luaopen_string (lua_State *L) { + luaL_register(L, LUA_STRLIBNAME, strlib); +#if defined(LUA_COMPAT_GFIND) + lua_getfield(L, -1, "gmatch"); + lua_setfield(L, -2, "gfind"); +#endif + createmetatable(L); + return 1; +} + diff --git a/extern/lua-5.1.5/src/ltable.c b/extern/lua-5.1.5/src/ltable.c new file mode 100644 index 00000000..ec84f4fa --- /dev/null +++ b/extern/lua-5.1.5/src/ltable.c @@ -0,0 +1,588 @@ +/* +** $Id: ltable.c,v 2.32.1.2 2007/12/28 15:32:23 roberto Exp $ +** Lua tables (hash) +** See Copyright Notice in lua.h +*/ + + +/* +** Implementation of tables (aka arrays, objects, or hash tables). +** Tables keep its elements in two parts: an array part and a hash part. +** Non-negative integer keys are all candidates to be kept in the array +** part. The actual size of the array is the largest `n' such that at +** least half the slots between 0 and n are in use. +** Hash uses a mix of chained scatter table with Brent's variation. +** A main invariant of these tables is that, if an element is not +** in its main position (i.e. the `original' position that its hash gives +** to it), then the colliding element is in its own main position. +** Hence even when the load factor reaches 100%, performance remains good. +*/ + +#include +#include + +#define ltable_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lgc.h" +#include "lmem.h" +#include "lobject.h" +#include "lstate.h" +#include "ltable.h" + + +/* +** max size of array part is 2^MAXBITS +*/ +#if LUAI_BITSINT > 26 +#define MAXBITS 26 +#else +#define MAXBITS (LUAI_BITSINT-2) +#endif + +#define MAXASIZE (1 << MAXBITS) + + +#define hashpow2(t,n) (gnode(t, lmod((n), sizenode(t)))) + +#define hashstr(t,str) hashpow2(t, (str)->tsv.hash) +#define hashboolean(t,p) hashpow2(t, p) + + +/* +** for some types, it is better to avoid modulus by power of 2, as +** they tend to have many 2 factors. +*/ +#define hashmod(t,n) (gnode(t, ((n) % ((sizenode(t)-1)|1)))) + + +#define hashpointer(t,p) hashmod(t, IntPoint(p)) + + +/* +** number of ints inside a lua_Number +*/ +#define numints cast_int(sizeof(lua_Number)/sizeof(int)) + + + +#define dummynode (&dummynode_) + +static const Node dummynode_ = { + {{NULL}, LUA_TNIL}, /* value */ + {{{NULL}, LUA_TNIL, NULL}} /* key */ +}; + + +/* +** hash for lua_Numbers +*/ +static Node *hashnum (const Table *t, lua_Number n) { + unsigned int a[numints]; + int i; + if (luai_numeq(n, 0)) /* avoid problems with -0 */ + return gnode(t, 0); + memcpy(a, &n, sizeof(a)); + for (i = 1; i < numints; i++) a[0] += a[i]; + return hashmod(t, a[0]); +} + + + +/* +** returns the `main' position of an element in a table (that is, the index +** of its hash value) +*/ +static Node *mainposition (const Table *t, const TValue *key) { + switch (ttype(key)) { + case LUA_TNUMBER: + return hashnum(t, nvalue(key)); + case LUA_TSTRING: + return hashstr(t, rawtsvalue(key)); + case LUA_TBOOLEAN: + return hashboolean(t, bvalue(key)); + case LUA_TLIGHTUSERDATA: + return hashpointer(t, pvalue(key)); + default: + return hashpointer(t, gcvalue(key)); + } +} + + +/* +** returns the index for `key' if `key' is an appropriate key to live in +** the array part of the table, -1 otherwise. +*/ +static int arrayindex (const TValue *key) { + if (ttisnumber(key)) { + lua_Number n = nvalue(key); + int k; + lua_number2int(k, n); + if (luai_numeq(cast_num(k), n)) + return k; + } + return -1; /* `key' did not match some condition */ +} + + +/* +** returns the index of a `key' for table traversals. First goes all +** elements in the array part, then elements in the hash part. The +** beginning of a traversal is signalled by -1. +*/ +static int findindex (lua_State *L, Table *t, StkId key) { + int i; + if (ttisnil(key)) return -1; /* first iteration */ + i = arrayindex(key); + if (0 < i && i <= t->sizearray) /* is `key' inside array part? */ + return i-1; /* yes; that's the index (corrected to C) */ + else { + Node *n = mainposition(t, key); + do { /* check whether `key' is somewhere in the chain */ + /* key may be dead already, but it is ok to use it in `next' */ + if (luaO_rawequalObj(key2tval(n), key) || + (ttype(gkey(n)) == LUA_TDEADKEY && iscollectable(key) && + gcvalue(gkey(n)) == gcvalue(key))) { + i = cast_int(n - gnode(t, 0)); /* key index in hash table */ + /* hash elements are numbered after array ones */ + return i + t->sizearray; + } + else n = gnext(n); + } while (n); + luaG_runerror(L, "invalid key to " LUA_QL("next")); /* key not found */ + return 0; /* to avoid warnings */ + } +} + + +int luaH_next (lua_State *L, Table *t, StkId key) { + int i = findindex(L, t, key); /* find original element */ + for (i++; i < t->sizearray; i++) { /* try first array part */ + if (!ttisnil(&t->array[i])) { /* a non-nil value? */ + setnvalue(key, cast_num(i+1)); + setobj2s(L, key+1, &t->array[i]); + return 1; + } + } + for (i -= t->sizearray; i < sizenode(t); i++) { /* then hash part */ + if (!ttisnil(gval(gnode(t, i)))) { /* a non-nil value? */ + setobj2s(L, key, key2tval(gnode(t, i))); + setobj2s(L, key+1, gval(gnode(t, i))); + return 1; + } + } + return 0; /* no more elements */ +} + + +/* +** {============================================================= +** Rehash +** ============================================================== +*/ + + +static int computesizes (int nums[], int *narray) { + int i; + int twotoi; /* 2^i */ + int a = 0; /* number of elements smaller than 2^i */ + int na = 0; /* number of elements to go to array part */ + int n = 0; /* optimal size for array part */ + for (i = 0, twotoi = 1; twotoi/2 < *narray; i++, twotoi *= 2) { + if (nums[i] > 0) { + a += nums[i]; + if (a > twotoi/2) { /* more than half elements present? */ + n = twotoi; /* optimal size (till now) */ + na = a; /* all elements smaller than n will go to array part */ + } + } + if (a == *narray) break; /* all elements already counted */ + } + *narray = n; + lua_assert(*narray/2 <= na && na <= *narray); + return na; +} + + +static int countint (const TValue *key, int *nums) { + int k = arrayindex(key); + if (0 < k && k <= MAXASIZE) { /* is `key' an appropriate array index? */ + nums[ceillog2(k)]++; /* count as such */ + return 1; + } + else + return 0; +} + + +static int numusearray (const Table *t, int *nums) { + int lg; + int ttlg; /* 2^lg */ + int ause = 0; /* summation of `nums' */ + int i = 1; /* count to traverse all array keys */ + for (lg=0, ttlg=1; lg<=MAXBITS; lg++, ttlg*=2) { /* for each slice */ + int lc = 0; /* counter */ + int lim = ttlg; + if (lim > t->sizearray) { + lim = t->sizearray; /* adjust upper limit */ + if (i > lim) + break; /* no more elements to count */ + } + /* count elements in range (2^(lg-1), 2^lg] */ + for (; i <= lim; i++) { + if (!ttisnil(&t->array[i-1])) + lc++; + } + nums[lg] += lc; + ause += lc; + } + return ause; +} + + +static int numusehash (const Table *t, int *nums, int *pnasize) { + int totaluse = 0; /* total number of elements */ + int ause = 0; /* summation of `nums' */ + int i = sizenode(t); + while (i--) { + Node *n = &t->node[i]; + if (!ttisnil(gval(n))) { + ause += countint(key2tval(n), nums); + totaluse++; + } + } + *pnasize += ause; + return totaluse; +} + + +static void setarrayvector (lua_State *L, Table *t, int size) { + int i; + luaM_reallocvector(L, t->array, t->sizearray, size, TValue); + for (i=t->sizearray; iarray[i]); + t->sizearray = size; +} + + +static void setnodevector (lua_State *L, Table *t, int size) { + int lsize; + if (size == 0) { /* no elements to hash part? */ + t->node = cast(Node *, dummynode); /* use common `dummynode' */ + lsize = 0; + } + else { + int i; + lsize = ceillog2(size); + if (lsize > MAXBITS) + luaG_runerror(L, "table overflow"); + size = twoto(lsize); + t->node = luaM_newvector(L, size, Node); + for (i=0; ilsizenode = cast_byte(lsize); + t->lastfree = gnode(t, size); /* all positions are free */ +} + + +static void resize (lua_State *L, Table *t, int nasize, int nhsize) { + int i; + int oldasize = t->sizearray; + int oldhsize = t->lsizenode; + Node *nold = t->node; /* save old hash ... */ + if (nasize > oldasize) /* array part must grow? */ + setarrayvector(L, t, nasize); + /* create new hash part with appropriate size */ + setnodevector(L, t, nhsize); + if (nasize < oldasize) { /* array part must shrink? */ + t->sizearray = nasize; + /* re-insert elements from vanishing slice */ + for (i=nasize; iarray[i])) + setobjt2t(L, luaH_setnum(L, t, i+1), &t->array[i]); + } + /* shrink array */ + luaM_reallocvector(L, t->array, oldasize, nasize, TValue); + } + /* re-insert elements from hash part */ + for (i = twoto(oldhsize) - 1; i >= 0; i--) { + Node *old = nold+i; + if (!ttisnil(gval(old))) + setobjt2t(L, luaH_set(L, t, key2tval(old)), gval(old)); + } + if (nold != dummynode) + luaM_freearray(L, nold, twoto(oldhsize), Node); /* free old array */ +} + + +void luaH_resizearray (lua_State *L, Table *t, int nasize) { + int nsize = (t->node == dummynode) ? 0 : sizenode(t); + resize(L, t, nasize, nsize); +} + + +static void rehash (lua_State *L, Table *t, const TValue *ek) { + int nasize, na; + int nums[MAXBITS+1]; /* nums[i] = number of keys between 2^(i-1) and 2^i */ + int i; + int totaluse; + for (i=0; i<=MAXBITS; i++) nums[i] = 0; /* reset counts */ + nasize = numusearray(t, nums); /* count keys in array part */ + totaluse = nasize; /* all those keys are integer keys */ + totaluse += numusehash(t, nums, &nasize); /* count keys in hash part */ + /* count extra key */ + nasize += countint(ek, nums); + totaluse++; + /* compute new size for array part */ + na = computesizes(nums, &nasize); + /* resize the table to new computed sizes */ + resize(L, t, nasize, totaluse - na); +} + + + +/* +** }============================================================= +*/ + + +Table *luaH_new (lua_State *L, int narray, int nhash) { + Table *t = luaM_new(L, Table); + luaC_link(L, obj2gco(t), LUA_TTABLE); + t->metatable = NULL; + t->flags = cast_byte(~0); + /* temporary values (kept only if some malloc fails) */ + t->array = NULL; + t->sizearray = 0; + t->lsizenode = 0; + t->node = cast(Node *, dummynode); + setarrayvector(L, t, narray); + setnodevector(L, t, nhash); + return t; +} + + +void luaH_free (lua_State *L, Table *t) { + if (t->node != dummynode) + luaM_freearray(L, t->node, sizenode(t), Node); + luaM_freearray(L, t->array, t->sizearray, TValue); + luaM_free(L, t); +} + + +static Node *getfreepos (Table *t) { + while (t->lastfree-- > t->node) { + if (ttisnil(gkey(t->lastfree))) + return t->lastfree; + } + return NULL; /* could not find a free place */ +} + + + +/* +** inserts a new key into a hash table; first, check whether key's main +** position is free. If not, check whether colliding node is in its main +** position or not: if it is not, move colliding node to an empty place and +** put new key in its main position; otherwise (colliding node is in its main +** position), new key goes to an empty position. +*/ +static TValue *newkey (lua_State *L, Table *t, const TValue *key) { + Node *mp = mainposition(t, key); + if (!ttisnil(gval(mp)) || mp == dummynode) { + Node *othern; + Node *n = getfreepos(t); /* get a free place */ + if (n == NULL) { /* cannot find a free place? */ + rehash(L, t, key); /* grow table */ + return luaH_set(L, t, key); /* re-insert key into grown table */ + } + lua_assert(n != dummynode); + othern = mainposition(t, key2tval(mp)); + if (othern != mp) { /* is colliding node out of its main position? */ + /* yes; move colliding node into free position */ + while (gnext(othern) != mp) othern = gnext(othern); /* find previous */ + gnext(othern) = n; /* redo the chain with `n' in place of `mp' */ + *n = *mp; /* copy colliding node into free pos. (mp->next also goes) */ + gnext(mp) = NULL; /* now `mp' is free */ + setnilvalue(gval(mp)); + } + else { /* colliding node is in its own main position */ + /* new node will go into free position */ + gnext(n) = gnext(mp); /* chain new position */ + gnext(mp) = n; + mp = n; + } + } + gkey(mp)->value = key->value; gkey(mp)->tt = key->tt; + luaC_barriert(L, t, key); + lua_assert(ttisnil(gval(mp))); + return gval(mp); +} + + +/* +** search function for integers +*/ +const TValue *luaH_getnum (Table *t, int key) { + /* (1 <= key && key <= t->sizearray) */ + if (cast(unsigned int, key-1) < cast(unsigned int, t->sizearray)) + return &t->array[key-1]; + else { + lua_Number nk = cast_num(key); + Node *n = hashnum(t, nk); + do { /* check whether `key' is somewhere in the chain */ + if (ttisnumber(gkey(n)) && luai_numeq(nvalue(gkey(n)), nk)) + return gval(n); /* that's it */ + else n = gnext(n); + } while (n); + return luaO_nilobject; + } +} + + +/* +** search function for strings +*/ +const TValue *luaH_getstr (Table *t, TString *key) { + Node *n = hashstr(t, key); + do { /* check whether `key' is somewhere in the chain */ + if (ttisstring(gkey(n)) && rawtsvalue(gkey(n)) == key) + return gval(n); /* that's it */ + else n = gnext(n); + } while (n); + return luaO_nilobject; +} + + +/* +** main search function +*/ +const TValue *luaH_get (Table *t, const TValue *key) { + switch (ttype(key)) { + case LUA_TNIL: return luaO_nilobject; + case LUA_TSTRING: return luaH_getstr(t, rawtsvalue(key)); + case LUA_TNUMBER: { + int k; + lua_Number n = nvalue(key); + lua_number2int(k, n); + if (luai_numeq(cast_num(k), nvalue(key))) /* index is int? */ + return luaH_getnum(t, k); /* use specialized version */ + /* else go through */ + } + default: { + Node *n = mainposition(t, key); + do { /* check whether `key' is somewhere in the chain */ + if (luaO_rawequalObj(key2tval(n), key)) + return gval(n); /* that's it */ + else n = gnext(n); + } while (n); + return luaO_nilobject; + } + } +} + + +TValue *luaH_set (lua_State *L, Table *t, const TValue *key) { + const TValue *p = luaH_get(t, key); + t->flags = 0; + if (p != luaO_nilobject) + return cast(TValue *, p); + else { + if (ttisnil(key)) luaG_runerror(L, "table index is nil"); + else if (ttisnumber(key) && luai_numisnan(nvalue(key))) + luaG_runerror(L, "table index is NaN"); + return newkey(L, t, key); + } +} + + +TValue *luaH_setnum (lua_State *L, Table *t, int key) { + const TValue *p = luaH_getnum(t, key); + if (p != luaO_nilobject) + return cast(TValue *, p); + else { + TValue k; + setnvalue(&k, cast_num(key)); + return newkey(L, t, &k); + } +} + + +TValue *luaH_setstr (lua_State *L, Table *t, TString *key) { + const TValue *p = luaH_getstr(t, key); + if (p != luaO_nilobject) + return cast(TValue *, p); + else { + TValue k; + setsvalue(L, &k, key); + return newkey(L, t, &k); + } +} + + +static int unbound_search (Table *t, unsigned int j) { + unsigned int i = j; /* i is zero or a present index */ + j++; + /* find `i' and `j' such that i is present and j is not */ + while (!ttisnil(luaH_getnum(t, j))) { + i = j; + j *= 2; + if (j > cast(unsigned int, MAX_INT)) { /* overflow? */ + /* table was built with bad purposes: resort to linear search */ + i = 1; + while (!ttisnil(luaH_getnum(t, i))) i++; + return i - 1; + } + } + /* now do a binary search between them */ + while (j - i > 1) { + unsigned int m = (i+j)/2; + if (ttisnil(luaH_getnum(t, m))) j = m; + else i = m; + } + return i; +} + + +/* +** Try to find a boundary in table `t'. A `boundary' is an integer index +** such that t[i] is non-nil and t[i+1] is nil (and 0 if t[1] is nil). +*/ +int luaH_getn (Table *t) { + unsigned int j = t->sizearray; + if (j > 0 && ttisnil(&t->array[j - 1])) { + /* there is a boundary in the array part: (binary) search for it */ + unsigned int i = 0; + while (j - i > 1) { + unsigned int m = (i+j)/2; + if (ttisnil(&t->array[m - 1])) j = m; + else i = m; + } + return i; + } + /* else must find a boundary in hash part */ + else if (t->node == dummynode) /* hash part is empty? */ + return j; /* that is easy... */ + else return unbound_search(t, j); +} + + + +#if defined(LUA_DEBUG) + +Node *luaH_mainposition (const Table *t, const TValue *key) { + return mainposition(t, key); +} + +int luaH_isdummy (Node *n) { return n == dummynode; } + +#endif diff --git a/extern/lua-5.1.5/src/ltable.h b/extern/lua-5.1.5/src/ltable.h new file mode 100644 index 00000000..f5b9d5ea --- /dev/null +++ b/extern/lua-5.1.5/src/ltable.h @@ -0,0 +1,40 @@ +/* +** $Id: ltable.h,v 2.10.1.1 2007/12/27 13:02:25 roberto Exp $ +** Lua tables (hash) +** See Copyright Notice in lua.h +*/ + +#ifndef ltable_h +#define ltable_h + +#include "lobject.h" + + +#define gnode(t,i) (&(t)->node[i]) +#define gkey(n) (&(n)->i_key.nk) +#define gval(n) (&(n)->i_val) +#define gnext(n) ((n)->i_key.nk.next) + +#define key2tval(n) (&(n)->i_key.tvk) + + +LUAI_FUNC const TValue *luaH_getnum (Table *t, int key); +LUAI_FUNC TValue *luaH_setnum (lua_State *L, Table *t, int key); +LUAI_FUNC const TValue *luaH_getstr (Table *t, TString *key); +LUAI_FUNC TValue *luaH_setstr (lua_State *L, Table *t, TString *key); +LUAI_FUNC const TValue *luaH_get (Table *t, const TValue *key); +LUAI_FUNC TValue *luaH_set (lua_State *L, Table *t, const TValue *key); +LUAI_FUNC Table *luaH_new (lua_State *L, int narray, int lnhash); +LUAI_FUNC void luaH_resizearray (lua_State *L, Table *t, int nasize); +LUAI_FUNC void luaH_free (lua_State *L, Table *t); +LUAI_FUNC int luaH_next (lua_State *L, Table *t, StkId key); +LUAI_FUNC int luaH_getn (Table *t); + + +#if defined(LUA_DEBUG) +LUAI_FUNC Node *luaH_mainposition (const Table *t, const TValue *key); +LUAI_FUNC int luaH_isdummy (Node *n); +#endif + + +#endif diff --git a/extern/lua-5.1.5/src/ltablib.c b/extern/lua-5.1.5/src/ltablib.c new file mode 100644 index 00000000..b6d9cb4a --- /dev/null +++ b/extern/lua-5.1.5/src/ltablib.c @@ -0,0 +1,287 @@ +/* +** $Id: ltablib.c,v 1.38.1.3 2008/02/14 16:46:58 roberto Exp $ +** Library for Table Manipulation +** See Copyright Notice in lua.h +*/ + + +#include + +#define ltablib_c +#define LUA_LIB + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + +#define aux_getn(L,n) (luaL_checktype(L, n, LUA_TTABLE), luaL_getn(L, n)) + + +static int foreachi (lua_State *L) { + int i; + int n = aux_getn(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + for (i=1; i <= n; i++) { + lua_pushvalue(L, 2); /* function */ + lua_pushinteger(L, i); /* 1st argument */ + lua_rawgeti(L, 1, i); /* 2nd argument */ + lua_call(L, 2, 1); + if (!lua_isnil(L, -1)) + return 1; + lua_pop(L, 1); /* remove nil result */ + } + return 0; +} + + +static int foreach (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); + luaL_checktype(L, 2, LUA_TFUNCTION); + lua_pushnil(L); /* first key */ + while (lua_next(L, 1)) { + lua_pushvalue(L, 2); /* function */ + lua_pushvalue(L, -3); /* key */ + lua_pushvalue(L, -3); /* value */ + lua_call(L, 2, 1); + if (!lua_isnil(L, -1)) + return 1; + lua_pop(L, 2); /* remove value and result */ + } + return 0; +} + + +static int maxn (lua_State *L) { + lua_Number max = 0; + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnil(L); /* first key */ + while (lua_next(L, 1)) { + lua_pop(L, 1); /* remove value */ + if (lua_type(L, -1) == LUA_TNUMBER) { + lua_Number v = lua_tonumber(L, -1); + if (v > max) max = v; + } + } + lua_pushnumber(L, max); + return 1; +} + + +static int getn (lua_State *L) { + lua_pushinteger(L, aux_getn(L, 1)); + return 1; +} + + +static int setn (lua_State *L) { + luaL_checktype(L, 1, LUA_TTABLE); +#ifndef luaL_setn + luaL_setn(L, 1, luaL_checkint(L, 2)); +#else + luaL_error(L, LUA_QL("setn") " is obsolete"); +#endif + lua_pushvalue(L, 1); + return 1; +} + + +static int tinsert (lua_State *L) { + int e = aux_getn(L, 1) + 1; /* first empty element */ + int pos; /* where to insert new element */ + switch (lua_gettop(L)) { + case 2: { /* called with only 2 arguments */ + pos = e; /* insert new element at the end */ + break; + } + case 3: { + int i; + pos = luaL_checkint(L, 2); /* 2nd argument is the position */ + if (pos > e) e = pos; /* `grow' array if necessary */ + for (i = e; i > pos; i--) { /* move up elements */ + lua_rawgeti(L, 1, i-1); + lua_rawseti(L, 1, i); /* t[i] = t[i-1] */ + } + break; + } + default: { + return luaL_error(L, "wrong number of arguments to " LUA_QL("insert")); + } + } + luaL_setn(L, 1, e); /* new size */ + lua_rawseti(L, 1, pos); /* t[pos] = v */ + return 0; +} + + +static int tremove (lua_State *L) { + int e = aux_getn(L, 1); + int pos = luaL_optint(L, 2, e); + if (!(1 <= pos && pos <= e)) /* position is outside bounds? */ + return 0; /* nothing to remove */ + luaL_setn(L, 1, e - 1); /* t.n = n-1 */ + lua_rawgeti(L, 1, pos); /* result = t[pos] */ + for ( ;pos= P */ + while (lua_rawgeti(L, 1, ++i), sort_comp(L, -1, -2)) { + if (i>u) luaL_error(L, "invalid order function for sorting"); + lua_pop(L, 1); /* remove a[i] */ + } + /* repeat --j until a[j] <= P */ + while (lua_rawgeti(L, 1, --j), sort_comp(L, -3, -1)) { + if (j + +#define ltm_c +#define LUA_CORE + +#include "lua.h" + +#include "lobject.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" + + + +const char *const luaT_typenames[] = { + "nil", "boolean", "userdata", "number", + "string", "table", "function", "userdata", "thread", + "proto", "upval" +}; + + +void luaT_init (lua_State *L) { + static const char *const luaT_eventname[] = { /* ORDER TM */ + "__index", "__newindex", + "__gc", "__mode", "__eq", + "__add", "__sub", "__mul", "__div", "__mod", + "__pow", "__unm", "__len", "__lt", "__le", + "__concat", "__call" + }; + int i; + for (i=0; itmname[i] = luaS_new(L, luaT_eventname[i]); + luaS_fix(G(L)->tmname[i]); /* never collect these names */ + } +} + + +/* +** function to be used with macro "fasttm": optimized for absence of +** tag methods +*/ +const TValue *luaT_gettm (Table *events, TMS event, TString *ename) { + const TValue *tm = luaH_getstr(events, ename); + lua_assert(event <= TM_EQ); + if (ttisnil(tm)) { /* no tag method? */ + events->flags |= cast_byte(1u<metatable; + break; + case LUA_TUSERDATA: + mt = uvalue(o)->metatable; + break; + default: + mt = G(L)->mt[ttype(o)]; + } + return (mt ? luaH_getstr(mt, G(L)->tmname[event]) : luaO_nilobject); +} + diff --git a/extern/lua-5.1.5/src/ltm.h b/extern/lua-5.1.5/src/ltm.h new file mode 100644 index 00000000..64343b78 --- /dev/null +++ b/extern/lua-5.1.5/src/ltm.h @@ -0,0 +1,54 @@ +/* +** $Id: ltm.h,v 2.6.1.1 2007/12/27 13:02:25 roberto Exp $ +** Tag methods +** See Copyright Notice in lua.h +*/ + +#ifndef ltm_h +#define ltm_h + + +#include "lobject.h" + + +/* +* WARNING: if you change the order of this enumeration, +* grep "ORDER TM" +*/ +typedef enum { + TM_INDEX, + TM_NEWINDEX, + TM_GC, + TM_MODE, + TM_EQ, /* last tag method with `fast' access */ + TM_ADD, + TM_SUB, + TM_MUL, + TM_DIV, + TM_MOD, + TM_POW, + TM_UNM, + TM_LEN, + TM_LT, + TM_LE, + TM_CONCAT, + TM_CALL, + TM_N /* number of elements in the enum */ +} TMS; + + + +#define gfasttm(g,et,e) ((et) == NULL ? NULL : \ + ((et)->flags & (1u<<(e))) ? NULL : luaT_gettm(et, e, (g)->tmname[e])) + +#define fasttm(l,et,e) gfasttm(G(l), et, e) + +LUAI_DATA const char *const luaT_typenames[]; + + +LUAI_FUNC const TValue *luaT_gettm (Table *events, TMS event, TString *ename); +LUAI_FUNC const TValue *luaT_gettmbyobj (lua_State *L, const TValue *o, + TMS event); +LUAI_FUNC void luaT_init (lua_State *L); + +#endif diff --git a/extern/lua-5.1.5/src/lua.c b/extern/lua-5.1.5/src/lua.c new file mode 100644 index 00000000..3a466093 --- /dev/null +++ b/extern/lua-5.1.5/src/lua.c @@ -0,0 +1,392 @@ +/* +** $Id: lua.c,v 1.160.1.2 2007/12/28 15:32:23 roberto Exp $ +** Lua stand-alone interpreter +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include +#include + +#define lua_c + +#include "lua.h" + +#include "lauxlib.h" +#include "lualib.h" + + + +static lua_State *globalL = NULL; + +static const char *progname = LUA_PROGNAME; + + + +static void lstop (lua_State *L, lua_Debug *ar) { + (void)ar; /* unused arg. */ + lua_sethook(L, NULL, 0, 0); + luaL_error(L, "interrupted!"); +} + + +static void laction (int i) { + signal(i, SIG_DFL); /* if another SIGINT happens before lstop, + terminate process (default action) */ + lua_sethook(globalL, lstop, LUA_MASKCALL | LUA_MASKRET | LUA_MASKCOUNT, 1); +} + + +static void print_usage (void) { + fprintf(stderr, + "usage: %s [options] [script [args]].\n" + "Available options are:\n" + " -e stat execute string " LUA_QL("stat") "\n" + " -l name require library " LUA_QL("name") "\n" + " -i enter interactive mode after executing " LUA_QL("script") "\n" + " -v show version information\n" + " -- stop handling options\n" + " - execute stdin and stop handling options\n" + , + progname); + fflush(stderr); +} + + +static void l_message (const char *pname, const char *msg) { + if (pname) fprintf(stderr, "%s: ", pname); + fprintf(stderr, "%s\n", msg); + fflush(stderr); +} + + +static int report (lua_State *L, int status) { + if (status && !lua_isnil(L, -1)) { + const char *msg = lua_tostring(L, -1); + if (msg == NULL) msg = "(error object is not a string)"; + l_message(progname, msg); + lua_pop(L, 1); + } + return status; +} + + +static int traceback (lua_State *L) { + if (!lua_isstring(L, 1)) /* 'message' not a string? */ + return 1; /* keep it intact */ + lua_getfield(L, LUA_GLOBALSINDEX, "debug"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + return 1; + } + lua_getfield(L, -1, "traceback"); + if (!lua_isfunction(L, -1)) { + lua_pop(L, 2); + return 1; + } + lua_pushvalue(L, 1); /* pass error message */ + lua_pushinteger(L, 2); /* skip this function and traceback */ + lua_call(L, 2, 1); /* call debug.traceback */ + return 1; +} + + +static int docall (lua_State *L, int narg, int clear) { + int status; + int base = lua_gettop(L) - narg; /* function index */ + lua_pushcfunction(L, traceback); /* push traceback function */ + lua_insert(L, base); /* put it under chunk and args */ + signal(SIGINT, laction); + status = lua_pcall(L, narg, (clear ? 0 : LUA_MULTRET), base); + signal(SIGINT, SIG_DFL); + lua_remove(L, base); /* remove traceback function */ + /* force a complete garbage collection in case of errors */ + if (status != 0) lua_gc(L, LUA_GCCOLLECT, 0); + return status; +} + + +static void print_version (void) { + l_message(NULL, LUA_RELEASE " " LUA_COPYRIGHT); +} + + +static int getargs (lua_State *L, char **argv, int n) { + int narg; + int i; + int argc = 0; + while (argv[argc]) argc++; /* count total number of arguments */ + narg = argc - (n + 1); /* number of arguments to the script */ + luaL_checkstack(L, narg + 3, "too many arguments to script"); + for (i=n+1; i < argc; i++) + lua_pushstring(L, argv[i]); + lua_createtable(L, narg, n + 1); + for (i=0; i < argc; i++) { + lua_pushstring(L, argv[i]); + lua_rawseti(L, -2, i - n); + } + return narg; +} + + +static int dofile (lua_State *L, const char *name) { + int status = luaL_loadfile(L, name) || docall(L, 0, 1); + return report(L, status); +} + + +static int dostring (lua_State *L, const char *s, const char *name) { + int status = luaL_loadbuffer(L, s, strlen(s), name) || docall(L, 0, 1); + return report(L, status); +} + + +static int dolibrary (lua_State *L, const char *name) { + lua_getglobal(L, "require"); + lua_pushstring(L, name); + return report(L, docall(L, 1, 1)); +} + + +static const char *get_prompt (lua_State *L, int firstline) { + const char *p; + lua_getfield(L, LUA_GLOBALSINDEX, firstline ? "_PROMPT" : "_PROMPT2"); + p = lua_tostring(L, -1); + if (p == NULL) p = (firstline ? LUA_PROMPT : LUA_PROMPT2); + lua_pop(L, 1); /* remove global */ + return p; +} + + +static int incomplete (lua_State *L, int status) { + if (status == LUA_ERRSYNTAX) { + size_t lmsg; + const char *msg = lua_tolstring(L, -1, &lmsg); + const char *tp = msg + lmsg - (sizeof(LUA_QL("")) - 1); + if (strstr(msg, LUA_QL("")) == tp) { + lua_pop(L, 1); + return 1; + } + } + return 0; /* else... */ +} + + +static int pushline (lua_State *L, int firstline) { + char buffer[LUA_MAXINPUT]; + char *b = buffer; + size_t l; + const char *prmt = get_prompt(L, firstline); + if (lua_readline(L, b, prmt) == 0) + return 0; /* no input */ + l = strlen(b); + if (l > 0 && b[l-1] == '\n') /* line ends with newline? */ + b[l-1] = '\0'; /* remove it */ + if (firstline && b[0] == '=') /* first line starts with `=' ? */ + lua_pushfstring(L, "return %s", b+1); /* change it to `return' */ + else + lua_pushstring(L, b); + lua_freeline(L, b); + return 1; +} + + +static int loadline (lua_State *L) { + int status; + lua_settop(L, 0); + if (!pushline(L, 1)) + return -1; /* no input */ + for (;;) { /* repeat until gets a complete line */ + status = luaL_loadbuffer(L, lua_tostring(L, 1), lua_strlen(L, 1), "=stdin"); + if (!incomplete(L, status)) break; /* cannot try to add lines? */ + if (!pushline(L, 0)) /* no more input? */ + return -1; + lua_pushliteral(L, "\n"); /* add a new line... */ + lua_insert(L, -2); /* ...between the two lines */ + lua_concat(L, 3); /* join them */ + } + lua_saveline(L, 1); + lua_remove(L, 1); /* remove line */ + return status; +} + + +static void dotty (lua_State *L) { + int status; + const char *oldprogname = progname; + progname = NULL; + while ((status = loadline(L)) != -1) { + if (status == 0) status = docall(L, 0, 0); + report(L, status); + if (status == 0 && lua_gettop(L) > 0) { /* any result to print? */ + lua_getglobal(L, "print"); + lua_insert(L, 1); + if (lua_pcall(L, lua_gettop(L)-1, 0, 0) != 0) + l_message(progname, lua_pushfstring(L, + "error calling " LUA_QL("print") " (%s)", + lua_tostring(L, -1))); + } + } + lua_settop(L, 0); /* clear stack */ + fputs("\n", stdout); + fflush(stdout); + progname = oldprogname; +} + + +static int handle_script (lua_State *L, char **argv, int n) { + int status; + const char *fname; + int narg = getargs(L, argv, n); /* collect arguments */ + lua_setglobal(L, "arg"); + fname = argv[n]; + if (strcmp(fname, "-") == 0 && strcmp(argv[n-1], "--") != 0) + fname = NULL; /* stdin */ + status = luaL_loadfile(L, fname); + lua_insert(L, -(narg+1)); + if (status == 0) + status = docall(L, narg, 0); + else + lua_pop(L, narg); + return report(L, status); +} + + +/* check that argument has no extra characters at the end */ +#define notail(x) {if ((x)[2] != '\0') return -1;} + + +static int collectargs (char **argv, int *pi, int *pv, int *pe) { + int i; + for (i = 1; argv[i] != NULL; i++) { + if (argv[i][0] != '-') /* not an option? */ + return i; + switch (argv[i][1]) { /* option */ + case '-': + notail(argv[i]); + return (argv[i+1] != NULL ? i+1 : 0); + case '\0': + return i; + case 'i': + notail(argv[i]); + *pi = 1; /* go through */ + case 'v': + notail(argv[i]); + *pv = 1; + break; + case 'e': + *pe = 1; /* go through */ + case 'l': + if (argv[i][2] == '\0') { + i++; + if (argv[i] == NULL) return -1; + } + break; + default: return -1; /* invalid option */ + } + } + return 0; +} + + +static int runargs (lua_State *L, char **argv, int n) { + int i; + for (i = 1; i < n; i++) { + if (argv[i] == NULL) continue; + lua_assert(argv[i][0] == '-'); + switch (argv[i][1]) { /* option */ + case 'e': { + const char *chunk = argv[i] + 2; + if (*chunk == '\0') chunk = argv[++i]; + lua_assert(chunk != NULL); + if (dostring(L, chunk, "=(command line)") != 0) + return 1; + break; + } + case 'l': { + const char *filename = argv[i] + 2; + if (*filename == '\0') filename = argv[++i]; + lua_assert(filename != NULL); + if (dolibrary(L, filename)) + return 1; /* stop if file fails */ + break; + } + default: break; + } + } + return 0; +} + + +static int handle_luainit (lua_State *L) { + const char *init = getenv(LUA_INIT); + if (init == NULL) return 0; /* status OK */ + else if (init[0] == '@') + return dofile(L, init+1); + else + return dostring(L, init, "=" LUA_INIT); +} + + +struct Smain { + int argc; + char **argv; + int status; +}; + + +static int pmain (lua_State *L) { + struct Smain *s = (struct Smain *)lua_touserdata(L, 1); + char **argv = s->argv; + int script; + int has_i = 0, has_v = 0, has_e = 0; + globalL = L; + if (argv[0] && argv[0][0]) progname = argv[0]; + lua_gc(L, LUA_GCSTOP, 0); /* stop collector during initialization */ + luaL_openlibs(L); /* open libraries */ + lua_gc(L, LUA_GCRESTART, 0); + s->status = handle_luainit(L); + if (s->status != 0) return 0; + script = collectargs(argv, &has_i, &has_v, &has_e); + if (script < 0) { /* invalid args? */ + print_usage(); + s->status = 1; + return 0; + } + if (has_v) print_version(); + s->status = runargs(L, argv, (script > 0) ? script : s->argc); + if (s->status != 0) return 0; + if (script) + s->status = handle_script(L, argv, script); + if (s->status != 0) return 0; + if (has_i) + dotty(L); + else if (script == 0 && !has_e && !has_v) { + if (lua_stdin_is_tty()) { + print_version(); + dotty(L); + } + else dofile(L, NULL); /* executes stdin as a file */ + } + return 0; +} + + +int main (int argc, char **argv) { + int status; + struct Smain s; + lua_State *L = lua_open(); /* create state */ + if (L == NULL) { + l_message(argv[0], "cannot create state: not enough memory"); + return EXIT_FAILURE; + } + s.argc = argc; + s.argv = argv; + status = lua_cpcall(L, &pmain, &s); + report(L, status); + lua_close(L); + return (status || s.status) ? EXIT_FAILURE : EXIT_SUCCESS; +} + diff --git a/extern/lua-5.1.5/src/lua.h b/extern/lua-5.1.5/src/lua.h new file mode 100644 index 00000000..a4b73e74 --- /dev/null +++ b/extern/lua-5.1.5/src/lua.h @@ -0,0 +1,388 @@ +/* +** $Id: lua.h,v 1.218.1.7 2012/01/13 20:36:20 roberto Exp $ +** Lua - An Extensible Extension Language +** Lua.org, PUC-Rio, Brazil (http://www.lua.org) +** See Copyright Notice at the end of this file +*/ + + +#ifndef lua_h +#define lua_h + +#include +#include + + +#include "luaconf.h" + + +#define LUA_VERSION "Lua 5.1" +#define LUA_RELEASE "Lua 5.1.5" +#define LUA_VERSION_NUM 501 +#define LUA_COPYRIGHT "Copyright (C) 1994-2012 Lua.org, PUC-Rio" +#define LUA_AUTHORS "R. Ierusalimschy, L. H. de Figueiredo & W. Celes" + + +/* mark for precompiled code (`Lua') */ +#define LUA_SIGNATURE "\033Lua" + +/* option for multiple returns in `lua_pcall' and `lua_call' */ +#define LUA_MULTRET (-1) + + +/* +** pseudo-indices +*/ +#define LUA_REGISTRYINDEX (-10000) +#define LUA_ENVIRONINDEX (-10001) +#define LUA_GLOBALSINDEX (-10002) +#define lua_upvalueindex(i) (LUA_GLOBALSINDEX-(i)) + + +/* thread status; 0 is OK */ +#define LUA_YIELD 1 +#define LUA_ERRRUN 2 +#define LUA_ERRSYNTAX 3 +#define LUA_ERRMEM 4 +#define LUA_ERRERR 5 + + +typedef struct lua_State lua_State; + +typedef int (*lua_CFunction) (lua_State *L); + + +/* +** functions that read/write blocks when loading/dumping Lua chunks +*/ +typedef const char * (*lua_Reader) (lua_State *L, void *ud, size_t *sz); + +typedef int (*lua_Writer) (lua_State *L, const void* p, size_t sz, void* ud); + + +/* +** prototype for memory-allocation functions +*/ +typedef void * (*lua_Alloc) (void *ud, void *ptr, size_t osize, size_t nsize); + + +/* +** basic types +*/ +#define LUA_TNONE (-1) + +#define LUA_TNIL 0 +#define LUA_TBOOLEAN 1 +#define LUA_TLIGHTUSERDATA 2 +#define LUA_TNUMBER 3 +#define LUA_TSTRING 4 +#define LUA_TTABLE 5 +#define LUA_TFUNCTION 6 +#define LUA_TUSERDATA 7 +#define LUA_TTHREAD 8 + + + +/* minimum Lua stack available to a C function */ +#define LUA_MINSTACK 20 + + +/* +** generic extra include file +*/ +#if defined(LUA_USER_H) +#include LUA_USER_H +#endif + + +/* type of numbers in Lua */ +typedef LUA_NUMBER lua_Number; + + +/* type for integer functions */ +typedef LUA_INTEGER lua_Integer; + + + +/* +** state manipulation +*/ +LUA_API lua_State *(lua_newstate) (lua_Alloc f, void *ud); +LUA_API void (lua_close) (lua_State *L); +LUA_API lua_State *(lua_newthread) (lua_State *L); + +LUA_API lua_CFunction (lua_atpanic) (lua_State *L, lua_CFunction panicf); + + +/* +** basic stack manipulation +*/ +LUA_API int (lua_gettop) (lua_State *L); +LUA_API void (lua_settop) (lua_State *L, int idx); +LUA_API void (lua_pushvalue) (lua_State *L, int idx); +LUA_API void (lua_remove) (lua_State *L, int idx); +LUA_API void (lua_insert) (lua_State *L, int idx); +LUA_API void (lua_replace) (lua_State *L, int idx); +LUA_API int (lua_checkstack) (lua_State *L, int sz); + +LUA_API void (lua_xmove) (lua_State *from, lua_State *to, int n); + + +/* +** access functions (stack -> C) +*/ + +LUA_API int (lua_isnumber) (lua_State *L, int idx); +LUA_API int (lua_isstring) (lua_State *L, int idx); +LUA_API int (lua_iscfunction) (lua_State *L, int idx); +LUA_API int (lua_isuserdata) (lua_State *L, int idx); +LUA_API int (lua_type) (lua_State *L, int idx); +LUA_API const char *(lua_typename) (lua_State *L, int tp); + +LUA_API int (lua_equal) (lua_State *L, int idx1, int idx2); +LUA_API int (lua_rawequal) (lua_State *L, int idx1, int idx2); +LUA_API int (lua_lessthan) (lua_State *L, int idx1, int idx2); + +LUA_API lua_Number (lua_tonumber) (lua_State *L, int idx); +LUA_API lua_Integer (lua_tointeger) (lua_State *L, int idx); +LUA_API int (lua_toboolean) (lua_State *L, int idx); +LUA_API const char *(lua_tolstring) (lua_State *L, int idx, size_t *len); +LUA_API size_t (lua_objlen) (lua_State *L, int idx); +LUA_API lua_CFunction (lua_tocfunction) (lua_State *L, int idx); +LUA_API void *(lua_touserdata) (lua_State *L, int idx); +LUA_API lua_State *(lua_tothread) (lua_State *L, int idx); +LUA_API const void *(lua_topointer) (lua_State *L, int idx); + + +/* +** push functions (C -> stack) +*/ +LUA_API void (lua_pushnil) (lua_State *L); +LUA_API void (lua_pushnumber) (lua_State *L, lua_Number n); +LUA_API void (lua_pushinteger) (lua_State *L, lua_Integer n); +LUA_API void (lua_pushlstring) (lua_State *L, const char *s, size_t l); +LUA_API void (lua_pushstring) (lua_State *L, const char *s); +LUA_API const char *(lua_pushvfstring) (lua_State *L, const char *fmt, + va_list argp); +LUA_API const char *(lua_pushfstring) (lua_State *L, const char *fmt, ...); +LUA_API void (lua_pushcclosure) (lua_State *L, lua_CFunction fn, int n); +LUA_API void (lua_pushboolean) (lua_State *L, int b); +LUA_API void (lua_pushlightuserdata) (lua_State *L, void *p); +LUA_API int (lua_pushthread) (lua_State *L); + + +/* +** get functions (Lua -> stack) +*/ +LUA_API void (lua_gettable) (lua_State *L, int idx); +LUA_API void (lua_getfield) (lua_State *L, int idx, const char *k); +LUA_API void (lua_rawget) (lua_State *L, int idx); +LUA_API void (lua_rawgeti) (lua_State *L, int idx, int n); +LUA_API void (lua_createtable) (lua_State *L, int narr, int nrec); +LUA_API void *(lua_newuserdata) (lua_State *L, size_t sz); +LUA_API int (lua_getmetatable) (lua_State *L, int objindex); +LUA_API void (lua_getfenv) (lua_State *L, int idx); + + +/* +** set functions (stack -> Lua) +*/ +LUA_API void (lua_settable) (lua_State *L, int idx); +LUA_API void (lua_setfield) (lua_State *L, int idx, const char *k); +LUA_API void (lua_rawset) (lua_State *L, int idx); +LUA_API void (lua_rawseti) (lua_State *L, int idx, int n); +LUA_API int (lua_setmetatable) (lua_State *L, int objindex); +LUA_API int (lua_setfenv) (lua_State *L, int idx); + + +/* +** `load' and `call' functions (load and run Lua code) +*/ +LUA_API void (lua_call) (lua_State *L, int nargs, int nresults); +LUA_API int (lua_pcall) (lua_State *L, int nargs, int nresults, int errfunc); +LUA_API int (lua_cpcall) (lua_State *L, lua_CFunction func, void *ud); +LUA_API int (lua_load) (lua_State *L, lua_Reader reader, void *dt, + const char *chunkname); + +LUA_API int (lua_dump) (lua_State *L, lua_Writer writer, void *data); + + +/* +** coroutine functions +*/ +LUA_API int (lua_yield) (lua_State *L, int nresults); +LUA_API int (lua_resume) (lua_State *L, int narg); +LUA_API int (lua_status) (lua_State *L); + +/* +** garbage-collection function and options +*/ + +#define LUA_GCSTOP 0 +#define LUA_GCRESTART 1 +#define LUA_GCCOLLECT 2 +#define LUA_GCCOUNT 3 +#define LUA_GCCOUNTB 4 +#define LUA_GCSTEP 5 +#define LUA_GCSETPAUSE 6 +#define LUA_GCSETSTEPMUL 7 + +LUA_API int (lua_gc) (lua_State *L, int what, int data); + + +/* +** miscellaneous functions +*/ + +LUA_API int (lua_error) (lua_State *L); + +LUA_API int (lua_next) (lua_State *L, int idx); + +LUA_API void (lua_concat) (lua_State *L, int n); + +LUA_API lua_Alloc (lua_getallocf) (lua_State *L, void **ud); +LUA_API void lua_setallocf (lua_State *L, lua_Alloc f, void *ud); + + + +/* +** =============================================================== +** some useful macros +** =============================================================== +*/ + +#define lua_pop(L,n) lua_settop(L, -(n)-1) + +#define lua_newtable(L) lua_createtable(L, 0, 0) + +#define lua_register(L,n,f) (lua_pushcfunction(L, (f)), lua_setglobal(L, (n))) + +#define lua_pushcfunction(L,f) lua_pushcclosure(L, (f), 0) + +#define lua_strlen(L,i) lua_objlen(L, (i)) + +#define lua_isfunction(L,n) (lua_type(L, (n)) == LUA_TFUNCTION) +#define lua_istable(L,n) (lua_type(L, (n)) == LUA_TTABLE) +#define lua_islightuserdata(L,n) (lua_type(L, (n)) == LUA_TLIGHTUSERDATA) +#define lua_isnil(L,n) (lua_type(L, (n)) == LUA_TNIL) +#define lua_isboolean(L,n) (lua_type(L, (n)) == LUA_TBOOLEAN) +#define lua_isthread(L,n) (lua_type(L, (n)) == LUA_TTHREAD) +#define lua_isnone(L,n) (lua_type(L, (n)) == LUA_TNONE) +#define lua_isnoneornil(L, n) (lua_type(L, (n)) <= 0) + +#define lua_pushliteral(L, s) \ + lua_pushlstring(L, "" s, (sizeof(s)/sizeof(char))-1) + +#define lua_setglobal(L,s) lua_setfield(L, LUA_GLOBALSINDEX, (s)) +#define lua_getglobal(L,s) lua_getfield(L, LUA_GLOBALSINDEX, (s)) + +#define lua_tostring(L,i) lua_tolstring(L, (i), NULL) + + + +/* +** compatibility macros and functions +*/ + +#define lua_open() luaL_newstate() + +#define lua_getregistry(L) lua_pushvalue(L, LUA_REGISTRYINDEX) + +#define lua_getgccount(L) lua_gc(L, LUA_GCCOUNT, 0) + +#define lua_Chunkreader lua_Reader +#define lua_Chunkwriter lua_Writer + + +/* hack */ +LUA_API void lua_setlevel (lua_State *from, lua_State *to); + + +/* +** {====================================================================== +** Debug API +** ======================================================================= +*/ + + +/* +** Event codes +*/ +#define LUA_HOOKCALL 0 +#define LUA_HOOKRET 1 +#define LUA_HOOKLINE 2 +#define LUA_HOOKCOUNT 3 +#define LUA_HOOKTAILRET 4 + + +/* +** Event masks +*/ +#define LUA_MASKCALL (1 << LUA_HOOKCALL) +#define LUA_MASKRET (1 << LUA_HOOKRET) +#define LUA_MASKLINE (1 << LUA_HOOKLINE) +#define LUA_MASKCOUNT (1 << LUA_HOOKCOUNT) + +typedef struct lua_Debug lua_Debug; /* activation record */ + + +/* Functions to be called by the debuger in specific events */ +typedef void (*lua_Hook) (lua_State *L, lua_Debug *ar); + + +LUA_API int lua_getstack (lua_State *L, int level, lua_Debug *ar); +LUA_API int lua_getinfo (lua_State *L, const char *what, lua_Debug *ar); +LUA_API const char *lua_getlocal (lua_State *L, const lua_Debug *ar, int n); +LUA_API const char *lua_setlocal (lua_State *L, const lua_Debug *ar, int n); +LUA_API const char *lua_getupvalue (lua_State *L, int funcindex, int n); +LUA_API const char *lua_setupvalue (lua_State *L, int funcindex, int n); + +LUA_API int lua_sethook (lua_State *L, lua_Hook func, int mask, int count); +LUA_API lua_Hook lua_gethook (lua_State *L); +LUA_API int lua_gethookmask (lua_State *L); +LUA_API int lua_gethookcount (lua_State *L); + + +struct lua_Debug { + int event; + const char *name; /* (n) */ + const char *namewhat; /* (n) `global', `local', `field', `method' */ + const char *what; /* (S) `Lua', `C', `main', `tail' */ + const char *source; /* (S) */ + int currentline; /* (l) */ + int nups; /* (u) number of upvalues */ + int linedefined; /* (S) */ + int lastlinedefined; /* (S) */ + char short_src[LUA_IDSIZE]; /* (S) */ + /* private part */ + int i_ci; /* active function */ +}; + +/* }====================================================================== */ + + +/****************************************************************************** +* Copyright (C) 1994-2012 Lua.org, PUC-Rio. All rights reserved. +* +* Permission is hereby granted, free of charge, to any person obtaining +* a copy of this software and associated documentation files (the +* "Software"), to deal in the Software without restriction, including +* without limitation the rights to use, copy, modify, merge, publish, +* distribute, sublicense, and/or sell copies of the Software, and to +* permit persons to whom the Software is furnished to do so, subject to +* the following conditions: +* +* The above copyright notice and this permission notice shall be +* included in all copies or substantial portions of the Software. +* +* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +* CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +* TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +******************************************************************************/ + + +#endif diff --git a/extern/lua-5.1.5/src/luac.c b/extern/lua-5.1.5/src/luac.c new file mode 100644 index 00000000..d0701739 --- /dev/null +++ b/extern/lua-5.1.5/src/luac.c @@ -0,0 +1,200 @@ +/* +** $Id: luac.c,v 1.54 2006/06/02 17:37:11 lhf Exp $ +** Lua compiler (saves bytecodes to files; also list bytecodes) +** See Copyright Notice in lua.h +*/ + +#include +#include +#include +#include + +#define luac_c +#define LUA_CORE + +#include "lua.h" +#include "lauxlib.h" + +#include "ldo.h" +#include "lfunc.h" +#include "lmem.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lstring.h" +#include "lundump.h" + +#define PROGNAME "luac" /* default program name */ +#define OUTPUT PROGNAME ".out" /* default output file */ + +static int listing=0; /* list bytecodes? */ +static int dumping=1; /* dump bytecodes? */ +static int stripping=0; /* strip debug information? */ +static char Output[]={ OUTPUT }; /* default output file name */ +static const char* output=Output; /* actual output file name */ +static const char* progname=PROGNAME; /* actual program name */ + +static void fatal(const char* message) +{ + fprintf(stderr,"%s: %s\n",progname,message); + exit(EXIT_FAILURE); +} + +static void cannot(const char* what) +{ + fprintf(stderr,"%s: cannot %s %s: %s\n",progname,what,output,strerror(errno)); + exit(EXIT_FAILURE); +} + +static void usage(const char* message) +{ + if (*message=='-') + fprintf(stderr,"%s: unrecognized option " LUA_QS "\n",progname,message); + else + fprintf(stderr,"%s: %s\n",progname,message); + fprintf(stderr, + "usage: %s [options] [filenames].\n" + "Available options are:\n" + " - process stdin\n" + " -l list\n" + " -o name output to file " LUA_QL("name") " (default is \"%s\")\n" + " -p parse only\n" + " -s strip debug information\n" + " -v show version information\n" + " -- stop handling options\n", + progname,Output); + exit(EXIT_FAILURE); +} + +#define IS(s) (strcmp(argv[i],s)==0) + +static int doargs(int argc, char* argv[]) +{ + int i; + int version=0; + if (argv[0]!=NULL && *argv[0]!=0) progname=argv[0]; + for (i=1; itop+(i))->l.p) + +static const Proto* combine(lua_State* L, int n) +{ + if (n==1) + return toproto(L,-1); + else + { + int i,pc; + Proto* f=luaF_newproto(L); + setptvalue2s(L,L->top,f); incr_top(L); + f->source=luaS_newliteral(L,"=(" PROGNAME ")"); + f->maxstacksize=1; + pc=2*n+1; + f->code=luaM_newvector(L,pc,Instruction); + f->sizecode=pc; + f->p=luaM_newvector(L,n,Proto*); + f->sizep=n; + pc=0; + for (i=0; ip[i]=toproto(L,i-n-1); + f->code[pc++]=CREATE_ABx(OP_CLOSURE,0,i); + f->code[pc++]=CREATE_ABC(OP_CALL,0,1,1); + } + f->code[pc++]=CREATE_ABC(OP_RETURN,0,1,0); + return f; + } +} + +static int writer(lua_State* L, const void* p, size_t size, void* u) +{ + UNUSED(L); + return (fwrite(p,size,1,(FILE*)u)!=1) && (size!=0); +} + +struct Smain { + int argc; + char** argv; +}; + +static int pmain(lua_State* L) +{ + struct Smain* s = (struct Smain*)lua_touserdata(L, 1); + int argc=s->argc; + char** argv=s->argv; + const Proto* f; + int i; + if (!lua_checkstack(L,argc)) fatal("too many input files"); + for (i=0; i1); + if (dumping) + { + FILE* D= (output==NULL) ? stdout : fopen(output,"wb"); + if (D==NULL) cannot("open"); + lua_lock(L); + luaU_dump(L,f,writer,D,stripping); + lua_unlock(L); + if (ferror(D)) cannot("write"); + if (fclose(D)) cannot("close"); + } + return 0; +} + +int main(int argc, char* argv[]) +{ + lua_State* L; + struct Smain s; + int i=doargs(argc,argv); + argc-=i; argv+=i; + if (argc<=0) usage("no input files given"); + L=lua_open(); + if (L==NULL) fatal("not enough memory for state"); + s.argc=argc; + s.argv=argv; + if (lua_cpcall(L,pmain,&s)!=0) fatal(lua_tostring(L,-1)); + lua_close(L); + return EXIT_SUCCESS; +} diff --git a/extern/lua-5.1.5/src/luaconf.h b/extern/lua-5.1.5/src/luaconf.h new file mode 100644 index 00000000..e2cb2616 --- /dev/null +++ b/extern/lua-5.1.5/src/luaconf.h @@ -0,0 +1,763 @@ +/* +** $Id: luaconf.h,v 1.82.1.7 2008/02/11 16:25:08 roberto Exp $ +** Configuration file for Lua +** See Copyright Notice in lua.h +*/ + + +#ifndef lconfig_h +#define lconfig_h + +#include +#include + + +/* +** ================================================================== +** Search for "@@" to find all configurable definitions. +** =================================================================== +*/ + + +/* +@@ LUA_ANSI controls the use of non-ansi features. +** CHANGE it (define it) if you want Lua to avoid the use of any +** non-ansi feature or library. +*/ +#if defined(__STRICT_ANSI__) +#define LUA_ANSI +#endif + + +#if !defined(LUA_ANSI) && defined(_WIN32) +#define LUA_WIN +#endif + +#if defined(LUA_USE_LINUX) +#define LUA_USE_POSIX +#define LUA_USE_DLOPEN /* needs an extra library: -ldl */ +#define LUA_USE_READLINE /* needs some extra libraries */ +#endif + +#if defined(LUA_USE_MACOSX) +#define LUA_USE_POSIX +#define LUA_DL_DYLD /* does not need extra library */ +#endif + + + +/* +@@ LUA_USE_POSIX includes all functionallity listed as X/Open System +@* Interfaces Extension (XSI). +** CHANGE it (define it) if your system is XSI compatible. +*/ +#if defined(LUA_USE_POSIX) +#define LUA_USE_MKSTEMP +#define LUA_USE_ISATTY +#define LUA_USE_POPEN +#define LUA_USE_ULONGJMP +#endif + + +/* +@@ LUA_PATH and LUA_CPATH are the names of the environment variables that +@* Lua check to set its paths. +@@ LUA_INIT is the name of the environment variable that Lua +@* checks for initialization code. +** CHANGE them if you want different names. +*/ +#define LUA_PATH "LUA_PATH" +#define LUA_CPATH "LUA_CPATH" +#define LUA_INIT "LUA_INIT" + + +/* +@@ LUA_PATH_DEFAULT is the default path that Lua uses to look for +@* Lua libraries. +@@ LUA_CPATH_DEFAULT is the default path that Lua uses to look for +@* C libraries. +** CHANGE them if your machine has a non-conventional directory +** hierarchy or if you want to install your libraries in +** non-conventional directories. +*/ +#if defined(_WIN32) +/* +** In Windows, any exclamation mark ('!') in the path is replaced by the +** path of the directory of the executable file of the current process. +*/ +#define LUA_LDIR "!\\lua\\" +#define LUA_CDIR "!\\" +#define LUA_PATH_DEFAULT \ + ".\\?.lua;" LUA_LDIR"?.lua;" LUA_LDIR"?\\init.lua;" \ + LUA_CDIR"?.lua;" LUA_CDIR"?\\init.lua" +#define LUA_CPATH_DEFAULT \ + ".\\?.dll;" LUA_CDIR"?.dll;" LUA_CDIR"loadall.dll" + +#else +#define LUA_ROOT "/usr/local/" +#define LUA_LDIR LUA_ROOT "share/lua/5.1/" +#define LUA_CDIR LUA_ROOT "lib/lua/5.1/" +#define LUA_PATH_DEFAULT \ + "./?.lua;" LUA_LDIR"?.lua;" LUA_LDIR"?/init.lua;" \ + LUA_CDIR"?.lua;" LUA_CDIR"?/init.lua" +#define LUA_CPATH_DEFAULT \ + "./?.so;" LUA_CDIR"?.so;" LUA_CDIR"loadall.so" +#endif + + +/* +@@ LUA_DIRSEP is the directory separator (for submodules). +** CHANGE it if your machine does not use "/" as the directory separator +** and is not Windows. (On Windows Lua automatically uses "\".) +*/ +#if defined(_WIN32) +#define LUA_DIRSEP "\\" +#else +#define LUA_DIRSEP "/" +#endif + + +/* +@@ LUA_PATHSEP is the character that separates templates in a path. +@@ LUA_PATH_MARK is the string that marks the substitution points in a +@* template. +@@ LUA_EXECDIR in a Windows path is replaced by the executable's +@* directory. +@@ LUA_IGMARK is a mark to ignore all before it when bulding the +@* luaopen_ function name. +** CHANGE them if for some reason your system cannot use those +** characters. (E.g., if one of those characters is a common character +** in file/directory names.) Probably you do not need to change them. +*/ +#define LUA_PATHSEP ";" +#define LUA_PATH_MARK "?" +#define LUA_EXECDIR "!" +#define LUA_IGMARK "-" + + +/* +@@ LUA_INTEGER is the integral type used by lua_pushinteger/lua_tointeger. +** CHANGE that if ptrdiff_t is not adequate on your machine. (On most +** machines, ptrdiff_t gives a good choice between int or long.) +*/ +#define LUA_INTEGER ptrdiff_t + + +/* +@@ LUA_API is a mark for all core API functions. +@@ LUALIB_API is a mark for all standard library functions. +** CHANGE them if you need to define those functions in some special way. +** For instance, if you want to create one Windows DLL with the core and +** the libraries, you may want to use the following definition (define +** LUA_BUILD_AS_DLL to get it). +*/ +#if defined(LUA_BUILD_AS_DLL) + +#if defined(LUA_CORE) || defined(LUA_LIB) +#define LUA_API __declspec(dllexport) +#else +#define LUA_API __declspec(dllimport) +#endif + +#else + +#define LUA_API extern + +#endif + +/* more often than not the libs go together with the core */ +#define LUALIB_API LUA_API + + +/* +@@ LUAI_FUNC is a mark for all extern functions that are not to be +@* exported to outside modules. +@@ LUAI_DATA is a mark for all extern (const) variables that are not to +@* be exported to outside modules. +** CHANGE them if you need to mark them in some special way. Elf/gcc +** (versions 3.2 and later) mark them as "hidden" to optimize access +** when Lua is compiled as a shared library. +*/ +#if defined(luaall_c) +#define LUAI_FUNC static +#define LUAI_DATA /* empty */ + +#elif defined(__GNUC__) && ((__GNUC__*100 + __GNUC_MINOR__) >= 302) && \ + defined(__ELF__) +#define LUAI_FUNC __attribute__((visibility("hidden"))) extern +#define LUAI_DATA LUAI_FUNC + +#else +#define LUAI_FUNC extern +#define LUAI_DATA extern +#endif + + + +/* +@@ LUA_QL describes how error messages quote program elements. +** CHANGE it if you want a different appearance. +*/ +#define LUA_QL(x) "'" x "'" +#define LUA_QS LUA_QL("%s") + + +/* +@@ LUA_IDSIZE gives the maximum size for the description of the source +@* of a function in debug information. +** CHANGE it if you want a different size. +*/ +#define LUA_IDSIZE 60 + + +/* +** {================================================================== +** Stand-alone configuration +** =================================================================== +*/ + +#if defined(lua_c) || defined(luaall_c) + +/* +@@ lua_stdin_is_tty detects whether the standard input is a 'tty' (that +@* is, whether we're running lua interactively). +** CHANGE it if you have a better definition for non-POSIX/non-Windows +** systems. +*/ +#if defined(LUA_USE_ISATTY) +#include +#define lua_stdin_is_tty() isatty(0) +#elif defined(LUA_WIN) +#include +#include +#define lua_stdin_is_tty() _isatty(_fileno(stdin)) +#else +#define lua_stdin_is_tty() 1 /* assume stdin is a tty */ +#endif + + +/* +@@ LUA_PROMPT is the default prompt used by stand-alone Lua. +@@ LUA_PROMPT2 is the default continuation prompt used by stand-alone Lua. +** CHANGE them if you want different prompts. (You can also change the +** prompts dynamically, assigning to globals _PROMPT/_PROMPT2.) +*/ +#define LUA_PROMPT "> " +#define LUA_PROMPT2 ">> " + + +/* +@@ LUA_PROGNAME is the default name for the stand-alone Lua program. +** CHANGE it if your stand-alone interpreter has a different name and +** your system is not able to detect that name automatically. +*/ +#define LUA_PROGNAME "lua" + + +/* +@@ LUA_MAXINPUT is the maximum length for an input line in the +@* stand-alone interpreter. +** CHANGE it if you need longer lines. +*/ +#define LUA_MAXINPUT 512 + + +/* +@@ lua_readline defines how to show a prompt and then read a line from +@* the standard input. +@@ lua_saveline defines how to "save" a read line in a "history". +@@ lua_freeline defines how to free a line read by lua_readline. +** CHANGE them if you want to improve this functionality (e.g., by using +** GNU readline and history facilities). +*/ +#if defined(LUA_USE_READLINE) +#include +#include +#include +#define lua_readline(L,b,p) ((void)L, ((b)=readline(p)) != NULL) +#define lua_saveline(L,idx) \ + if (lua_strlen(L,idx) > 0) /* non-empty line? */ \ + add_history(lua_tostring(L, idx)); /* add it to history */ +#define lua_freeline(L,b) ((void)L, free(b)) +#else +#define lua_readline(L,b,p) \ + ((void)L, fputs(p, stdout), fflush(stdout), /* show prompt */ \ + fgets(b, LUA_MAXINPUT, stdin) != NULL) /* get line */ +#define lua_saveline(L,idx) { (void)L; (void)idx; } +#define lua_freeline(L,b) { (void)L; (void)b; } +#endif + +#endif + +/* }================================================================== */ + + +/* +@@ LUAI_GCPAUSE defines the default pause between garbage-collector cycles +@* as a percentage. +** CHANGE it if you want the GC to run faster or slower (higher values +** mean larger pauses which mean slower collection.) You can also change +** this value dynamically. +*/ +#define LUAI_GCPAUSE 200 /* 200% (wait memory to double before next GC) */ + + +/* +@@ LUAI_GCMUL defines the default speed of garbage collection relative to +@* memory allocation as a percentage. +** CHANGE it if you want to change the granularity of the garbage +** collection. (Higher values mean coarser collections. 0 represents +** infinity, where each step performs a full collection.) You can also +** change this value dynamically. +*/ +#define LUAI_GCMUL 200 /* GC runs 'twice the speed' of memory allocation */ + + + +/* +@@ LUA_COMPAT_GETN controls compatibility with old getn behavior. +** CHANGE it (define it) if you want exact compatibility with the +** behavior of setn/getn in Lua 5.0. +*/ +#undef LUA_COMPAT_GETN + +/* +@@ LUA_COMPAT_LOADLIB controls compatibility about global loadlib. +** CHANGE it to undefined as soon as you do not need a global 'loadlib' +** function (the function is still available as 'package.loadlib'). +*/ +#undef LUA_COMPAT_LOADLIB + +/* +@@ LUA_COMPAT_VARARG controls compatibility with old vararg feature. +** CHANGE it to undefined as soon as your programs use only '...' to +** access vararg parameters (instead of the old 'arg' table). +*/ +#define LUA_COMPAT_VARARG + +/* +@@ LUA_COMPAT_MOD controls compatibility with old math.mod function. +** CHANGE it to undefined as soon as your programs use 'math.fmod' or +** the new '%' operator instead of 'math.mod'. +*/ +#define LUA_COMPAT_MOD + +/* +@@ LUA_COMPAT_LSTR controls compatibility with old long string nesting +@* facility. +** CHANGE it to 2 if you want the old behaviour, or undefine it to turn +** off the advisory error when nesting [[...]]. +*/ +#define LUA_COMPAT_LSTR 1 + +/* +@@ LUA_COMPAT_GFIND controls compatibility with old 'string.gfind' name. +** CHANGE it to undefined as soon as you rename 'string.gfind' to +** 'string.gmatch'. +*/ +#define LUA_COMPAT_GFIND + +/* +@@ LUA_COMPAT_OPENLIB controls compatibility with old 'luaL_openlib' +@* behavior. +** CHANGE it to undefined as soon as you replace to 'luaL_register' +** your uses of 'luaL_openlib' +*/ +#define LUA_COMPAT_OPENLIB + + + +/* +@@ luai_apicheck is the assert macro used by the Lua-C API. +** CHANGE luai_apicheck if you want Lua to perform some checks in the +** parameters it gets from API calls. This may slow down the interpreter +** a bit, but may be quite useful when debugging C code that interfaces +** with Lua. A useful redefinition is to use assert.h. +*/ +#if defined(LUA_USE_APICHECK) +#include +#define luai_apicheck(L,o) { (void)L; assert(o); } +#else +#define luai_apicheck(L,o) { (void)L; } +#endif + + +/* +@@ LUAI_BITSINT defines the number of bits in an int. +** CHANGE here if Lua cannot automatically detect the number of bits of +** your machine. Probably you do not need to change this. +*/ +/* avoid overflows in comparison */ +#if INT_MAX-20 < 32760 +#define LUAI_BITSINT 16 +#elif INT_MAX > 2147483640L +/* int has at least 32 bits */ +#define LUAI_BITSINT 32 +#else +#error "you must define LUA_BITSINT with number of bits in an integer" +#endif + + +/* +@@ LUAI_UINT32 is an unsigned integer with at least 32 bits. +@@ LUAI_INT32 is an signed integer with at least 32 bits. +@@ LUAI_UMEM is an unsigned integer big enough to count the total +@* memory used by Lua. +@@ LUAI_MEM is a signed integer big enough to count the total memory +@* used by Lua. +** CHANGE here if for some weird reason the default definitions are not +** good enough for your machine. (The definitions in the 'else' +** part always works, but may waste space on machines with 64-bit +** longs.) Probably you do not need to change this. +*/ +#if LUAI_BITSINT >= 32 +#define LUAI_UINT32 unsigned int +#define LUAI_INT32 int +#define LUAI_MAXINT32 INT_MAX +#define LUAI_UMEM size_t +#define LUAI_MEM ptrdiff_t +#else +/* 16-bit ints */ +#define LUAI_UINT32 unsigned long +#define LUAI_INT32 long +#define LUAI_MAXINT32 LONG_MAX +#define LUAI_UMEM unsigned long +#define LUAI_MEM long +#endif + + +/* +@@ LUAI_MAXCALLS limits the number of nested calls. +** CHANGE it if you need really deep recursive calls. This limit is +** arbitrary; its only purpose is to stop infinite recursion before +** exhausting memory. +*/ +#define LUAI_MAXCALLS 20000 + + +/* +@@ LUAI_MAXCSTACK limits the number of Lua stack slots that a C function +@* can use. +** CHANGE it if you need lots of (Lua) stack space for your C +** functions. This limit is arbitrary; its only purpose is to stop C +** functions to consume unlimited stack space. (must be smaller than +** -LUA_REGISTRYINDEX) +*/ +#define LUAI_MAXCSTACK 8000 + + + +/* +** {================================================================== +** CHANGE (to smaller values) the following definitions if your system +** has a small C stack. (Or you may want to change them to larger +** values if your system has a large C stack and these limits are +** too rigid for you.) Some of these constants control the size of +** stack-allocated arrays used by the compiler or the interpreter, while +** others limit the maximum number of recursive calls that the compiler +** or the interpreter can perform. Values too large may cause a C stack +** overflow for some forms of deep constructs. +** =================================================================== +*/ + + +/* +@@ LUAI_MAXCCALLS is the maximum depth for nested C calls (short) and +@* syntactical nested non-terminals in a program. +*/ +#define LUAI_MAXCCALLS 200 + + +/* +@@ LUAI_MAXVARS is the maximum number of local variables per function +@* (must be smaller than 250). +*/ +#define LUAI_MAXVARS 200 + + +/* +@@ LUAI_MAXUPVALUES is the maximum number of upvalues per function +@* (must be smaller than 250). +*/ +#define LUAI_MAXUPVALUES 60 + + +/* +@@ LUAL_BUFFERSIZE is the buffer size used by the lauxlib buffer system. +*/ +#define LUAL_BUFFERSIZE BUFSIZ + +/* }================================================================== */ + + + + +/* +** {================================================================== +@@ LUA_NUMBER is the type of numbers in Lua. +** CHANGE the following definitions only if you want to build Lua +** with a number type different from double. You may also need to +** change lua_number2int & lua_number2integer. +** =================================================================== +*/ + +#define LUA_NUMBER_DOUBLE +#define LUA_NUMBER double + +/* +@@ LUAI_UACNUMBER is the result of an 'usual argument conversion' +@* over a number. +*/ +#define LUAI_UACNUMBER double + + +/* +@@ LUA_NUMBER_SCAN is the format for reading numbers. +@@ LUA_NUMBER_FMT is the format for writing numbers. +@@ lua_number2str converts a number to a string. +@@ LUAI_MAXNUMBER2STR is maximum size of previous conversion. +@@ lua_str2number converts a string to a number. +*/ +#define LUA_NUMBER_SCAN "%lf" +#define LUA_NUMBER_FMT "%.14g" +#define lua_number2str(s,n) sprintf((s), LUA_NUMBER_FMT, (n)) +#define LUAI_MAXNUMBER2STR 32 /* 16 digits, sign, point, and \0 */ +#define lua_str2number(s,p) strtod((s), (p)) + + +/* +@@ The luai_num* macros define the primitive operations over numbers. +*/ +#if defined(LUA_CORE) +#include +#define luai_numadd(a,b) ((a)+(b)) +#define luai_numsub(a,b) ((a)-(b)) +#define luai_nummul(a,b) ((a)*(b)) +#define luai_numdiv(a,b) ((a)/(b)) +#define luai_nummod(a,b) ((a) - floor((a)/(b))*(b)) +#define luai_numpow(a,b) (pow(a,b)) +#define luai_numunm(a) (-(a)) +#define luai_numeq(a,b) ((a)==(b)) +#define luai_numlt(a,b) ((a)<(b)) +#define luai_numle(a,b) ((a)<=(b)) +#define luai_numisnan(a) (!luai_numeq((a), (a))) +#endif + + +/* +@@ lua_number2int is a macro to convert lua_Number to int. +@@ lua_number2integer is a macro to convert lua_Number to lua_Integer. +** CHANGE them if you know a faster way to convert a lua_Number to +** int (with any rounding method and without throwing errors) in your +** system. In Pentium machines, a naive typecast from double to int +** in C is extremely slow, so any alternative is worth trying. +*/ + +/* On a Pentium, resort to a trick */ +#if defined(LUA_NUMBER_DOUBLE) && !defined(LUA_ANSI) && !defined(__SSE2__) && \ + (defined(__i386) || defined (_M_IX86) || defined(__i386__)) + +/* On a Microsoft compiler, use assembler */ +#if defined(_MSC_VER) + +#define lua_number2int(i,d) __asm fld d __asm fistp i +#define lua_number2integer(i,n) lua_number2int(i, n) + +/* the next trick should work on any Pentium, but sometimes clashes + with a DirectX idiosyncrasy */ +#else + +union luai_Cast { double l_d; long l_l; }; +#define lua_number2int(i,d) \ + { volatile union luai_Cast u; u.l_d = (d) + 6755399441055744.0; (i) = u.l_l; } +#define lua_number2integer(i,n) lua_number2int(i, n) + +#endif + + +/* this option always works, but may be slow */ +#else +#define lua_number2int(i,d) ((i)=(int)(d)) +#define lua_number2integer(i,d) ((i)=(lua_Integer)(d)) + +#endif + +/* }================================================================== */ + + +/* +@@ LUAI_USER_ALIGNMENT_T is a type that requires maximum alignment. +** CHANGE it if your system requires alignments larger than double. (For +** instance, if your system supports long doubles and they must be +** aligned in 16-byte boundaries, then you should add long double in the +** union.) Probably you do not need to change this. +*/ +#define LUAI_USER_ALIGNMENT_T union { double u; void *s; long l; } + + +/* +@@ LUAI_THROW/LUAI_TRY define how Lua does exception handling. +** CHANGE them if you prefer to use longjmp/setjmp even with C++ +** or if want/don't to use _longjmp/_setjmp instead of regular +** longjmp/setjmp. By default, Lua handles errors with exceptions when +** compiling as C++ code, with _longjmp/_setjmp when asked to use them, +** and with longjmp/setjmp otherwise. +*/ +#if defined(__cplusplus) +/* C++ exceptions */ +#define LUAI_THROW(L,c) throw(c) +#define LUAI_TRY(L,c,a) try { a } catch(...) \ + { if ((c)->status == 0) (c)->status = -1; } +#define luai_jmpbuf int /* dummy variable */ + +#elif defined(LUA_USE_ULONGJMP) +/* in Unix, try _longjmp/_setjmp (more efficient) */ +#define LUAI_THROW(L,c) _longjmp((c)->b, 1) +#define LUAI_TRY(L,c,a) if (_setjmp((c)->b) == 0) { a } +#define luai_jmpbuf jmp_buf + +#else +/* default handling with long jumps */ +#define LUAI_THROW(L,c) longjmp((c)->b, 1) +#define LUAI_TRY(L,c,a) if (setjmp((c)->b) == 0) { a } +#define luai_jmpbuf jmp_buf + +#endif + + +/* +@@ LUA_MAXCAPTURES is the maximum number of captures that a pattern +@* can do during pattern-matching. +** CHANGE it if you need more captures. This limit is arbitrary. +*/ +#define LUA_MAXCAPTURES 32 + + +/* +@@ lua_tmpnam is the function that the OS library uses to create a +@* temporary name. +@@ LUA_TMPNAMBUFSIZE is the maximum size of a name created by lua_tmpnam. +** CHANGE them if you have an alternative to tmpnam (which is considered +** insecure) or if you want the original tmpnam anyway. By default, Lua +** uses tmpnam except when POSIX is available, where it uses mkstemp. +*/ +#if defined(loslib_c) || defined(luaall_c) + +#if defined(LUA_USE_MKSTEMP) +#include +#define LUA_TMPNAMBUFSIZE 32 +#define lua_tmpnam(b,e) { \ + strcpy(b, "/tmp/lua_XXXXXX"); \ + e = mkstemp(b); \ + if (e != -1) close(e); \ + e = (e == -1); } + +#else +#define LUA_TMPNAMBUFSIZE L_tmpnam +#define lua_tmpnam(b,e) { e = (tmpnam(b) == NULL); } +#endif + +#endif + + +/* +@@ lua_popen spawns a new process connected to the current one through +@* the file streams. +** CHANGE it if you have a way to implement it in your system. +*/ +#if defined(LUA_USE_POPEN) + +#define lua_popen(L,c,m) ((void)L, fflush(NULL), popen(c,m)) +#define lua_pclose(L,file) ((void)L, (pclose(file) != -1)) + +#elif defined(LUA_WIN) + +#define lua_popen(L,c,m) ((void)L, _popen(c,m)) +#define lua_pclose(L,file) ((void)L, (_pclose(file) != -1)) + +#else + +#define lua_popen(L,c,m) ((void)((void)c, m), \ + luaL_error(L, LUA_QL("popen") " not supported"), (FILE*)0) +#define lua_pclose(L,file) ((void)((void)L, file), 0) + +#endif + +/* +@@ LUA_DL_* define which dynamic-library system Lua should use. +** CHANGE here if Lua has problems choosing the appropriate +** dynamic-library system for your platform (either Windows' DLL, Mac's +** dyld, or Unix's dlopen). If your system is some kind of Unix, there +** is a good chance that it has dlopen, so LUA_DL_DLOPEN will work for +** it. To use dlopen you also need to adapt the src/Makefile (probably +** adding -ldl to the linker options), so Lua does not select it +** automatically. (When you change the makefile to add -ldl, you must +** also add -DLUA_USE_DLOPEN.) +** If you do not want any kind of dynamic library, undefine all these +** options. +** By default, _WIN32 gets LUA_DL_DLL and MAC OS X gets LUA_DL_DYLD. +*/ +#if defined(LUA_USE_DLOPEN) +#define LUA_DL_DLOPEN +#endif + +#if defined(LUA_WIN) +#define LUA_DL_DLL +#endif + + +/* +@@ LUAI_EXTRASPACE allows you to add user-specific data in a lua_State +@* (the data goes just *before* the lua_State pointer). +** CHANGE (define) this if you really need that. This value must be +** a multiple of the maximum alignment required for your machine. +*/ +#define LUAI_EXTRASPACE 0 + + +/* +@@ luai_userstate* allow user-specific actions on threads. +** CHANGE them if you defined LUAI_EXTRASPACE and need to do something +** extra when a thread is created/deleted/resumed/yielded. +*/ +#define luai_userstateopen(L) ((void)L) +#define luai_userstateclose(L) ((void)L) +#define luai_userstatethread(L,L1) ((void)L) +#define luai_userstatefree(L) ((void)L) +#define luai_userstateresume(L,n) ((void)L) +#define luai_userstateyield(L,n) ((void)L) + + +/* +@@ LUA_INTFRMLEN is the length modifier for integer conversions +@* in 'string.format'. +@@ LUA_INTFRM_T is the integer type correspoding to the previous length +@* modifier. +** CHANGE them if your system supports long long or does not support long. +*/ + +#if defined(LUA_USELONGLONG) + +#define LUA_INTFRMLEN "ll" +#define LUA_INTFRM_T long long + +#else + +#define LUA_INTFRMLEN "l" +#define LUA_INTFRM_T long + +#endif + + + +/* =================================================================== */ + +/* +** Local configuration. You can use this space to add your redefinitions +** without modifying the main part of the file. +*/ + + + +#endif + diff --git a/extern/lua-5.1.5/src/lualib.h b/extern/lua-5.1.5/src/lualib.h new file mode 100644 index 00000000..469417f6 --- /dev/null +++ b/extern/lua-5.1.5/src/lualib.h @@ -0,0 +1,53 @@ +/* +** $Id: lualib.h,v 1.36.1.1 2007/12/27 13:02:25 roberto Exp $ +** Lua standard libraries +** See Copyright Notice in lua.h +*/ + + +#ifndef lualib_h +#define lualib_h + +#include "lua.h" + + +/* Key to file-handle type */ +#define LUA_FILEHANDLE "FILE*" + + +#define LUA_COLIBNAME "coroutine" +LUALIB_API int (luaopen_base) (lua_State *L); + +#define LUA_TABLIBNAME "table" +LUALIB_API int (luaopen_table) (lua_State *L); + +#define LUA_IOLIBNAME "io" +LUALIB_API int (luaopen_io) (lua_State *L); + +#define LUA_OSLIBNAME "os" +LUALIB_API int (luaopen_os) (lua_State *L); + +#define LUA_STRLIBNAME "string" +LUALIB_API int (luaopen_string) (lua_State *L); + +#define LUA_MATHLIBNAME "math" +LUALIB_API int (luaopen_math) (lua_State *L); + +#define LUA_DBLIBNAME "debug" +LUALIB_API int (luaopen_debug) (lua_State *L); + +#define LUA_LOADLIBNAME "package" +LUALIB_API int (luaopen_package) (lua_State *L); + + +/* open all previous libraries */ +LUALIB_API void (luaL_openlibs) (lua_State *L); + + + +#ifndef lua_assert +#define lua_assert(x) ((void)0) +#endif + + +#endif diff --git a/extern/lua-5.1.5/src/lundump.c b/extern/lua-5.1.5/src/lundump.c new file mode 100644 index 00000000..8010a457 --- /dev/null +++ b/extern/lua-5.1.5/src/lundump.c @@ -0,0 +1,227 @@ +/* +** $Id: lundump.c,v 2.7.1.4 2008/04/04 19:51:41 roberto Exp $ +** load precompiled Lua chunks +** See Copyright Notice in lua.h +*/ + +#include + +#define lundump_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lmem.h" +#include "lobject.h" +#include "lstring.h" +#include "lundump.h" +#include "lzio.h" + +typedef struct { + lua_State* L; + ZIO* Z; + Mbuffer* b; + const char* name; +} LoadState; + +#ifdef LUAC_TRUST_BINARIES +#define IF(c,s) +#define error(S,s) +#else +#define IF(c,s) if (c) error(S,s) + +static void error(LoadState* S, const char* why) +{ + luaO_pushfstring(S->L,"%s: %s in precompiled chunk",S->name,why); + luaD_throw(S->L,LUA_ERRSYNTAX); +} +#endif + +#define LoadMem(S,b,n,size) LoadBlock(S,b,(n)*(size)) +#define LoadByte(S) (lu_byte)LoadChar(S) +#define LoadVar(S,x) LoadMem(S,&x,1,sizeof(x)) +#define LoadVector(S,b,n,size) LoadMem(S,b,n,size) + +static void LoadBlock(LoadState* S, void* b, size_t size) +{ + size_t r=luaZ_read(S->Z,b,size); + IF (r!=0, "unexpected end"); +} + +static int LoadChar(LoadState* S) +{ + char x; + LoadVar(S,x); + return x; +} + +static int LoadInt(LoadState* S) +{ + int x; + LoadVar(S,x); + IF (x<0, "bad integer"); + return x; +} + +static lua_Number LoadNumber(LoadState* S) +{ + lua_Number x; + LoadVar(S,x); + return x; +} + +static TString* LoadString(LoadState* S) +{ + size_t size; + LoadVar(S,size); + if (size==0) + return NULL; + else + { + char* s=luaZ_openspace(S->L,S->b,size); + LoadBlock(S,s,size); + return luaS_newlstr(S->L,s,size-1); /* remove trailing '\0' */ + } +} + +static void LoadCode(LoadState* S, Proto* f) +{ + int n=LoadInt(S); + f->code=luaM_newvector(S->L,n,Instruction); + f->sizecode=n; + LoadVector(S,f->code,n,sizeof(Instruction)); +} + +static Proto* LoadFunction(LoadState* S, TString* p); + +static void LoadConstants(LoadState* S, Proto* f) +{ + int i,n; + n=LoadInt(S); + f->k=luaM_newvector(S->L,n,TValue); + f->sizek=n; + for (i=0; ik[i]); + for (i=0; ik[i]; + int t=LoadChar(S); + switch (t) + { + case LUA_TNIL: + setnilvalue(o); + break; + case LUA_TBOOLEAN: + setbvalue(o,LoadChar(S)!=0); + break; + case LUA_TNUMBER: + setnvalue(o,LoadNumber(S)); + break; + case LUA_TSTRING: + setsvalue2n(S->L,o,LoadString(S)); + break; + default: + error(S,"bad constant"); + break; + } + } + n=LoadInt(S); + f->p=luaM_newvector(S->L,n,Proto*); + f->sizep=n; + for (i=0; ip[i]=NULL; + for (i=0; ip[i]=LoadFunction(S,f->source); +} + +static void LoadDebug(LoadState* S, Proto* f) +{ + int i,n; + n=LoadInt(S); + f->lineinfo=luaM_newvector(S->L,n,int); + f->sizelineinfo=n; + LoadVector(S,f->lineinfo,n,sizeof(int)); + n=LoadInt(S); + f->locvars=luaM_newvector(S->L,n,LocVar); + f->sizelocvars=n; + for (i=0; ilocvars[i].varname=NULL; + for (i=0; ilocvars[i].varname=LoadString(S); + f->locvars[i].startpc=LoadInt(S); + f->locvars[i].endpc=LoadInt(S); + } + n=LoadInt(S); + f->upvalues=luaM_newvector(S->L,n,TString*); + f->sizeupvalues=n; + for (i=0; iupvalues[i]=NULL; + for (i=0; iupvalues[i]=LoadString(S); +} + +static Proto* LoadFunction(LoadState* S, TString* p) +{ + Proto* f; + if (++S->L->nCcalls > LUAI_MAXCCALLS) error(S,"code too deep"); + f=luaF_newproto(S->L); + setptvalue2s(S->L,S->L->top,f); incr_top(S->L); + f->source=LoadString(S); if (f->source==NULL) f->source=p; + f->linedefined=LoadInt(S); + f->lastlinedefined=LoadInt(S); + f->nups=LoadByte(S); + f->numparams=LoadByte(S); + f->is_vararg=LoadByte(S); + f->maxstacksize=LoadByte(S); + LoadCode(S,f); + LoadConstants(S,f); + LoadDebug(S,f); + IF (!luaG_checkcode(f), "bad code"); + S->L->top--; + S->L->nCcalls--; + return f; +} + +static void LoadHeader(LoadState* S) +{ + char h[LUAC_HEADERSIZE]; + char s[LUAC_HEADERSIZE]; + luaU_header(h); + LoadBlock(S,s,LUAC_HEADERSIZE); + IF (memcmp(h,s,LUAC_HEADERSIZE)!=0, "bad header"); +} + +/* +** load precompiled chunk +*/ +Proto* luaU_undump (lua_State* L, ZIO* Z, Mbuffer* buff, const char* name) +{ + LoadState S; + if (*name=='@' || *name=='=') + S.name=name+1; + else if (*name==LUA_SIGNATURE[0]) + S.name="binary string"; + else + S.name=name; + S.L=L; + S.Z=Z; + S.b=buff; + LoadHeader(&S); + return LoadFunction(&S,luaS_newliteral(L,"=?")); +} + +/* +* make header +*/ +void luaU_header (char* h) +{ + int x=1; + memcpy(h,LUA_SIGNATURE,sizeof(LUA_SIGNATURE)-1); + h+=sizeof(LUA_SIGNATURE)-1; + *h++=(char)LUAC_VERSION; + *h++=(char)LUAC_FORMAT; + *h++=(char)*(char*)&x; /* endianness */ + *h++=(char)sizeof(int); + *h++=(char)sizeof(size_t); + *h++=(char)sizeof(Instruction); + *h++=(char)sizeof(lua_Number); + *h++=(char)(((lua_Number)0.5)==0); /* is lua_Number integral? */ +} diff --git a/extern/lua-5.1.5/src/lundump.h b/extern/lua-5.1.5/src/lundump.h new file mode 100644 index 00000000..c80189db --- /dev/null +++ b/extern/lua-5.1.5/src/lundump.h @@ -0,0 +1,36 @@ +/* +** $Id: lundump.h,v 1.37.1.1 2007/12/27 13:02:25 roberto Exp $ +** load precompiled Lua chunks +** See Copyright Notice in lua.h +*/ + +#ifndef lundump_h +#define lundump_h + +#include "lobject.h" +#include "lzio.h" + +/* load one chunk; from lundump.c */ +LUAI_FUNC Proto* luaU_undump (lua_State* L, ZIO* Z, Mbuffer* buff, const char* name); + +/* make header; from lundump.c */ +LUAI_FUNC void luaU_header (char* h); + +/* dump one chunk; from ldump.c */ +LUAI_FUNC int luaU_dump (lua_State* L, const Proto* f, lua_Writer w, void* data, int strip); + +#ifdef luac_c +/* print one chunk; from print.c */ +LUAI_FUNC void luaU_print (const Proto* f, int full); +#endif + +/* for header of binary files -- this is Lua 5.1 */ +#define LUAC_VERSION 0x51 + +/* for header of binary files -- this is the official format */ +#define LUAC_FORMAT 0 + +/* size of header of binary files */ +#define LUAC_HEADERSIZE 12 + +#endif diff --git a/extern/lua-5.1.5/src/lvm.c b/extern/lua-5.1.5/src/lvm.c new file mode 100644 index 00000000..e0a0cd85 --- /dev/null +++ b/extern/lua-5.1.5/src/lvm.c @@ -0,0 +1,767 @@ +/* +** $Id: lvm.c,v 2.63.1.5 2011/08/17 20:43:11 roberto Exp $ +** Lua virtual machine +** See Copyright Notice in lua.h +*/ + + +#include +#include +#include + +#define lvm_c +#define LUA_CORE + +#include "lua.h" + +#include "ldebug.h" +#include "ldo.h" +#include "lfunc.h" +#include "lgc.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lstate.h" +#include "lstring.h" +#include "ltable.h" +#include "ltm.h" +#include "lvm.h" + + + +/* limit for table tag-method chains (to avoid loops) */ +#define MAXTAGLOOP 100 + + +const TValue *luaV_tonumber (const TValue *obj, TValue *n) { + lua_Number num; + if (ttisnumber(obj)) return obj; + if (ttisstring(obj) && luaO_str2d(svalue(obj), &num)) { + setnvalue(n, num); + return n; + } + else + return NULL; +} + + +int luaV_tostring (lua_State *L, StkId obj) { + if (!ttisnumber(obj)) + return 0; + else { + char s[LUAI_MAXNUMBER2STR]; + lua_Number n = nvalue(obj); + lua_number2str(s, n); + setsvalue2s(L, obj, luaS_new(L, s)); + return 1; + } +} + + +static void traceexec (lua_State *L, const Instruction *pc) { + lu_byte mask = L->hookmask; + const Instruction *oldpc = L->savedpc; + L->savedpc = pc; + if ((mask & LUA_MASKCOUNT) && L->hookcount == 0) { + resethookcount(L); + luaD_callhook(L, LUA_HOOKCOUNT, -1); + } + if (mask & LUA_MASKLINE) { + Proto *p = ci_func(L->ci)->l.p; + int npc = pcRel(pc, p); + int newline = getline(p, npc); + /* call linehook when enter a new function, when jump back (loop), + or when enter a new line */ + if (npc == 0 || pc <= oldpc || newline != getline(p, pcRel(oldpc, p))) + luaD_callhook(L, LUA_HOOKLINE, newline); + } +} + + +static void callTMres (lua_State *L, StkId res, const TValue *f, + const TValue *p1, const TValue *p2) { + ptrdiff_t result = savestack(L, res); + setobj2s(L, L->top, f); /* push function */ + setobj2s(L, L->top+1, p1); /* 1st argument */ + setobj2s(L, L->top+2, p2); /* 2nd argument */ + luaD_checkstack(L, 3); + L->top += 3; + luaD_call(L, L->top - 3, 1); + res = restorestack(L, result); + L->top--; + setobjs2s(L, res, L->top); +} + + + +static void callTM (lua_State *L, const TValue *f, const TValue *p1, + const TValue *p2, const TValue *p3) { + setobj2s(L, L->top, f); /* push function */ + setobj2s(L, L->top+1, p1); /* 1st argument */ + setobj2s(L, L->top+2, p2); /* 2nd argument */ + setobj2s(L, L->top+3, p3); /* 3th argument */ + luaD_checkstack(L, 4); + L->top += 4; + luaD_call(L, L->top - 4, 0); +} + + +void luaV_gettable (lua_State *L, const TValue *t, TValue *key, StkId val) { + int loop; + for (loop = 0; loop < MAXTAGLOOP; loop++) { + const TValue *tm; + if (ttistable(t)) { /* `t' is a table? */ + Table *h = hvalue(t); + const TValue *res = luaH_get(h, key); /* do a primitive get */ + if (!ttisnil(res) || /* result is no nil? */ + (tm = fasttm(L, h->metatable, TM_INDEX)) == NULL) { /* or no TM? */ + setobj2s(L, val, res); + return; + } + /* else will try the tag method */ + } + else if (ttisnil(tm = luaT_gettmbyobj(L, t, TM_INDEX))) + luaG_typeerror(L, t, "index"); + if (ttisfunction(tm)) { + callTMres(L, val, tm, t, key); + return; + } + t = tm; /* else repeat with `tm' */ + } + luaG_runerror(L, "loop in gettable"); +} + + +void luaV_settable (lua_State *L, const TValue *t, TValue *key, StkId val) { + int loop; + TValue temp; + for (loop = 0; loop < MAXTAGLOOP; loop++) { + const TValue *tm; + if (ttistable(t)) { /* `t' is a table? */ + Table *h = hvalue(t); + TValue *oldval = luaH_set(L, h, key); /* do a primitive set */ + if (!ttisnil(oldval) || /* result is no nil? */ + (tm = fasttm(L, h->metatable, TM_NEWINDEX)) == NULL) { /* or no TM? */ + setobj2t(L, oldval, val); + h->flags = 0; + luaC_barriert(L, h, val); + return; + } + /* else will try the tag method */ + } + else if (ttisnil(tm = luaT_gettmbyobj(L, t, TM_NEWINDEX))) + luaG_typeerror(L, t, "index"); + if (ttisfunction(tm)) { + callTM(L, tm, t, key, val); + return; + } + /* else repeat with `tm' */ + setobj(L, &temp, tm); /* avoid pointing inside table (may rehash) */ + t = &temp; + } + luaG_runerror(L, "loop in settable"); +} + + +static int call_binTM (lua_State *L, const TValue *p1, const TValue *p2, + StkId res, TMS event) { + const TValue *tm = luaT_gettmbyobj(L, p1, event); /* try first operand */ + if (ttisnil(tm)) + tm = luaT_gettmbyobj(L, p2, event); /* try second operand */ + if (ttisnil(tm)) return 0; + callTMres(L, res, tm, p1, p2); + return 1; +} + + +static const TValue *get_compTM (lua_State *L, Table *mt1, Table *mt2, + TMS event) { + const TValue *tm1 = fasttm(L, mt1, event); + const TValue *tm2; + if (tm1 == NULL) return NULL; /* no metamethod */ + if (mt1 == mt2) return tm1; /* same metatables => same metamethods */ + tm2 = fasttm(L, mt2, event); + if (tm2 == NULL) return NULL; /* no metamethod */ + if (luaO_rawequalObj(tm1, tm2)) /* same metamethods? */ + return tm1; + return NULL; +} + + +static int call_orderTM (lua_State *L, const TValue *p1, const TValue *p2, + TMS event) { + const TValue *tm1 = luaT_gettmbyobj(L, p1, event); + const TValue *tm2; + if (ttisnil(tm1)) return -1; /* no metamethod? */ + tm2 = luaT_gettmbyobj(L, p2, event); + if (!luaO_rawequalObj(tm1, tm2)) /* different metamethods? */ + return -1; + callTMres(L, L->top, tm1, p1, p2); + return !l_isfalse(L->top); +} + + +static int l_strcmp (const TString *ls, const TString *rs) { + const char *l = getstr(ls); + size_t ll = ls->tsv.len; + const char *r = getstr(rs); + size_t lr = rs->tsv.len; + for (;;) { + int temp = strcoll(l, r); + if (temp != 0) return temp; + else { /* strings are equal up to a `\0' */ + size_t len = strlen(l); /* index of first `\0' in both strings */ + if (len == lr) /* r is finished? */ + return (len == ll) ? 0 : 1; + else if (len == ll) /* l is finished? */ + return -1; /* l is smaller than r (because r is not finished) */ + /* both strings longer than `len'; go on comparing (after the `\0') */ + len++; + l += len; ll -= len; r += len; lr -= len; + } + } +} + + +int luaV_lessthan (lua_State *L, const TValue *l, const TValue *r) { + int res; + if (ttype(l) != ttype(r)) + return luaG_ordererror(L, l, r); + else if (ttisnumber(l)) + return luai_numlt(nvalue(l), nvalue(r)); + else if (ttisstring(l)) + return l_strcmp(rawtsvalue(l), rawtsvalue(r)) < 0; + else if ((res = call_orderTM(L, l, r, TM_LT)) != -1) + return res; + return luaG_ordererror(L, l, r); +} + + +static int lessequal (lua_State *L, const TValue *l, const TValue *r) { + int res; + if (ttype(l) != ttype(r)) + return luaG_ordererror(L, l, r); + else if (ttisnumber(l)) + return luai_numle(nvalue(l), nvalue(r)); + else if (ttisstring(l)) + return l_strcmp(rawtsvalue(l), rawtsvalue(r)) <= 0; + else if ((res = call_orderTM(L, l, r, TM_LE)) != -1) /* first try `le' */ + return res; + else if ((res = call_orderTM(L, r, l, TM_LT)) != -1) /* else try `lt' */ + return !res; + return luaG_ordererror(L, l, r); +} + + +int luaV_equalval (lua_State *L, const TValue *t1, const TValue *t2) { + const TValue *tm; + lua_assert(ttype(t1) == ttype(t2)); + switch (ttype(t1)) { + case LUA_TNIL: return 1; + case LUA_TNUMBER: return luai_numeq(nvalue(t1), nvalue(t2)); + case LUA_TBOOLEAN: return bvalue(t1) == bvalue(t2); /* true must be 1 !! */ + case LUA_TLIGHTUSERDATA: return pvalue(t1) == pvalue(t2); + case LUA_TUSERDATA: { + if (uvalue(t1) == uvalue(t2)) return 1; + tm = get_compTM(L, uvalue(t1)->metatable, uvalue(t2)->metatable, + TM_EQ); + break; /* will try TM */ + } + case LUA_TTABLE: { + if (hvalue(t1) == hvalue(t2)) return 1; + tm = get_compTM(L, hvalue(t1)->metatable, hvalue(t2)->metatable, TM_EQ); + break; /* will try TM */ + } + default: return gcvalue(t1) == gcvalue(t2); + } + if (tm == NULL) return 0; /* no TM? */ + callTMres(L, L->top, tm, t1, t2); /* call TM */ + return !l_isfalse(L->top); +} + + +void luaV_concat (lua_State *L, int total, int last) { + do { + StkId top = L->base + last + 1; + int n = 2; /* number of elements handled in this pass (at least 2) */ + if (!(ttisstring(top-2) || ttisnumber(top-2)) || !tostring(L, top-1)) { + if (!call_binTM(L, top-2, top-1, top-2, TM_CONCAT)) + luaG_concaterror(L, top-2, top-1); + } else if (tsvalue(top-1)->len == 0) /* second op is empty? */ + (void)tostring(L, top - 2); /* result is first op (as string) */ + else { + /* at least two string values; get as many as possible */ + size_t tl = tsvalue(top-1)->len; + char *buffer; + int i; + /* collect total length */ + for (n = 1; n < total && tostring(L, top-n-1); n++) { + size_t l = tsvalue(top-n-1)->len; + if (l >= MAX_SIZET - tl) luaG_runerror(L, "string length overflow"); + tl += l; + } + buffer = luaZ_openspace(L, &G(L)->buff, tl); + tl = 0; + for (i=n; i>0; i--) { /* concat all strings */ + size_t l = tsvalue(top-i)->len; + memcpy(buffer+tl, svalue(top-i), l); + tl += l; + } + setsvalue2s(L, top-n, luaS_newlstr(L, buffer, tl)); + } + total -= n-1; /* got `n' strings to create 1 new */ + last -= n-1; + } while (total > 1); /* repeat until only 1 result left */ +} + + +static void Arith (lua_State *L, StkId ra, const TValue *rb, + const TValue *rc, TMS op) { + TValue tempb, tempc; + const TValue *b, *c; + if ((b = luaV_tonumber(rb, &tempb)) != NULL && + (c = luaV_tonumber(rc, &tempc)) != NULL) { + lua_Number nb = nvalue(b), nc = nvalue(c); + switch (op) { + case TM_ADD: setnvalue(ra, luai_numadd(nb, nc)); break; + case TM_SUB: setnvalue(ra, luai_numsub(nb, nc)); break; + case TM_MUL: setnvalue(ra, luai_nummul(nb, nc)); break; + case TM_DIV: setnvalue(ra, luai_numdiv(nb, nc)); break; + case TM_MOD: setnvalue(ra, luai_nummod(nb, nc)); break; + case TM_POW: setnvalue(ra, luai_numpow(nb, nc)); break; + case TM_UNM: setnvalue(ra, luai_numunm(nb)); break; + default: lua_assert(0); break; + } + } + else if (!call_binTM(L, rb, rc, ra, op)) + luaG_aritherror(L, rb, rc); +} + + + +/* +** some macros for common tasks in `luaV_execute' +*/ + +#define runtime_check(L, c) { if (!(c)) break; } + +#define RA(i) (base+GETARG_A(i)) +/* to be used after possible stack reallocation */ +#define RB(i) check_exp(getBMode(GET_OPCODE(i)) == OpArgR, base+GETARG_B(i)) +#define RC(i) check_exp(getCMode(GET_OPCODE(i)) == OpArgR, base+GETARG_C(i)) +#define RKB(i) check_exp(getBMode(GET_OPCODE(i)) == OpArgK, \ + ISK(GETARG_B(i)) ? k+INDEXK(GETARG_B(i)) : base+GETARG_B(i)) +#define RKC(i) check_exp(getCMode(GET_OPCODE(i)) == OpArgK, \ + ISK(GETARG_C(i)) ? k+INDEXK(GETARG_C(i)) : base+GETARG_C(i)) +#define KBx(i) check_exp(getBMode(GET_OPCODE(i)) == OpArgK, k+GETARG_Bx(i)) + + +#define dojump(L,pc,i) {(pc) += (i); luai_threadyield(L);} + + +#define Protect(x) { L->savedpc = pc; {x;}; base = L->base; } + + +#define arith_op(op,tm) { \ + TValue *rb = RKB(i); \ + TValue *rc = RKC(i); \ + if (ttisnumber(rb) && ttisnumber(rc)) { \ + lua_Number nb = nvalue(rb), nc = nvalue(rc); \ + setnvalue(ra, op(nb, nc)); \ + } \ + else \ + Protect(Arith(L, ra, rb, rc, tm)); \ + } + + + +void luaV_execute (lua_State *L, int nexeccalls) { + LClosure *cl; + StkId base; + TValue *k; + const Instruction *pc; + reentry: /* entry point */ + lua_assert(isLua(L->ci)); + pc = L->savedpc; + cl = &clvalue(L->ci->func)->l; + base = L->base; + k = cl->p->k; + /* main loop of interpreter */ + for (;;) { + const Instruction i = *pc++; + StkId ra; + if ((L->hookmask & (LUA_MASKLINE | LUA_MASKCOUNT)) && + (--L->hookcount == 0 || L->hookmask & LUA_MASKLINE)) { + traceexec(L, pc); + if (L->status == LUA_YIELD) { /* did hook yield? */ + L->savedpc = pc - 1; + return; + } + base = L->base; + } + /* warning!! several calls may realloc the stack and invalidate `ra' */ + ra = RA(i); + lua_assert(base == L->base && L->base == L->ci->base); + lua_assert(base <= L->top && L->top <= L->stack + L->stacksize); + lua_assert(L->top == L->ci->top || luaG_checkopenop(i)); + switch (GET_OPCODE(i)) { + case OP_MOVE: { + setobjs2s(L, ra, RB(i)); + continue; + } + case OP_LOADK: { + setobj2s(L, ra, KBx(i)); + continue; + } + case OP_LOADBOOL: { + setbvalue(ra, GETARG_B(i)); + if (GETARG_C(i)) pc++; /* skip next instruction (if C) */ + continue; + } + case OP_LOADNIL: { + TValue *rb = RB(i); + do { + setnilvalue(rb--); + } while (rb >= ra); + continue; + } + case OP_GETUPVAL: { + int b = GETARG_B(i); + setobj2s(L, ra, cl->upvals[b]->v); + continue; + } + case OP_GETGLOBAL: { + TValue g; + TValue *rb = KBx(i); + sethvalue(L, &g, cl->env); + lua_assert(ttisstring(rb)); + Protect(luaV_gettable(L, &g, rb, ra)); + continue; + } + case OP_GETTABLE: { + Protect(luaV_gettable(L, RB(i), RKC(i), ra)); + continue; + } + case OP_SETGLOBAL: { + TValue g; + sethvalue(L, &g, cl->env); + lua_assert(ttisstring(KBx(i))); + Protect(luaV_settable(L, &g, KBx(i), ra)); + continue; + } + case OP_SETUPVAL: { + UpVal *uv = cl->upvals[GETARG_B(i)]; + setobj(L, uv->v, ra); + luaC_barrier(L, uv, ra); + continue; + } + case OP_SETTABLE: { + Protect(luaV_settable(L, ra, RKB(i), RKC(i))); + continue; + } + case OP_NEWTABLE: { + int b = GETARG_B(i); + int c = GETARG_C(i); + sethvalue(L, ra, luaH_new(L, luaO_fb2int(b), luaO_fb2int(c))); + Protect(luaC_checkGC(L)); + continue; + } + case OP_SELF: { + StkId rb = RB(i); + setobjs2s(L, ra+1, rb); + Protect(luaV_gettable(L, rb, RKC(i), ra)); + continue; + } + case OP_ADD: { + arith_op(luai_numadd, TM_ADD); + continue; + } + case OP_SUB: { + arith_op(luai_numsub, TM_SUB); + continue; + } + case OP_MUL: { + arith_op(luai_nummul, TM_MUL); + continue; + } + case OP_DIV: { + arith_op(luai_numdiv, TM_DIV); + continue; + } + case OP_MOD: { + arith_op(luai_nummod, TM_MOD); + continue; + } + case OP_POW: { + arith_op(luai_numpow, TM_POW); + continue; + } + case OP_UNM: { + TValue *rb = RB(i); + if (ttisnumber(rb)) { + lua_Number nb = nvalue(rb); + setnvalue(ra, luai_numunm(nb)); + } + else { + Protect(Arith(L, ra, rb, rb, TM_UNM)); + } + continue; + } + case OP_NOT: { + int res = l_isfalse(RB(i)); /* next assignment may change this value */ + setbvalue(ra, res); + continue; + } + case OP_LEN: { + const TValue *rb = RB(i); + switch (ttype(rb)) { + case LUA_TTABLE: { + setnvalue(ra, cast_num(luaH_getn(hvalue(rb)))); + break; + } + case LUA_TSTRING: { + setnvalue(ra, cast_num(tsvalue(rb)->len)); + break; + } + default: { /* try metamethod */ + Protect( + if (!call_binTM(L, rb, luaO_nilobject, ra, TM_LEN)) + luaG_typeerror(L, rb, "get length of"); + ) + } + } + continue; + } + case OP_CONCAT: { + int b = GETARG_B(i); + int c = GETARG_C(i); + Protect(luaV_concat(L, c-b+1, c); luaC_checkGC(L)); + setobjs2s(L, RA(i), base+b); + continue; + } + case OP_JMP: { + dojump(L, pc, GETARG_sBx(i)); + continue; + } + case OP_EQ: { + TValue *rb = RKB(i); + TValue *rc = RKC(i); + Protect( + if (equalobj(L, rb, rc) == GETARG_A(i)) + dojump(L, pc, GETARG_sBx(*pc)); + ) + pc++; + continue; + } + case OP_LT: { + Protect( + if (luaV_lessthan(L, RKB(i), RKC(i)) == GETARG_A(i)) + dojump(L, pc, GETARG_sBx(*pc)); + ) + pc++; + continue; + } + case OP_LE: { + Protect( + if (lessequal(L, RKB(i), RKC(i)) == GETARG_A(i)) + dojump(L, pc, GETARG_sBx(*pc)); + ) + pc++; + continue; + } + case OP_TEST: { + if (l_isfalse(ra) != GETARG_C(i)) + dojump(L, pc, GETARG_sBx(*pc)); + pc++; + continue; + } + case OP_TESTSET: { + TValue *rb = RB(i); + if (l_isfalse(rb) != GETARG_C(i)) { + setobjs2s(L, ra, rb); + dojump(L, pc, GETARG_sBx(*pc)); + } + pc++; + continue; + } + case OP_CALL: { + int b = GETARG_B(i); + int nresults = GETARG_C(i) - 1; + if (b != 0) L->top = ra+b; /* else previous instruction set top */ + L->savedpc = pc; + switch (luaD_precall(L, ra, nresults)) { + case PCRLUA: { + nexeccalls++; + goto reentry; /* restart luaV_execute over new Lua function */ + } + case PCRC: { + /* it was a C function (`precall' called it); adjust results */ + if (nresults >= 0) L->top = L->ci->top; + base = L->base; + continue; + } + default: { + return; /* yield */ + } + } + } + case OP_TAILCALL: { + int b = GETARG_B(i); + if (b != 0) L->top = ra+b; /* else previous instruction set top */ + L->savedpc = pc; + lua_assert(GETARG_C(i) - 1 == LUA_MULTRET); + switch (luaD_precall(L, ra, LUA_MULTRET)) { + case PCRLUA: { + /* tail call: put new frame in place of previous one */ + CallInfo *ci = L->ci - 1; /* previous frame */ + int aux; + StkId func = ci->func; + StkId pfunc = (ci+1)->func; /* previous function index */ + if (L->openupval) luaF_close(L, ci->base); + L->base = ci->base = ci->func + ((ci+1)->base - pfunc); + for (aux = 0; pfunc+aux < L->top; aux++) /* move frame down */ + setobjs2s(L, func+aux, pfunc+aux); + ci->top = L->top = func+aux; /* correct top */ + lua_assert(L->top == L->base + clvalue(func)->l.p->maxstacksize); + ci->savedpc = L->savedpc; + ci->tailcalls++; /* one more call lost */ + L->ci--; /* remove new frame */ + goto reentry; + } + case PCRC: { /* it was a C function (`precall' called it) */ + base = L->base; + continue; + } + default: { + return; /* yield */ + } + } + } + case OP_RETURN: { + int b = GETARG_B(i); + if (b != 0) L->top = ra+b-1; + if (L->openupval) luaF_close(L, base); + L->savedpc = pc; + b = luaD_poscall(L, ra); + if (--nexeccalls == 0) /* was previous function running `here'? */ + return; /* no: return */ + else { /* yes: continue its execution */ + if (b) L->top = L->ci->top; + lua_assert(isLua(L->ci)); + lua_assert(GET_OPCODE(*((L->ci)->savedpc - 1)) == OP_CALL); + goto reentry; + } + } + case OP_FORLOOP: { + lua_Number step = nvalue(ra+2); + lua_Number idx = luai_numadd(nvalue(ra), step); /* increment index */ + lua_Number limit = nvalue(ra+1); + if (luai_numlt(0, step) ? luai_numle(idx, limit) + : luai_numle(limit, idx)) { + dojump(L, pc, GETARG_sBx(i)); /* jump back */ + setnvalue(ra, idx); /* update internal index... */ + setnvalue(ra+3, idx); /* ...and external index */ + } + continue; + } + case OP_FORPREP: { + const TValue *init = ra; + const TValue *plimit = ra+1; + const TValue *pstep = ra+2; + L->savedpc = pc; /* next steps may throw errors */ + if (!tonumber(init, ra)) + luaG_runerror(L, LUA_QL("for") " initial value must be a number"); + else if (!tonumber(plimit, ra+1)) + luaG_runerror(L, LUA_QL("for") " limit must be a number"); + else if (!tonumber(pstep, ra+2)) + luaG_runerror(L, LUA_QL("for") " step must be a number"); + setnvalue(ra, luai_numsub(nvalue(ra), nvalue(pstep))); + dojump(L, pc, GETARG_sBx(i)); + continue; + } + case OP_TFORLOOP: { + StkId cb = ra + 3; /* call base */ + setobjs2s(L, cb+2, ra+2); + setobjs2s(L, cb+1, ra+1); + setobjs2s(L, cb, ra); + L->top = cb+3; /* func. + 2 args (state and index) */ + Protect(luaD_call(L, cb, GETARG_C(i))); + L->top = L->ci->top; + cb = RA(i) + 3; /* previous call may change the stack */ + if (!ttisnil(cb)) { /* continue loop? */ + setobjs2s(L, cb-1, cb); /* save control variable */ + dojump(L, pc, GETARG_sBx(*pc)); /* jump back */ + } + pc++; + continue; + } + case OP_SETLIST: { + int n = GETARG_B(i); + int c = GETARG_C(i); + int last; + Table *h; + if (n == 0) { + n = cast_int(L->top - ra) - 1; + L->top = L->ci->top; + } + if (c == 0) c = cast_int(*pc++); + runtime_check(L, ttistable(ra)); + h = hvalue(ra); + last = ((c-1)*LFIELDS_PER_FLUSH) + n; + if (last > h->sizearray) /* needs more space? */ + luaH_resizearray(L, h, last); /* pre-alloc it at once */ + for (; n > 0; n--) { + TValue *val = ra+n; + setobj2t(L, luaH_setnum(L, h, last--), val); + luaC_barriert(L, h, val); + } + continue; + } + case OP_CLOSE: { + luaF_close(L, ra); + continue; + } + case OP_CLOSURE: { + Proto *p; + Closure *ncl; + int nup, j; + p = cl->p->p[GETARG_Bx(i)]; + nup = p->nups; + ncl = luaF_newLclosure(L, nup, cl->env); + ncl->l.p = p; + for (j=0; jl.upvals[j] = cl->upvals[GETARG_B(*pc)]; + else { + lua_assert(GET_OPCODE(*pc) == OP_MOVE); + ncl->l.upvals[j] = luaF_findupval(L, base + GETARG_B(*pc)); + } + } + setclvalue(L, ra, ncl); + Protect(luaC_checkGC(L)); + continue; + } + case OP_VARARG: { + int b = GETARG_B(i) - 1; + int j; + CallInfo *ci = L->ci; + int n = cast_int(ci->base - ci->func) - cl->p->numparams - 1; + if (b == LUA_MULTRET) { + Protect(luaD_checkstack(L, n)); + ra = RA(i); /* previous call may change the stack */ + b = n; + L->top = ra + n; + } + for (j = 0; j < b; j++) { + if (j < n) { + setobjs2s(L, ra + j, ci->base - n + j); + } + else { + setnilvalue(ra + j); + } + } + continue; + } + } + } +} + diff --git a/extern/lua-5.1.5/src/lvm.h b/extern/lua-5.1.5/src/lvm.h new file mode 100644 index 00000000..bfe4f567 --- /dev/null +++ b/extern/lua-5.1.5/src/lvm.h @@ -0,0 +1,36 @@ +/* +** $Id: lvm.h,v 2.5.1.1 2007/12/27 13:02:25 roberto Exp $ +** Lua virtual machine +** See Copyright Notice in lua.h +*/ + +#ifndef lvm_h +#define lvm_h + + +#include "ldo.h" +#include "lobject.h" +#include "ltm.h" + + +#define tostring(L,o) ((ttype(o) == LUA_TSTRING) || (luaV_tostring(L, o))) + +#define tonumber(o,n) (ttype(o) == LUA_TNUMBER || \ + (((o) = luaV_tonumber(o,n)) != NULL)) + +#define equalobj(L,o1,o2) \ + (ttype(o1) == ttype(o2) && luaV_equalval(L, o1, o2)) + + +LUAI_FUNC int luaV_lessthan (lua_State *L, const TValue *l, const TValue *r); +LUAI_FUNC int luaV_equalval (lua_State *L, const TValue *t1, const TValue *t2); +LUAI_FUNC const TValue *luaV_tonumber (const TValue *obj, TValue *n); +LUAI_FUNC int luaV_tostring (lua_State *L, StkId obj); +LUAI_FUNC void luaV_gettable (lua_State *L, const TValue *t, TValue *key, + StkId val); +LUAI_FUNC void luaV_settable (lua_State *L, const TValue *t, TValue *key, + StkId val); +LUAI_FUNC void luaV_execute (lua_State *L, int nexeccalls); +LUAI_FUNC void luaV_concat (lua_State *L, int total, int last); + +#endif diff --git a/extern/lua-5.1.5/src/lzio.c b/extern/lua-5.1.5/src/lzio.c new file mode 100644 index 00000000..293edd59 --- /dev/null +++ b/extern/lua-5.1.5/src/lzio.c @@ -0,0 +1,82 @@ +/* +** $Id: lzio.c,v 1.31.1.1 2007/12/27 13:02:25 roberto Exp $ +** a generic input stream interface +** See Copyright Notice in lua.h +*/ + + +#include + +#define lzio_c +#define LUA_CORE + +#include "lua.h" + +#include "llimits.h" +#include "lmem.h" +#include "lstate.h" +#include "lzio.h" + + +int luaZ_fill (ZIO *z) { + size_t size; + lua_State *L = z->L; + const char *buff; + lua_unlock(L); + buff = z->reader(L, z->data, &size); + lua_lock(L); + if (buff == NULL || size == 0) return EOZ; + z->n = size - 1; + z->p = buff; + return char2int(*(z->p++)); +} + + +int luaZ_lookahead (ZIO *z) { + if (z->n == 0) { + if (luaZ_fill(z) == EOZ) + return EOZ; + else { + z->n++; /* luaZ_fill removed first byte; put back it */ + z->p--; + } + } + return char2int(*z->p); +} + + +void luaZ_init (lua_State *L, ZIO *z, lua_Reader reader, void *data) { + z->L = L; + z->reader = reader; + z->data = data; + z->n = 0; + z->p = NULL; +} + + +/* --------------------------------------------------------------- read --- */ +size_t luaZ_read (ZIO *z, void *b, size_t n) { + while (n) { + size_t m; + if (luaZ_lookahead(z) == EOZ) + return n; /* return number of missing bytes */ + m = (n <= z->n) ? n : z->n; /* min. between n and z->n */ + memcpy(b, z->p, m); + z->n -= m; + z->p += m; + b = (char *)b + m; + n -= m; + } + return 0; +} + +/* ------------------------------------------------------------------------ */ +char *luaZ_openspace (lua_State *L, Mbuffer *buff, size_t n) { + if (n > buff->buffsize) { + if (n < LUA_MINBUFFER) n = LUA_MINBUFFER; + luaZ_resizebuffer(L, buff, n); + } + return buff->buffer; +} + + diff --git a/extern/lua-5.1.5/src/lzio.h b/extern/lua-5.1.5/src/lzio.h new file mode 100644 index 00000000..51d695d8 --- /dev/null +++ b/extern/lua-5.1.5/src/lzio.h @@ -0,0 +1,67 @@ +/* +** $Id: lzio.h,v 1.21.1.1 2007/12/27 13:02:25 roberto Exp $ +** Buffered streams +** See Copyright Notice in lua.h +*/ + + +#ifndef lzio_h +#define lzio_h + +#include "lua.h" + +#include "lmem.h" + + +#define EOZ (-1) /* end of stream */ + +typedef struct Zio ZIO; + +#define char2int(c) cast(int, cast(unsigned char, (c))) + +#define zgetc(z) (((z)->n--)>0 ? char2int(*(z)->p++) : luaZ_fill(z)) + +typedef struct Mbuffer { + char *buffer; + size_t n; + size_t buffsize; +} Mbuffer; + +#define luaZ_initbuffer(L, buff) ((buff)->buffer = NULL, (buff)->buffsize = 0) + +#define luaZ_buffer(buff) ((buff)->buffer) +#define luaZ_sizebuffer(buff) ((buff)->buffsize) +#define luaZ_bufflen(buff) ((buff)->n) + +#define luaZ_resetbuffer(buff) ((buff)->n = 0) + + +#define luaZ_resizebuffer(L, buff, size) \ + (luaM_reallocvector(L, (buff)->buffer, (buff)->buffsize, size, char), \ + (buff)->buffsize = size) + +#define luaZ_freebuffer(L, buff) luaZ_resizebuffer(L, buff, 0) + + +LUAI_FUNC char *luaZ_openspace (lua_State *L, Mbuffer *buff, size_t n); +LUAI_FUNC void luaZ_init (lua_State *L, ZIO *z, lua_Reader reader, + void *data); +LUAI_FUNC size_t luaZ_read (ZIO* z, void* b, size_t n); /* read next n bytes */ +LUAI_FUNC int luaZ_lookahead (ZIO *z); + + + +/* --------- Private Part ------------------ */ + +struct Zio { + size_t n; /* bytes still unread */ + const char *p; /* current position in buffer */ + lua_Reader reader; + void* data; /* additional data */ + lua_State *L; /* Lua state (for reader) */ +}; + + +LUAI_FUNC int luaZ_fill (ZIO *z); + +#endif diff --git a/extern/lua-5.1.5/src/print.c b/extern/lua-5.1.5/src/print.c new file mode 100644 index 00000000..e240cfc3 --- /dev/null +++ b/extern/lua-5.1.5/src/print.c @@ -0,0 +1,227 @@ +/* +** $Id: print.c,v 1.55a 2006/05/31 13:30:05 lhf Exp $ +** print bytecodes +** See Copyright Notice in lua.h +*/ + +#include +#include + +#define luac_c +#define LUA_CORE + +#include "ldebug.h" +#include "lobject.h" +#include "lopcodes.h" +#include "lundump.h" + +#define PrintFunction luaU_print + +#define Sizeof(x) ((int)sizeof(x)) +#define VOID(p) ((const void*)(p)) + +static void PrintString(const TString* ts) +{ + const char* s=getstr(ts); + size_t i,n=ts->tsv.len; + putchar('"'); + for (i=0; ik[i]; + switch (ttype(o)) + { + case LUA_TNIL: + printf("nil"); + break; + case LUA_TBOOLEAN: + printf(bvalue(o) ? "true" : "false"); + break; + case LUA_TNUMBER: + printf(LUA_NUMBER_FMT,nvalue(o)); + break; + case LUA_TSTRING: + PrintString(rawtsvalue(o)); + break; + default: /* cannot happen */ + printf("? type=%d",ttype(o)); + break; + } +} + +static void PrintCode(const Proto* f) +{ + const Instruction* code=f->code; + int pc,n=f->sizecode; + for (pc=0; pc0) printf("[%d]\t",line); else printf("[-]\t"); + printf("%-9s\t",luaP_opnames[o]); + switch (getOpMode(o)) + { + case iABC: + printf("%d",a); + if (getBMode(o)!=OpArgN) printf(" %d",ISK(b) ? (-1-INDEXK(b)) : b); + if (getCMode(o)!=OpArgN) printf(" %d",ISK(c) ? (-1-INDEXK(c)) : c); + break; + case iABx: + if (getBMode(o)==OpArgK) printf("%d %d",a,-1-bx); else printf("%d %d",a,bx); + break; + case iAsBx: + if (o==OP_JMP) printf("%d",sbx); else printf("%d %d",a,sbx); + break; + } + switch (o) + { + case OP_LOADK: + printf("\t; "); PrintConstant(f,bx); + break; + case OP_GETUPVAL: + case OP_SETUPVAL: + printf("\t; %s", (f->sizeupvalues>0) ? getstr(f->upvalues[b]) : "-"); + break; + case OP_GETGLOBAL: + case OP_SETGLOBAL: + printf("\t; %s",svalue(&f->k[bx])); + break; + case OP_GETTABLE: + case OP_SELF: + if (ISK(c)) { printf("\t; "); PrintConstant(f,INDEXK(c)); } + break; + case OP_SETTABLE: + case OP_ADD: + case OP_SUB: + case OP_MUL: + case OP_DIV: + case OP_POW: + case OP_EQ: + case OP_LT: + case OP_LE: + if (ISK(b) || ISK(c)) + { + printf("\t; "); + if (ISK(b)) PrintConstant(f,INDEXK(b)); else printf("-"); + printf(" "); + if (ISK(c)) PrintConstant(f,INDEXK(c)); else printf("-"); + } + break; + case OP_JMP: + case OP_FORLOOP: + case OP_FORPREP: + printf("\t; to %d",sbx+pc+2); + break; + case OP_CLOSURE: + printf("\t; %p",VOID(f->p[bx])); + break; + case OP_SETLIST: + if (c==0) printf("\t; %d",(int)code[++pc]); + else printf("\t; %d",c); + break; + default: + break; + } + printf("\n"); + } +} + +#define SS(x) (x==1)?"":"s" +#define S(x) x,SS(x) + +static void PrintHeader(const Proto* f) +{ + const char* s=getstr(f->source); + if (*s=='@' || *s=='=') + s++; + else if (*s==LUA_SIGNATURE[0]) + s="(bstring)"; + else + s="(string)"; + printf("\n%s <%s:%d,%d> (%d instruction%s, %d bytes at %p)\n", + (f->linedefined==0)?"main":"function",s, + f->linedefined,f->lastlinedefined, + S(f->sizecode),f->sizecode*Sizeof(Instruction),VOID(f)); + printf("%d%s param%s, %d slot%s, %d upvalue%s, ", + f->numparams,f->is_vararg?"+":"",SS(f->numparams), + S(f->maxstacksize),S(f->nups)); + printf("%d local%s, %d constant%s, %d function%s\n", + S(f->sizelocvars),S(f->sizek),S(f->sizep)); +} + +static void PrintConstants(const Proto* f) +{ + int i,n=f->sizek; + printf("constants (%d) for %p:\n",n,VOID(f)); + for (i=0; isizelocvars; + printf("locals (%d) for %p:\n",n,VOID(f)); + for (i=0; ilocvars[i].varname),f->locvars[i].startpc+1,f->locvars[i].endpc+1); + } +} + +static void PrintUpvalues(const Proto* f) +{ + int i,n=f->sizeupvalues; + printf("upvalues (%d) for %p:\n",n,VOID(f)); + if (f->upvalues==NULL) return; + for (i=0; iupvalues[i])); + } +} + +void PrintFunction(const Proto* f, int full) +{ + int i,n=f->sizep; + PrintHeader(f); + PrintCode(f); + if (full) + { + PrintConstants(f); + PrintLocals(f); + PrintUpvalues(f); + } + for (i=0; ip[i],full); +} diff --git a/extern/lua-5.1.5/test/README b/extern/lua-5.1.5/test/README new file mode 100644 index 00000000..0c7f38bc --- /dev/null +++ b/extern/lua-5.1.5/test/README @@ -0,0 +1,26 @@ +These are simple tests for Lua. Some of them contain useful code. +They are meant to be run to make sure Lua is built correctly and also +to be read, to see how Lua programs look. + +Here is a one-line summary of each program: + + bisect.lua bisection method for solving non-linear equations + cf.lua temperature conversion table (celsius to farenheit) + echo.lua echo command line arguments + env.lua environment variables as automatic global variables + factorial.lua factorial without recursion + fib.lua fibonacci function with cache + fibfor.lua fibonacci numbers with coroutines and generators + globals.lua report global variable usage + hello.lua the first program in every language + life.lua Conway's Game of Life + luac.lua bare-bones luac + printf.lua an implementation of printf + readonly.lua make global variables readonly + sieve.lua the sieve of of Eratosthenes programmed with coroutines + sort.lua two implementations of a sort function + table.lua make table, grouping all data for the same item + trace-calls.lua trace calls + trace-globals.lua trace assigments to global variables + xd.lua hex dump + diff --git a/extern/lua-5.1.5/test/bisect.lua b/extern/lua-5.1.5/test/bisect.lua new file mode 100644 index 00000000..f91e69bf --- /dev/null +++ b/extern/lua-5.1.5/test/bisect.lua @@ -0,0 +1,27 @@ +-- bisection method for solving non-linear equations + +delta=1e-6 -- tolerance + +function bisect(f,a,b,fa,fb) + local c=(a+b)/2 + io.write(n," c=",c," a=",a," b=",b,"\n") + if c==a or c==b or math.abs(a-b) posted to lua-l +-- modified to use ANSI terminal escape sequences +-- modified to use for instead of while + +local write=io.write + +ALIVE="" DEAD="" +ALIVE="O" DEAD="-" + +function delay() -- NOTE: SYSTEM-DEPENDENT, adjust as necessary + for i=1,10000 do end + -- local i=os.clock()+1 while(os.clock() 0 do + local xm1,x,xp1,xi=self.w-1,self.w,1,self.w + while xi > 0 do + local sum = self[ym1][xm1] + self[ym1][x] + self[ym1][xp1] + + self[y][xm1] + self[y][xp1] + + self[yp1][xm1] + self[yp1][x] + self[yp1][xp1] + next[y][x] = ((sum==2) and self[y][x]) or ((sum==3) and 1) or 0 + xm1,x,xp1,xi = x,xp1,xp1+1,xi-1 + end + ym1,y,yp1,yi = y,yp1,yp1+1,yi-1 + end +end + +-- output the array to screen +function _CELLS:draw() + local out="" -- accumulate to reduce flicker + for y=1,self.h do + for x=1,self.w do + out=out..(((self[y][x]>0) and ALIVE) or DEAD) + end + out=out.."\n" + end + write(out) +end + +-- constructor +function CELLS(w,h) + local c = ARRAY2D(w,h) + c.spawn = _CELLS.spawn + c.evolve = _CELLS.evolve + c.draw = _CELLS.draw + return c +end + +-- +-- shapes suitable for use with spawn() above +-- +HEART = { 1,0,1,1,0,1,1,1,1; w=3,h=3 } +GLIDER = { 0,0,1,1,0,1,0,1,1; w=3,h=3 } +EXPLODE = { 0,1,0,1,1,1,1,0,1,0,1,0; w=3,h=4 } +FISH = { 0,1,1,1,1,1,0,0,0,1,0,0,0,0,1,1,0,0,1,0; w=5,h=4 } +BUTTERFLY = { 1,0,0,0,1,0,1,1,1,0,1,0,0,0,1,1,0,1,0,1,1,0,0,0,1; w=5,h=5 } + +-- the main routine +function LIFE(w,h) + -- create two arrays + local thisgen = CELLS(w,h) + local nextgen = CELLS(w,h) + + -- create some life + -- about 1000 generations of fun, then a glider steady-state + thisgen:spawn(GLIDER,5,4) + thisgen:spawn(EXPLODE,25,10) + thisgen:spawn(FISH,4,12) + + -- run until break + local gen=1 + write("\027[2J") -- ANSI clear screen + while 1 do + thisgen:evolve(nextgen) + thisgen,nextgen = nextgen,thisgen + write("\027[H") -- ANSI home cursor + thisgen:draw() + write("Life - generation ",gen,"\n") + gen=gen+1 + if gen>2000 then break end + --delay() -- no delay + end +end + +LIFE(40,20) diff --git a/extern/lua-5.1.5/test/luac.lua b/extern/lua-5.1.5/test/luac.lua new file mode 100644 index 00000000..96a0a97c --- /dev/null +++ b/extern/lua-5.1.5/test/luac.lua @@ -0,0 +1,7 @@ +-- bare-bones luac in Lua +-- usage: lua luac.lua file.lua + +assert(arg[1]~=nil and arg[2]==nil,"usage: lua luac.lua file.lua") +f=assert(io.open("luac.out","wb")) +assert(f:write(string.dump(assert(loadfile(arg[1]))))) +assert(f:close()) diff --git a/extern/lua-5.1.5/test/printf.lua b/extern/lua-5.1.5/test/printf.lua new file mode 100644 index 00000000..58c63ff5 --- /dev/null +++ b/extern/lua-5.1.5/test/printf.lua @@ -0,0 +1,7 @@ +-- an implementation of printf + +function printf(...) + io.write(string.format(...)) +end + +printf("Hello %s from %s on %s\n",os.getenv"USER" or "there",_VERSION,os.date()) diff --git a/extern/lua-5.1.5/test/readonly.lua b/extern/lua-5.1.5/test/readonly.lua new file mode 100644 index 00000000..85c0b4e0 --- /dev/null +++ b/extern/lua-5.1.5/test/readonly.lua @@ -0,0 +1,12 @@ +-- make global variables readonly + +local f=function (t,i) error("cannot redefine global variable `"..i.."'",2) end +local g={} +local G=getfenv() +setmetatable(g,{__index=G,__newindex=f}) +setfenv(1,g) + +-- an example +rawset(g,"x",3) +x=2 +y=1 -- cannot redefine `y' diff --git a/extern/lua-5.1.5/test/sieve.lua b/extern/lua-5.1.5/test/sieve.lua new file mode 100644 index 00000000..0871bb21 --- /dev/null +++ b/extern/lua-5.1.5/test/sieve.lua @@ -0,0 +1,29 @@ +-- the sieve of of Eratosthenes programmed with coroutines +-- typical usage: lua -e N=1000 sieve.lua | column + +-- generate all the numbers from 2 to n +function gen (n) + return coroutine.wrap(function () + for i=2,n do coroutine.yield(i) end + end) +end + +-- filter the numbers generated by `g', removing multiples of `p' +function filter (p, g) + return coroutine.wrap(function () + while 1 do + local n = g() + if n == nil then return end + if math.mod(n, p) ~= 0 then coroutine.yield(n) end + end + end) +end + +N=N or 1000 -- from command line +x = gen(N) -- generate primes up to N +while 1 do + local n = x() -- pick a number until done + if n == nil then break end + print(n) -- must be a prime number + x = filter(n, x) -- now remove its multiples +end diff --git a/extern/lua-5.1.5/test/sort.lua b/extern/lua-5.1.5/test/sort.lua new file mode 100644 index 00000000..0bcb15f8 --- /dev/null +++ b/extern/lua-5.1.5/test/sort.lua @@ -0,0 +1,66 @@ +-- two implementations of a sort function +-- this is an example only. Lua has now a built-in function "sort" + +-- extracted from Programming Pearls, page 110 +function qsort(x,l,u,f) + if ly end) + show("after reverse selection sort",x) + qsort(x,1,n,function (x,y) return x>> ",string.rep(" ",level)) + if t~=nil and t.currentline>=0 then io.write(t.short_src,":",t.currentline," ") end + t=debug.getinfo(2) + if event=="call" then + level=level+1 + else + level=level-1 if level<0 then level=0 end + end + if t.what=="main" then + if event=="call" then + io.write("begin ",t.short_src) + else + io.write("end ",t.short_src) + end + elseif t.what=="Lua" then +-- table.foreach(t,print) + io.write(event," ",t.name or "(Lua)"," <",t.linedefined,":",t.short_src,">") + else + io.write(event," ",t.name or "(C)"," [",t.what,"] ") + end + io.write("\n") +end + +debug.sethook(hook,"cr") +level=0 diff --git a/extern/lua-5.1.5/test/trace-globals.lua b/extern/lua-5.1.5/test/trace-globals.lua new file mode 100644 index 00000000..295e670c --- /dev/null +++ b/extern/lua-5.1.5/test/trace-globals.lua @@ -0,0 +1,38 @@ +-- trace assigments to global variables + +do + -- a tostring that quotes strings. note the use of the original tostring. + local _tostring=tostring + local tostring=function(a) + if type(a)=="string" then + return string.format("%q",a) + else + return _tostring(a) + end + end + + local log=function (name,old,new) + local t=debug.getinfo(3,"Sl") + local line=t.currentline + io.write(t.short_src) + if line>=0 then io.write(":",line) end + io.write(": ",name," is now ",tostring(new)," (was ",tostring(old),")","\n") + end + + local g={} + local set=function (t,name,value) + log(name,g[name],value) + g[name]=value + end + setmetatable(getfenv(),{__index=g,__newindex=set}) +end + +-- an example + +a=1 +b=2 +a=10 +b=20 +b=nil +b=200 +print(a,b,c) diff --git a/extern/lua-5.1.5/test/xd.lua b/extern/lua-5.1.5/test/xd.lua new file mode 100644 index 00000000..ebc3effc --- /dev/null +++ b/extern/lua-5.1.5/test/xd.lua @@ -0,0 +1,14 @@ +-- hex dump +-- usage: lua xd.lua < file + +local offset=0 +while true do + local s=io.read(16) + if s==nil then return end + io.write(string.format("%08X ",offset)) + string.gsub(s,"(.)", + function (c) io.write(string.format("%02X ",string.byte(c))) end) + io.write(string.rep(" ",3*(16-string.len(s)))) + io.write(" ",string.gsub(s,"%c","."),"\n") + offset=offset+16 +end diff --git a/include/addons/addon_manager.hpp b/include/addons/addon_manager.hpp new file mode 100644 index 00000000..cfbfd297 --- /dev/null +++ b/include/addons/addon_manager.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "addons/lua_engine.hpp" +#include "addons/toc_parser.hpp" +#include +#include +#include + +namespace wowee::addons { + +class AddonManager { +public: + AddonManager(); + ~AddonManager(); + + bool initialize(game::GameHandler* gameHandler); + void scanAddons(const std::string& addonsPath); + void loadAllAddons(); + bool runScript(const std::string& code); + void fireEvent(const std::string& event, const std::vector& args = {}); + void update(float deltaTime); + void shutdown(); + + const std::vector& getAddons() const { return addons_; } + LuaEngine* getLuaEngine() { return &luaEngine_; } + bool isInitialized() const { return luaEngine_.isInitialized(); } + + void saveAllSavedVariables(); + void setCharacterName(const std::string& name) { characterName_ = name; } + + /// Re-initialize the Lua VM and reload all addons (used by /reload). + bool reload(); + +private: + LuaEngine luaEngine_; + std::vector addons_; + game::GameHandler* gameHandler_ = nullptr; + std::string addonsPath_; + + bool loadAddon(const TocFile& addon); + std::string getSavedVariablesPath(const TocFile& addon) const; + std::string getSavedVariablesPerCharacterPath(const TocFile& addon) const; + std::string characterName_; +}; + +} // namespace wowee::addons diff --git a/include/addons/lua_engine.hpp b/include/addons/lua_engine.hpp new file mode 100644 index 00000000..6302a64c --- /dev/null +++ b/include/addons/lua_engine.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include + +struct lua_State; + +namespace wowee::game { class GameHandler; } + +namespace wowee::addons { + +struct TocFile; // forward declaration + +class LuaEngine { +public: + LuaEngine(); + ~LuaEngine(); + + LuaEngine(const LuaEngine&) = delete; + LuaEngine& operator=(const LuaEngine&) = delete; + + bool initialize(); + void shutdown(); + + bool executeFile(const std::string& path); + bool executeString(const std::string& code); + + void setGameHandler(game::GameHandler* handler); + + // Fire a WoW event to all registered Lua handlers. + void fireEvent(const std::string& eventName, + const std::vector& args = {}); + + // Try to dispatch a slash command via SlashCmdList. Returns true if handled. + bool dispatchSlashCommand(const std::string& command, const std::string& args); + + // Call OnUpdate scripts on all frames that have one. + void dispatchOnUpdate(float elapsed); + + // SavedVariables: load globals from file, save globals to file + bool loadSavedVariables(const std::string& path); + bool saveSavedVariables(const std::string& path, const std::vector& varNames); + + // Store addon info in registry for GetAddOnInfo/GetNumAddOns + void setAddonList(const std::vector& addons); + + lua_State* getState() { return L_; } + bool isInitialized() const { return L_ != nullptr; } + + // Optional callback for Lua errors (displayed as UI errors to the player) + using LuaErrorCallback = std::function; + void setLuaErrorCallback(LuaErrorCallback cb) { luaErrorCallback_ = std::move(cb); } + +private: + lua_State* L_ = nullptr; + game::GameHandler* gameHandler_ = nullptr; + LuaErrorCallback luaErrorCallback_; + + void registerCoreAPI(); + void registerEventAPI(); +}; + +} // namespace wowee::addons diff --git a/include/addons/toc_parser.hpp b/include/addons/toc_parser.hpp new file mode 100644 index 00000000..b19b4a78 --- /dev/null +++ b/include/addons/toc_parser.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include +#include + +namespace wowee::addons { + +struct TocFile { + std::string addonName; + std::string basePath; + + std::unordered_map directives; + std::vector files; + + std::string getTitle() const; + std::string getInterface() const; + bool isLoadOnDemand() const; + std::vector getSavedVariables() const; + std::vector getSavedVariablesPerCharacter() const; +}; + +std::optional parseTocFile(const std::string& tocPath); + +} // namespace wowee::addons diff --git a/include/audio/ui_sound_manager.hpp b/include/audio/ui_sound_manager.hpp index 241014ae..7a9a66b8 100644 --- a/include/audio/ui_sound_manager.hpp +++ b/include/audio/ui_sound_manager.hpp @@ -75,6 +75,12 @@ public: void playTargetSelect(); void playTargetDeselect(); + // Chat notifications + void playWhisperReceived(); + + // Minimap ping + void playMinimapPing(); + private: struct UISample { std::string path; @@ -122,6 +128,8 @@ private: std::vector errorSounds_; std::vector selectTargetSounds_; std::vector deselectTargetSounds_; + std::vector whisperSounds_; + std::vector minimapPingSounds_; // State tracking float volumeScale_ = 1.0f; diff --git a/include/core/application.hpp b/include/core/application.hpp index d2ef3f36..a22a210e 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -26,6 +26,7 @@ namespace auth { class AuthHandler; } namespace game { class GameHandler; class World; class ExpansionRegistry; } namespace pipeline { class AssetManager; class DBCLayout; struct M2Model; struct WMOModel; } namespace audio { enum class VoiceType; } +namespace addons { class AddonManager; } namespace core { @@ -62,6 +63,7 @@ public: game::GameHandler* getGameHandler() { return gameHandler.get(); } game::World* getWorld() { return world.get(); } pipeline::AssetManager* getAssetManager() { return assetManager.get(); } + addons::AddonManager* getAddonManager() { return addonManager_.get(); } game::ExpansionRegistry* getExpansionRegistry() { return expansionRegistry_.get(); } pipeline::DBCLayout* getDBCLayout() { return dbcLayout_.get(); } void reloadExpansionData(); // Reload DBC layouts, opcodes, etc. after expansion change @@ -98,7 +100,7 @@ private: void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); void buildFactionHostilityMap(uint8_t playerRace); pipeline::M2Model loadCreatureM2Sync(const std::string& m2Path); - void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation); + void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); void despawnOnlineCreature(uint64_t guid); bool tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId); void spawnOnlinePlayer(uint64_t guid, @@ -113,7 +115,7 @@ private: void despawnOnlinePlayer(uint64_t guid); void buildCreatureDisplayLookups(); std::string getModelPathForDisplayId(uint32_t displayId) const; - void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation); + void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); void despawnOnlineGameObject(uint64_t guid); void buildGameObjectDisplayLookups(); std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const; @@ -129,6 +131,8 @@ private: std::unique_ptr gameHandler; std::unique_ptr world; std::unique_ptr assetManager; + std::unique_ptr addonManager_; + bool addonsLoaded_ = false; std::unique_ptr expansionRegistry_; std::unique_ptr dbcLayout_; @@ -187,6 +191,13 @@ private: std::unordered_map creatureInstances_; // guid → render instanceId std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position + std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureWasSwimming_; // guid -> previous-frame swim state (for anim transition detection) + std::unordered_map creatureWasFlying_; // guid -> previous-frame flying state (for anim transition detection) + std::unordered_map creatureWasWalking_; // guid -> previous-frame walking state (walk vs run transition detection) + std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) + std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) + std::unordered_map creatureFlyingState_; // guid -> currently flying (FLYING flag) std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check @@ -207,6 +218,7 @@ private: uint32_t displayId; uint32_t modelId; float x, y, z, orientation; + float scale = 1.0f; std::shared_ptr model; // parsed on background thread std::unordered_map predecodedTextures; // decoded on bg thread bool valid = false; @@ -216,6 +228,7 @@ private: std::future future; }; std::vector asyncCreatureLoads_; + std::unordered_set asyncCreatureDisplayLoads_; // displayIds currently loading in background void processAsyncCreatureResults(bool unlimited = false); static constexpr int MAX_ASYNC_CREATURE_LOADS = 4; // concurrent background loads std::unordered_set deadCreatureGuids_; // GUIDs that should spawn in corpse/death pose @@ -263,6 +276,7 @@ private: }; std::unordered_map gameObjectDisplayIdToPath_; std::unordered_map gameObjectDisplayIdModelCache_; // displayId → M2 modelId + std::unordered_set gameObjectDisplayIdFailedCache_; // displayIds that permanently fail to load std::unordered_map gameObjectDisplayIdWmoCache_; // displayId → WMO modelId std::unordered_map gameObjectInstances_; // guid → instance info struct PendingTransportMove { @@ -271,7 +285,17 @@ private: float z = 0.0f; float orientation = 0.0f; }; + struct PendingTransportRegistration { + uint64_t guid = 0; + uint32_t entry = 0; + uint32_t displayId = 0; + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; + float orientation = 0.0f; + }; std::unordered_map pendingTransportMoves_; // guid -> latest pre-registration move + std::deque pendingTransportRegistrations_; uint32_t nextGameObjectModelId_ = 20000; uint32_t nextGameObjectWmoModelId_ = 40000; bool testTransportSetup_ = false; @@ -293,6 +317,7 @@ private: uint64_t guid; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; }; std::deque pendingCreatureSpawns_; static constexpr int MAX_SPAWNS_PER_FRAME = 3; @@ -386,6 +411,7 @@ private: uint32_t entry; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; }; std::vector pendingGameObjectSpawns_; void processGameObjectSpawnQueue(); @@ -396,6 +422,7 @@ private: uint32_t entry; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; std::shared_ptr wmoModel; std::unordered_map predecodedTextures; // decoded on bg thread bool valid = false; @@ -421,6 +448,7 @@ private: }; std::vector pendingTransportDoodadBatches_; static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4; + void processPendingTransportRegistrations(); void processPendingTransportDoodads(); // Quest marker billboard sprites (above NPCs) diff --git a/include/game/entity.hpp b/include/game/entity.hpp index 9f4dfde7..a608f6f5 100644 --- a/include/game/entity.hpp +++ b/include/game/entity.hpp @@ -135,6 +135,13 @@ public: bool isEntityMoving() const { return isMoving_; } + /// True only during the active interpolation phase (before reaching destination). + /// Unlike isEntityMoving(), this does NOT include the dead-reckoning overrun window, + /// so animations (Run/Walk) should use this to avoid "running in place" after arrival. + bool isActivelyMoving() const { + return isMoving_ && moveElapsed_ < moveDuration_; + } + // Returns the latest server-authoritative position: destination if moving, current if not. // Unlike getX/Y/Z (which only update via updateMovement), this always reflects the // last known server position regardless of distance culling. @@ -214,6 +221,9 @@ public: void setMaxPower(uint32_t p) { maxPowers[powerType < 7 ? powerType : 0] = p; } void setMaxPowerByType(uint8_t type, uint32_t p) { if (type < 7) maxPowers[type] = p; } + uint32_t getPowerByType(uint8_t type) const { return type < 7 ? powers[type] : 0; } + uint32_t getMaxPowerByType(uint8_t type) const { return type < 7 ? maxPowers[type] : 0; } + uint8_t getPowerType() const { return powerType; } void setPowerType(uint8_t t) { powerType = t; } @@ -274,18 +284,14 @@ protected: /** * Player entity + * Name is inherited from Unit — do NOT redeclare it here or the + * shadowed field will diverge from Unit::name, causing nameplates + * and other Unit*-based lookups to read an empty string. */ class Player : public Unit { public: Player() { type = ObjectType::PLAYER; } explicit Player(uint64_t guid) : Unit(guid) { type = ObjectType::PLAYER; } - - // Name - const std::string& getName() const { return name; } - void setName(const std::string& n) { name = n; } - -protected: - std::string name; }; /** diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 42481376..5bb40efa 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -7,6 +7,7 @@ #include "game/inventory.hpp" #include "game/spell_defines.hpp" #include "game/group_defines.hpp" +#include "network/packet.hpp" #include #include #include @@ -21,6 +22,7 @@ #include #include #include +#include namespace wowee::game { class TransportManager; @@ -38,8 +40,11 @@ namespace game { struct PlayerSkill { uint32_t skillId = 0; - uint16_t value = 0; + uint16_t value = 0; // base + permanent item bonuses uint16_t maxValue = 0; + uint16_t bonusTemp = 0; // temporary buff bonus (food, potions, etc.) + uint16_t bonusPerm = 0; // permanent spec/misc bonus (rarely non-zero) + uint16_t effectiveValue() const { return value + bonusTemp + bonusPerm; } }; /** @@ -218,6 +223,7 @@ public: pos = homeBindPos_; return true; } + uint32_t getHomeBindZoneId() const { return homeBindZoneId_; } /** * Send a movement packet @@ -273,6 +279,44 @@ public: using ChatBubbleCallback = std::function; void setChatBubbleCallback(ChatBubbleCallback cb) { chatBubbleCallback_ = std::move(cb); } + // Addon chat event callback: fires when any chat message is received (for Lua event dispatch) + using AddonChatCallback = std::function; + void setAddonChatCallback(AddonChatCallback cb) { addonChatCallback_ = std::move(cb); } + + // Generic addon event callback: fires named events with string args + using AddonEventCallback = std::function&)>; + void setAddonEventCallback(AddonEventCallback cb) { addonEventCallback_ = std::move(cb); } + + // Spell icon path resolver: spellId -> texture path string (e.g., "Interface\\Icons\\Spell_Fire_Fireball01") + using SpellIconPathResolver = std::function; + void setSpellIconPathResolver(SpellIconPathResolver r) { spellIconPathResolver_ = std::move(r); } + std::string getSpellIconPath(uint32_t spellId) const { + return spellIconPathResolver_ ? spellIconPathResolver_(spellId) : std::string{}; + } + + // Spell data resolver: spellId -> {castTimeMs, minRange, maxRange} + struct SpellDataInfo { uint32_t castTimeMs = 0; float minRange = 0; float maxRange = 0; uint32_t manaCost = 0; uint8_t powerType = 0; }; + using SpellDataResolver = std::function; + void setSpellDataResolver(SpellDataResolver r) { spellDataResolver_ = std::move(r); } + SpellDataInfo getSpellData(uint32_t spellId) const { + return spellDataResolver_ ? spellDataResolver_(spellId) : SpellDataInfo{}; + } + + // Item icon path resolver: displayInfoId -> texture path (e.g., "Interface\\Icons\\INV_Sword_04") + using ItemIconPathResolver = std::function; + void setItemIconPathResolver(ItemIconPathResolver r) { itemIconPathResolver_ = std::move(r); } + std::string getItemIconPath(uint32_t displayInfoId) const { + return itemIconPathResolver_ ? itemIconPathResolver_(displayInfoId) : std::string{}; + } + + // Random property/suffix name resolver: randomPropertyId -> suffix name (e.g., "of the Eagle") + // Positive IDs → ItemRandomProperties.dbc; negative IDs → ItemRandomSuffix.dbc (abs value) + using RandomPropertyNameResolver = std::function; + void setRandomPropertyNameResolver(RandomPropertyNameResolver r) { randomPropertyNameResolver_ = std::move(r); } + std::string getRandomPropertyName(int32_t id) const { + return randomPropertyNameResolver_ ? randomPropertyNameResolver_(id) : std::string{}; + } + // Emote animation callback: (entityGuid, animationId) using EmoteAnimCallback = std::function; void setEmoteAnimCallback(EmoteAnimCallback cb) { emoteAnimCallback_ = std::move(cb); } @@ -283,6 +327,7 @@ public: * @return Vector of chat messages */ const std::deque& getChatHistory() const { return chatHistory; } + void clearChatHistory() { chatHistory.clear(); } /** * Add a locally-generated chat message (e.g., emote feedback) @@ -292,9 +337,64 @@ public: // Money (copper) uint64_t getMoneyCopper() const { return playerMoneyCopper_; } + // PvP currency (TBC/WotLK only) + uint32_t getHonorPoints() const { return playerHonorPoints_; } + uint32_t getArenaPoints() const { return playerArenaPoints_; } + // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } + // Server-authoritative elemental resistances (UNIT_FIELD_RESISTANCES[1-6]). + // school: 1=Holy, 2=Fire, 3=Nature, 4=Frost, 5=Shadow, 6=Arcane. Returns 0 if not received. + int32_t getResistance(int school) const { + if (school < 1 || school > 6) return 0; + return playerResistances_[school - 1]; + } + + // Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI). + // Returns -1 if the server hasn't sent the value yet. + int32_t getPlayerStat(int idx) const { + if (idx < 0 || idx > 4) return -1; + return playerStats_[idx]; + } + + // Server-authoritative attack power (WotLK: UNIT_FIELD_ATTACK_POWER / RANGED). + // Returns -1 if not yet received. + int32_t getMeleeAttackPower() const { return playerMeleeAP_; } + int32_t getRangedAttackPower() const { return playerRangedAP_; } + + // Server-authoritative spell damage / healing bonus (WotLK: PLAYER_FIELD_MOD_*). + // getSpellPower returns the max damage bonus across magic schools 1-6 (Holy/Fire/Nature/Frost/Shadow/Arcane). + // Returns -1 if not yet received. + int32_t getSpellPower() const { + int32_t sp = -1; + for (int i = 1; i <= 6; ++i) { + if (playerSpellDmgBonus_[i] > sp) sp = playerSpellDmgBonus_[i]; + } + return sp; + } + int32_t getHealingPower() const { return playerHealBonus_; } + + // Server-authoritative combat chance percentages (WotLK: PLAYER_* float fields). + // Returns -1.0f if not yet received. + float getDodgePct() const { return playerDodgePct_; } + float getParryPct() const { return playerParryPct_; } + float getBlockPct() const { return playerBlockPct_; } + float getCritPct() const { return playerCritPct_; } + float getRangedCritPct() const { return playerRangedCritPct_; } + // Spell crit by school (0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane) + float getSpellCritPct(int school = 1) const { + if (school < 0 || school > 6) return -1.0f; + return playerSpellCritPct_[school]; + } + + // Server-authoritative combat ratings (WotLK: PLAYER_FIELD_COMBAT_RATING_1+idx). + // Returns -1 if not yet received. Indices match AzerothCore CombatRating enum. + int32_t getCombatRating(int cr) const { + if (cr < 0 || cr > 24) return -1; + return playerCombatRatings_[cr]; + } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -317,6 +417,10 @@ public: std::shared_ptr getFocus() const; bool hasFocus() const { return focusGuid != 0; } + // Mouseover targeting — set each frame by the nameplate renderer + void setMouseoverGuid(uint64_t guid); + uint64_t getMouseoverGuid() const { return mouseoverGuid_; } + // Advanced targeting void targetLastTarget(); void targetEnemy(bool reverse = false); @@ -325,10 +429,50 @@ public: // Inspection void inspectTarget(); + struct InspectArenaTeam { + uint32_t teamId = 0; + uint8_t type = 0; // bracket size: 2, 3, or 5 + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + std::string name; + uint32_t personalRating = 0; + }; + struct InspectResult { + uint64_t guid = 0; + std::string playerName; + uint32_t totalTalents = 0; + uint32_t unspentTalents = 0; + uint8_t talentGroups = 0; + uint8_t activeTalentGroup = 0; + std::array itemEntries{}; // 0=head…18=ranged + std::array enchantIds{}; // permanent enchant per slot (0 = none) + std::vector arenaTeams; // from MSG_INSPECT_ARENA_TEAMS (WotLK) + }; + const InspectResult* getInspectResult() const { + return inspectResult_.guid ? &inspectResult_ : nullptr; + } + // Server info commands void queryServerTime(); void requestPlayedTime(); void queryWho(const std::string& playerName = ""); + uint32_t getTotalTimePlayed() const { return totalTimePlayed_; } + uint32_t getLevelTimePlayed() const { return levelTimePlayed_; } + + // Who results (structured, from last SMSG_WHO response) + struct WhoEntry { + std::string name; + std::string guildName; + uint32_t level = 0; + uint32_t classId = 0; + uint32_t raceId = 0; + uint32_t zoneId = 0; + }; + const std::vector& getWhoResults() const { return whoResults_; } + uint32_t getWhoOnlineCount() const { return whoOnlineCount_; } + std::string getWhoAreaName(uint32_t zoneId) const { return getAreaName(zoneId); } // Social commands void addFriend(const std::string& playerName, const std::string& note = ""); @@ -336,17 +480,87 @@ public: void setFriendNote(const std::string& playerName, const std::string& note); void addIgnore(const std::string& playerName); void removeIgnore(const std::string& playerName); + const std::unordered_map& getIgnoreCache() const { return ignoreCache; } // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); + // Battleground queue slot (public so UI can read invite details) + struct BgQueueSlot { + uint32_t queueSlot = 0; + uint32_t bgTypeId = 0; + uint8_t arenaType = 0; + uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress + uint32_t inviteTimeout = 80; + uint32_t avgWaitTimeSec = 0; // server-estimated average wait (STATUS_WAIT_QUEUE) + uint32_t timeInQueueSec = 0; // time already spent in queue (STATUS_WAIT_QUEUE) + std::chrono::steady_clock::time_point inviteReceivedTime{}; + std::string bgName; // human-readable BG/arena name + }; + + // Available BG list (populated by SMSG_BATTLEFIELD_LIST) + struct AvailableBgInfo { + uint32_t bgTypeId = 0; + bool isRegistered = false; + bool isHoliday = false; + uint32_t minLevel = 0; + uint32_t maxLevel = 0; + std::vector instanceIds; + }; + // Battleground bool hasPendingBgInvite() const; void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + const std::array& getBgQueues() const { return bgQueues_; } + const std::vector& getAvailableBgs() const { return availableBgs_; } + + // BG scoreboard (MSG_PVP_LOG_DATA) + struct BgPlayerScore { + uint64_t guid = 0; + std::string name; + uint8_t team = 0; // 0=Horde, 1=Alliance + uint32_t killingBlows = 0; + uint32_t deaths = 0; + uint32_t honorableKills = 0; + uint32_t bonusHonor = 0; + std::vector> bgStats; // BG-specific fields + }; + struct ArenaTeamScore { + std::string teamName; + uint32_t ratingChange = 0; // signed delta packed as uint32 + uint32_t newRating = 0; + }; + struct BgScoreboardData { + std::vector players; + bool hasWinner = false; + uint8_t winner = 0; // 0=Horde, 1=Alliance + bool isArena = false; + // Arena-only fields (valid when isArena=true) + ArenaTeamScore arenaTeams[2]; // team 0 = first, team 1 = second + }; + void requestPvpLog(); + const BgScoreboardData* getBgScoreboard() const { + return bgScoreboard_.players.empty() ? nullptr : &bgScoreboard_; + } + + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + struct BgPlayerPosition { + uint64_t guid = 0; + float wowX = 0.0f; // canonical WoW X (north) + float wowY = 0.0f; // canonical WoW Y (west) + int group = 0; // 0 = first list (usually ally flag carriers), 1 = second list + }; + const std::vector& getBgPlayerPositions() const { return bgPlayerPositions_; } + + // Network latency (milliseconds, updated each PONG response) + uint32_t getLatencyMs() const { return lastLatency; } // Logout commands void requestLogout(); void cancelLogout(); + bool isLoggingOut() const { return loggingOut_; } + float getLogoutCountdown() const { return logoutCountdown_; } // Stand state void setStandState(uint8_t state); // 0=stand, 1=sit, 2=sit_chair, 3=sleep, 4=sit_low_chair, 5=sit_medium_chair, 6=sit_high_chair, 7=dead, 8=kneel, 9=submerged @@ -358,14 +572,20 @@ public: // Display toggles void toggleHelm(); void toggleCloak(); + bool isHelmVisible() const { return helmVisible_; } + bool isCloakVisible() const { return cloakVisible_; } // Follow/Assist void followTarget(); + void cancelFollow(); // Stop following current target void assistTarget(); // PvP void togglePvp(); + // Minimap ping (Ctrl+click on minimap; wowX/wowY in canonical WoW coords) + void sendMinimapPing(float wowX, float wowY); + // Guild commands void requestGuildInfo(); void requestGuildRoster(); @@ -381,6 +601,28 @@ public: void setGuildOfficerNote(const std::string& name, const std::string& note); void acceptGuildInvite(); void declineGuildInvite(); + + // GM Ticket + void submitGmTicket(const std::string& text); + void deleteGmTicket(); + void requestGmTicket(); ///< Send CMSG_GMTICKET_GETTICKET to query open ticket + + // GM ticket status accessors + bool hasActiveGmTicket() const { return gmTicketActive_; } + const std::string& getGmTicketText() const { return gmTicketText_; } + bool isGmSupportAvailable() const { return gmSupportAvailable_; } + float getGmTicketWaitHours() const { return gmTicketWaitHours_; } + + // Battlefield Manager (Wintergrasp) + bool hasBfMgrInvite() const { return bfMgrInvitePending_; } + bool isInBfMgrZone() const { return bfMgrActive_; } + uint32_t getBfMgrZoneId() const { return bfMgrZoneId_; } + void acceptBfMgrInvite(); + void declineBfMgrInvite(); + + // WotLK Calendar + uint32_t getCalendarPendingInvites() const { return calendarPendingInvites_; } + void requestCalendar(); ///< Send CMSG_CALENDAR_GET_CALENDAR to the server void queryGuildInfo(uint32_t guildId); void createGuild(const std::string& guildName); void addGuildRank(const std::string& rankName); @@ -409,12 +651,44 @@ public: uint32_t getPetitionCost() const { return petitionCost_; } uint64_t getPetitionNpcGuid() const { return petitionNpcGuid_; } + // Petition signatures (guild charter signing flow) + struct PetitionSignature { + uint64_t playerGuid = 0; + std::string playerName; // resolved later or empty + }; + struct PetitionInfo { + uint64_t petitionGuid = 0; + uint64_t ownerGuid = 0; + std::string guildName; + uint32_t signatureCount = 0; + uint32_t signaturesRequired = 9; // guild default; arena teams differ + std::vector signatures; + bool showUI = false; + }; + const PetitionInfo& getPetitionInfo() const { return petitionInfo_; } + bool hasPetitionSignaturesUI() const { return petitionInfo_.showUI; } + void clearPetitionSignaturesUI() { petitionInfo_.showUI = false; } + void signPetition(uint64_t petitionGuid); + void turnInPetition(uint64_t petitionGuid); + + // Guild name lookup for other players' nameplates + // Returns the guild name for a given guildId, or empty if unknown. + // Automatically queries the server for unknown guild IDs. + const std::string& lookupGuildName(uint32_t guildId); + // Returns the guildId for a player entity (from PLAYER_GUILDID update field). + uint32_t getEntityGuildId(uint64_t guid) const; + // Ready check + struct ReadyCheckResult { + std::string name; + bool ready = false; + }; void initiateReadyCheck(); void respondToReadyCheck(bool ready); bool hasPendingReadyCheck() const { return pendingReadyCheck_; } void dismissReadyCheck() { pendingReadyCheck_ = false; } const std::string& getReadyCheckInitiator() const { return readyCheckInitiator_; } + const std::vector& getReadyCheckResults() const { return readyCheckResults_; } // Duel void forfeitDuel(); @@ -422,6 +696,8 @@ public: // AFK/DND status void toggleAfk(const std::string& message = ""); void toggleDnd(const std::string& message = ""); + bool isAfk() const { return afkStatus_; } + bool isDnd() const { return dndStatus_; } void replyToLastWhisper(const std::string& message); std::string getLastWhisperSender() const { return lastWhisperSender_; } void setLastWhisperSender(const std::string& name) { lastWhisperSender_ = name; } @@ -450,6 +726,27 @@ public: } std::string getCachedPlayerName(uint64_t guid) const; std::string getCachedCreatureName(uint32_t entry) const; + // Returns the creature subname/title (e.g. ""), empty if not cached + std::string getCachedCreatureSubName(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.subName : ""; + } + // Returns the creature rank (0=Normal,1=Elite,2=RareElite,3=Boss,4=Rare) + // or -1 if not cached yet + int getCreatureRank(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? static_cast(it->second.rank) : -1; + } + // Returns creature type (1=Beast,2=Dragonkin,...,7=Humanoid,...) or 0 if not cached + uint32_t getCreatureType(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.creatureType : 0; + } + // Returns creature family (e.g. pet family for beasts) or 0 + uint32_t getCreatureFamily(uint32_t entry) const { + auto it = creatureInfoCache.find(entry); + return (it != creatureInfoCache.end()) ? it->second.family : 0; + } // ---- Phase 2: Combat ---- void startAutoAttack(uint64_t targetGuid); @@ -464,15 +761,49 @@ public: } uint64_t getAutoAttackTargetGuid() const { return autoAttackTarget; } bool isAggressiveTowardPlayer(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } + // Timestamp (ms since epoch) of the most recent player melee auto-attack. + // Zero if no swing has occurred this session. + uint64_t getLastMeleeSwingMs() const { return lastMeleeSwingMs_; } const std::vector& getCombatText() const { return combatText; } void updateCombatText(float deltaTime); + // Combat log (persistent rolling history, max MAX_COMBAT_LOG entries) + const std::deque& getCombatLog() const { return combatLog_; } + void clearCombatLog() { combatLog_.clear(); } + + // Area trigger messages (SMSG_AREA_TRIGGER_MESSAGE) — drained by UI each frame + bool hasAreaTriggerMsg() const { return !areaTriggerMsgs_.empty(); } + std::string popAreaTriggerMsg() { + if (areaTriggerMsgs_.empty()) return {}; + std::string msg = areaTriggerMsgs_.front(); + areaTriggerMsgs_.pop_front(); + return msg; + } + + // Threat + struct ThreatEntry { + uint64_t victimGuid = 0; + uint32_t threat = 0; + }; + // Returns the current threat list for a given unit GUID (from last SMSG_THREAT_UPDATE) + const std::vector* getThreatList(uint64_t unitGuid) const { + auto it = threatLists_.find(unitGuid); + return (it != threatLists_.end()) ? &it->second : nullptr; + } + // Returns the threat list for the player's current target, or nullptr + const std::vector* getTargetThreatList() const { + return targetGuid ? getThreatList(targetGuid) : nullptr; + } + // ---- Phase 3: Spells ---- void castSpell(uint32_t spellId, uint64_t targetGuid = 0); void cancelCast(); void cancelAura(uint32_t spellId); void dismissPet(); + void renamePet(const std::string& newName); bool hasPet() const { return petGuid_ != 0; } + // Returns true once after SMSG_PET_RENAMEABLE; consuming the flag clears it. + bool consumePetRenameablePending() { bool v = petRenameablePending_; petRenameablePending_ = false; return v; } uint64_t getPetGuid() const { return petGuid_; } // ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ---- @@ -495,8 +826,36 @@ public: } // Send CMSG_PET_ACTION to issue a pet command void sendPetAction(uint32_t action, uint64_t targetGuid = 0); + // Toggle autocast for a pet spell via CMSG_PET_SPELL_AUTOCAST + void togglePetSpellAutocast(uint32_t spellId); const std::unordered_set& getKnownSpells() const { return knownSpells; } + // Spell book tabs — groups known spells by class skill line for Lua API + struct SpellBookTab { + std::string name; + std::string texture; // icon path + std::vector spellIds; // spells in this tab + }; + const std::vector& getSpellBookTabs(); + + // ---- Pet Stable ---- + struct StabledPet { + uint32_t petNumber = 0; // server-side pet number (used for unstable/swap) + uint32_t entry = 0; // creature entry ID + uint32_t level = 0; + std::string name; + uint32_t displayId = 0; + bool isActive = false; // true = currently summoned/active slot + }; + bool isStableWindowOpen() const { return stableWindowOpen_; } + void closeStableWindow() { stableWindowOpen_ = false; } + uint64_t getStableMasterGuid() const { return stableMasterGuid_; } + uint8_t getStableSlots() const { return stableNumSlots_; } + const std::vector& getStabledPets() const { return stabledPets_; } + void requestStabledPetList(); // CMSG MSG_LIST_STABLED_PETS + void stablePet(uint8_t slot); // CMSG_STABLE_PET (store active pet in slot) + void unstablePet(uint32_t petNumber); // CMSG_UNSTABLE_PET (retrieve to active) + // Player proficiency bitmasks (from SMSG_SET_PROFICIENCY) // itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing) // itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield) @@ -524,19 +883,33 @@ public: } bool isCasting() const { return casting; } + bool isChanneling() const { return casting && castIsChannel; } bool isGameObjectInteractionCasting() const { return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0; } uint32_t getCurrentCastSpellId() const { return currentCastSpellId; } float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; } float getCastTimeRemaining() const { return castTimeRemaining; } + float getCastTimeTotal() const { return castTimeTotal; } + + // Repeat-craft queue + void startCraftQueue(uint32_t spellId, int count); + void cancelCraftQueue(); + int getCraftQueueRemaining() const { return craftQueueRemaining_; } + uint32_t getCraftQueueSpellId() const { return craftQueueSpellId_; } + + // 400ms spell-queue window: next spell to cast when current finishes + uint32_t getQueuedSpellId() const { return queuedSpellId_; } + void cancelQueuedSpell() { queuedSpellId_ = 0; queuedSpellTarget_ = 0; } // Unit cast state (tracked per GUID for target frame + boss frames) struct UnitCastState { bool casting = false; + bool isChannel = false; ///< true for channels (MSG_CHANNEL_START), false for casts (SMSG_SPELL_START) uint32_t spellId = 0; float timeRemaining = 0.0f; float timeTotal = 0.0f; + bool interruptible = true; ///< false when SPELL_ATTR_EX_NOT_INTERRUPTIBLE is set }; // Returns cast state for any unit by GUID (empty/non-casting if not found) const UnitCastState* getUnitCastState(uint64_t guid) const { @@ -558,6 +931,10 @@ public: auto* s = getUnitCastState(targetGuid); return s ? s->timeRemaining : 0.0f; } + bool isTargetCastInterruptible() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->interruptible : true; + } // Talents uint8_t getActiveTalentSpec() const { return activeTalentSpec_; } @@ -568,6 +945,14 @@ public: static std::unordered_map empty; return spec < 2 ? learnedTalents_[spec] : empty; } + + // Glyphs (WotLK): up to 6 glyph slots per spec (3 major + 3 minor) + static constexpr uint8_t MAX_GLYPH_SLOTS = 6; + const std::array& getGlyphs() const { return learnedGlyphs_[activeTalentSpec_]; } + const std::array& getGlyphs(uint8_t spec) const { + static std::array empty{}; + return spec < 2 ? learnedGlyphs_[spec] : empty; + } uint8_t getTalentRank(uint32_t talentId) const { auto it = learnedTalents_[activeTalentSpec_].find(talentId); return (it != learnedTalents_[activeTalentSpec_].end()) ? it->second : 0; @@ -588,14 +973,22 @@ public: const std::unordered_map& getAllTalentTabs() const { return talentTabCache_; } void loadTalentDbc(); - // Action bar — 2 bars × 12 slots = 24 total + // Action bar — 4 bars × 12 slots = 48 total + // Bar 0 (slots 0-11): main bottom bar (1-0, -, =) + // Bar 1 (slots 12-23): second bar above main (Shift+1 ... Shift+=) + // Bar 2 (slots 24-35): right side vertical bar + // Bar 3 (slots 36-47): left side vertical bar static constexpr int SLOTS_PER_BAR = 12; - static constexpr int ACTION_BARS = 2; - static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24 + static constexpr int ACTION_BARS = 4; + static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 48 std::array& getActionBar() { return actionBar; } const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); + // Client-side macro text storage (server sends only macro index; text is stored locally) + const std::string& getMacroText(uint32_t macroId) const; + void setMacroText(uint32_t macroId, const std::string& text); + void saveCharacterConfig(); void loadCharacterConfig(); static std::string getCharacterConfigDir(); @@ -603,6 +996,11 @@ public: // Auras const std::vector& getPlayerAuras() const { return playerAuras; } const std::vector& getTargetAuras() const { return targetAuras; } + // Per-unit aura cache (populated for party members and any unit we receive updates for) + const std::vector* getUnitAuras(uint64_t guid) const { + auto it = unitAurasCache_.find(guid); + return (it != unitAurasCache_.end()) ? &it->second : nullptr; + } // Completed quests (populated from SMSG_QUERY_QUESTS_COMPLETED_RESPONSE) bool isQuestCompleted(uint32_t questId) const { return completedQuests_.count(questId) > 0; } @@ -619,10 +1017,41 @@ public: using NpcRespawnCallback = std::function; 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 setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); } + + // Appearance changed callback — fired when PLAYER_BYTES or facial features update (barber shop, etc.) + using AppearanceChangedCallback = std::function; + void setAppearanceChangedCallback(AppearanceChangedCallback cb) { appearanceChangedCallback_ = std::move(cb); } + + // Ghost state callback — fired when player enters or leaves ghost (spirit) form + using GhostStateCallback = std::function; + void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); } + // Melee swing callback (for driving animation/SFX) using MeleeSwingCallback = std::function; 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 setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } + + // Fired when the player's own spell cast fails (spellId of the failed spell). + using SpellCastFailedCallback = std::function; + void setSpellCastFailedCallback(SpellCastFailedCallback cb) { spellCastFailedCallback_ = std::move(cb); } + + // Unit animation hint: signal jump (animId=38) for other players/NPCs + using UnitAnimHintCallback = std::function; + 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 setUnitMoveFlagsCallback(UnitMoveFlagsCallback cb) { unitMoveFlagsCallback_ = std::move(cb); } + // NPC swing callback (plays attack animation on NPC) using NpcSwingCallback = std::function; void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); } @@ -640,6 +1069,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& getPlayerExploredZoneMasks() const { return playerExploredZones_; } bool hasPlayerExploredZoneMasks() const { return hasPlayerExploredZones_; } @@ -649,6 +1080,17 @@ public: float getGameTime() const { return gameTime_; } float getTimeSpeed() const { return timeSpeed_; } + // Global Cooldown (GCD) — set when the server sends a spellId=0 cooldown entry + float getGCDRemaining() const { + if (gcdTotal_ <= 0.0f) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - gcdStartedAt_).count() / 1000.0f; + float rem = gcdTotal_ - elapsed; + return rem > 0.0f ? rem : 0.0f; + } + float getGCDTotal() const { return gcdTotal_; } + bool isGCDActive() const { return getGCDRemaining() > 0.0f; } + // Weather state (updated by SMSG_WEATHER) // weatherType: 0=clear, 1=rain, 2=snow, 3=storm/fog uint32_t getWeatherType() const { return weatherType_; } @@ -662,12 +1104,23 @@ public: const std::map& getPlayerSkills() const { return playerSkills_; } const std::string& getSkillName(uint32_t skillId) const; uint32_t getSkillCategory(uint32_t skillId) const; + bool isProfessionSpell(uint32_t spellId) const; // World entry callback (online mode - triggered when entering world) - // Parameters: mapId, x, y, z (canonical WoW coordinates) - using WorldEntryCallback = std::function; + // Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect + using WorldEntryCallback = std::function; void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } + // Knockback callback: called when server sends SMSG_MOVE_KNOCK_BACK for the player. + // Parameters: vcos, vsin (render-space direction), hspeed, vspeed (raw from packet). + using KnockBackCallback = std::function; + void setKnockBackCallback(KnockBackCallback cb) { knockBackCallback_ = std::move(cb); } + + // Camera shake callback: called when server sends SMSG_CAMERA_SHAKE. + // Parameters: magnitude (world units), frequency (Hz), duration (seconds). + using CameraShakeCallback = std::function; + void setCameraShakeCallback(CameraShakeCallback cb) { cameraShakeCallback_ = std::move(cb); } + // Unstuck callback (resets player Z to floor height) using UnstuckCallback = std::function; void setUnstuckCallback(UnstuckCallback cb) { unstuckCallback_ = std::move(cb); } @@ -686,8 +1139,8 @@ public: void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); } // Creature spawn callback (online mode - triggered when creature enters view) - // Parameters: guid, displayId, x, y, z (canonical), orientation - using CreatureSpawnCallback = std::function; + // Parameters: guid, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X) + using CreatureSpawnCallback = std::function; void setCreatureSpawnCallback(CreatureSpawnCallback cb) { creatureSpawnCallback_ = std::move(cb); } // Creature despawn callback (online mode - triggered when creature leaves view) @@ -717,8 +1170,8 @@ public: void setPlayerEquipmentCallback(PlayerEquipmentCallback cb) { playerEquipmentCallback_ = std::move(cb); } // GameObject spawn callback (online mode - triggered when gameobject enters view) - // Parameters: guid, entry, displayId, x, y, z (canonical), orientation - using GameObjectSpawnCallback = std::function; + // Parameters: guid, entry, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X) + using GameObjectSpawnCallback = std::function; void setGameObjectSpawnCallback(GameObjectSpawnCallback cb) { gameObjectSpawnCallback_ = std::move(cb); } // GameObject move callback (online mode - triggered when gameobject position updates) @@ -769,6 +1222,11 @@ public: glm::vec3 getComposedWorldPosition(); // Compose transport transform * local offset TransportManager* getTransportManager() { return transportManager_.get(); } void setPlayerOnTransport(uint64_t transportGuid, const glm::vec3& localOffset) { + // Validate transport is registered before attaching player + // (defer if transport not yet registered to prevent desyncs) + if (transportGuid != 0 && !isTransportGuid(transportGuid)) { + return; // Transport not yet registered; skip attachment + } playerTransportGuid_ = transportGuid; playerTransportOffset_ = localOffset; playerTransportStickyGuid_ = transportGuid; @@ -790,13 +1248,44 @@ public: // Cooldowns float getSpellCooldown(uint32_t spellId) const; + const std::unordered_map& getSpellCooldowns() const { return spellCooldowns; } // Player GUID uint64_t getPlayerGuid() const { return playerGuid; } + + // Look up class/race for a player GUID from name query cache. Returns 0 if unknown. + uint8_t lookupPlayerClass(uint64_t guid) const { + auto it = playerClassRaceCache_.find(guid); + return it != playerClassRaceCache_.end() ? it->second.classId : 0; + } + uint8_t lookupPlayerRace(uint64_t guid) const { + auto it = playerClassRaceCache_.find(guid); + return it != playerClassRaceCache_.end() ? it->second.raceId : 0; + } + + // Look up a display name for any guid: checks playerNameCache then entity manager. + // Returns empty string if unknown. Used by chat display to resolve names at render time. + const std::string& lookupName(uint64_t guid) const { + static const std::string kEmpty; + auto it = playerNameCache.find(guid); + if (it != playerNameCache.end()) return it->second; + auto entity = entityManager.getEntity(guid); + if (entity) { + if (auto* unit = dynamic_cast(entity.get())) { + if (!unit->getName().empty()) return unit->getName(); + } + } + return kEmpty; + } + uint8_t getPlayerClass() const { const Character* ch = getActiveCharacter(); return ch ? static_cast(ch->characterClass) : 0; } + uint8_t getPlayerRace() const { + const Character* ch = getActiveCharacter(); + return ch ? static_cast(ch->race) : 0; + } void setPlayerGuid(uint64_t guid) { playerGuid = guid; } // Player death state @@ -804,9 +1293,53 @@ public: bool isPlayerGhost() const { return releasedSpirit_; } bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } + /** True when SMSG_PRE_RESURRECT arrived — Reincarnation/Twisting Nether available. */ + bool canSelfRes() const { return selfResAvailable_; } + /** Send CMSG_SELF_RES to use Reincarnation / Twisting Nether. */ + void useSelfRes(); const std::string& getResurrectCasterName() const { return resurrectCasterName_; } + bool showTalentWipeConfirmDialog() const { return talentWipePending_; } + uint32_t getTalentWipeCost() const { return talentWipeCost_; } + void confirmTalentWipe(); + void cancelTalentWipe() { talentWipePending_ = false; } + // Pet talent respec confirm + bool showPetUnlearnDialog() const { return petUnlearnPending_; } + uint32_t getPetUnlearnCost() const { return petUnlearnCost_; } + void confirmPetUnlearn(); + void cancelPetUnlearn() { petUnlearnPending_ = false; } + + // Barber shop + bool isBarberShopOpen() const { return barberShopOpen_; } + void closeBarberShop() { barberShopOpen_ = false; if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_CLOSE", {}); } + void sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair); + + // Instance difficulty (0=5N, 1=5H, 2=25N, 3=25H for WotLK) + uint32_t getInstanceDifficulty() const { return instanceDifficulty_; } + bool isInstanceHeroic() const { return instanceIsHeroic_; } + bool isInInstance() const { return inInstance_; } + /** True when ghost is within 40 yards of corpse position (same map). */ bool canReclaimCorpse() const; + /** Seconds remaining on the PvP corpse-reclaim delay, or 0 if the reclaim is available now. */ + float getCorpseReclaimDelaySec() const; + /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ + float getCorpseDistance() const { + if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; + // movementInfo is canonical (x=north=server_y, y=west=server_x); + // corpse coords are raw server (x=west, y=north) — swap to compare. + float dx = movementInfo.x - corpseY_; + float dy = movementInfo.y - corpseX_; + float dz = movementInfo.z - corpseZ_; + return std::sqrt(dx*dx + dy*dy + dz*dz); + } + /** Corpse position in canonical WoW coords (X=north, Y=west). + * Returns false if no corpse data or on a different map. */ + bool getCorpseCanonicalPos(float& outX, float& outY) const { + if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return false; + outX = corpseY_; // server Y = canonical X (north) + outY = corpseX_; // server X = canonical Y (west) + return true; + } /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ void reclaimCorpse(); void releaseSpirit(); @@ -857,19 +1390,52 @@ public: enum class TradeStatus : uint8_t { None = 0, PendingIncoming, Open, Accepted, Complete }; + + static constexpr int TRADE_SLOT_COUNT = 6; // WoW has 6 normal trade slots + slot 6 for non-trade item + + struct TradeSlot { + uint32_t itemId = 0; + uint32_t displayId = 0; + uint32_t stackCount = 0; + uint64_t itemGuid = 0; + uint8_t bag = 0xFF; // 0xFF = not set + uint8_t bagSlot = 0xFF; + bool occupied = false; + }; + TradeStatus getTradeStatus() const { return tradeStatus_; } bool hasPendingTradeRequest() const { return tradeStatus_ == TradeStatus::PendingIncoming; } + bool isTradeOpen() const { return tradeStatus_ == TradeStatus::Open || tradeStatus_ == TradeStatus::Accepted; } const std::string& getTradePeerName() const { return tradePeerName_; } + + // My trade slots (what I'm offering) + const std::array& getMyTradeSlots() const { return myTradeSlots_; } + // Peer's trade slots (what they're offering) + const std::array& getPeerTradeSlots() const { return peerTradeSlots_; } + uint64_t getMyTradeGold() const { return myTradeGold_; } + uint64_t getPeerTradeGold() const { return peerTradeGold_; } + void acceptTradeRequest(); // respond to incoming SMSG_TRADE_STATUS(1) with CMSG_BEGIN_TRADE void declineTradeRequest(); // respond with CMSG_CANCEL_TRADE void acceptTrade(); // lock in offer: CMSG_ACCEPT_TRADE void cancelTrade(); // CMSG_CANCEL_TRADE + void setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot); + void clearTradeItem(uint8_t tradeSlot); + void setTradeGold(uint64_t copper); // ---- Duel ---- bool hasPendingDuelRequest() const { return pendingDuelRequest_; } const std::string& getDuelChallengerName() const { return duelChallengerName_; } void acceptDuel(); // forfeitDuel() already declared at line ~399 + // Returns remaining duel countdown seconds, or 0 if no active countdown + float getDuelCountdownRemaining() const { + if (duelCountdownMs_ == 0) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - duelCountdownStartedAt_).count(); + float rem = (static_cast(duelCountdownMs_) - static_cast(elapsed)) / 1000.0f; + return rem > 0.0f ? rem : 0.0f; + } // ---- Instance lockouts ---- struct InstanceLockout { @@ -902,6 +1468,8 @@ public: if (raidTargetGuids_[i] == guid) return static_cast(i); return 0xFF; } + // Set or clear a raid mark on a guid (icon 0-7, or 0xFF to clear) + void setRaidMark(uint64_t guid, uint8_t icon); // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { @@ -918,15 +1486,63 @@ public: // roles bitmask: 0x02=tank, 0x04=healer, 0x08=dps; pass LFGDungeonEntry ID void lfgJoin(uint32_t dungeonId, uint8_t roles); void lfgLeave(); + void lfgSetRoles(uint8_t roles); void lfgAcceptProposal(uint32_t proposalId, bool accept); + void lfgSetBootVote(bool vote); void lfgTeleport(bool toLfgDungeon = true); LfgState getLfgState() const { return lfgState_; } bool isLfgQueued() const { return lfgState_ == LfgState::Queued; } bool isLfgInDungeon() const { return lfgState_ == LfgState::InDungeon; } uint32_t getLfgDungeonId() const { return lfgDungeonId_; } + std::string getCurrentLfgDungeonName() const { return getLfgDungeonName(lfgDungeonId_); } + std::string getMapName(uint32_t mapId) const; uint32_t getLfgProposalId() const { return lfgProposalId_; } int32_t getLfgAvgWaitSec() const { return lfgAvgWaitSec_; } uint32_t getLfgTimeInQueueMs() const { return lfgTimeInQueueMs_; } + uint32_t getLfgBootVotes() const { return lfgBootVotes_; } + uint32_t getLfgBootTotal() const { return lfgBootTotal_; } + uint32_t getLfgBootTimeLeft() const { return lfgBootTimeLeft_; } + uint32_t getLfgBootNeeded() const { return lfgBootNeeded_; } + const std::string& getLfgBootTargetName() const { return lfgBootTargetName_; } + const std::string& getLfgBootReason() const { return lfgBootReason_; } + + // ---- Arena Team Stats ---- + struct ArenaTeamStats { + uint32_t teamId = 0; + uint32_t rating = 0; + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + uint32_t rank = 0; + std::string teamName; + uint32_t teamType = 0; // 2, 3, or 5 + }; + const std::vector& getArenaTeamStats() const { return arenaTeamStats_; } + void requestArenaTeamRoster(uint32_t teamId); + + // ---- Arena Team Roster ---- + struct ArenaTeamMember { + uint64_t guid = 0; + std::string name; + bool online = false; + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + uint32_t personalRating = 0; + }; + struct ArenaTeamRoster { + uint32_t teamId = 0; + std::vector members; + }; + // Returns roster for the given teamId, or nullptr if not yet received + const ArenaTeamRoster* getArenaTeamRoster(uint32_t teamId) const { + for (const auto& r : arenaTeamRosters_) { + if (r.teamId == teamId) return &r; + } + return nullptr; + } // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); @@ -937,6 +1553,15 @@ public: const LootResponseData& getCurrentLoot() const { return currentLoot; } void setAutoLoot(bool enabled) { autoLoot_ = enabled; } bool isAutoLoot() const { return autoLoot_; } + void setAutoSellGrey(bool enabled) { autoSellGrey_ = enabled; } + bool isAutoSellGrey() const { return autoSellGrey_; } + void setAutoRepair(bool enabled) { autoRepair_ = enabled; } + bool isAutoRepair() const { return autoRepair_; } + + // Master loot candidates (from SMSG_LOOT_MASTER_LIST) + const std::vector& getMasterLootCandidates() const { return masterLootCandidates_; } + bool hasMasterLootCandidates() const { return !masterLootCandidates_.empty(); } + void lootMasterGive(uint8_t lootSlot, uint64_t targetGuid); // Group loot roll struct LootRollEntry { @@ -945,12 +1570,36 @@ public: uint32_t itemId = 0; std::string itemName; uint8_t itemQuality = 0; + uint32_t rollCountdownMs = 60000; // Duration of roll window in ms + uint8_t voteMask = 0xFF; // Bitmask: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant + std::chrono::steady_clock::time_point rollStartedAt{}; + + struct PlayerRollResult { + std::string playerName; + uint8_t rollNum = 0; + uint8_t rollType = 0; // 0=need,1=greed,2=disenchant,96=pass + }; + std::vector playerRolls; // live roll results from group members }; bool hasPendingLootRoll() const { return pendingLootRollActive_; } const LootRollEntry& getPendingLootRoll() const { return pendingLootRoll_; } void sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType); // rollType: 0=need, 1=greed, 2=disenchant, 96=pass + // Equipment Sets (WotLK): saved gear loadouts + struct EquipmentSetInfo { + uint64_t setGuid = 0; + uint32_t setId = 0; + std::string name; + std::string iconName; + }; + const std::vector& getEquipmentSets() const { return equipmentSetInfo_; } + bool supportsEquipmentSets() const; + void useEquipmentSet(uint32_t setId); + void saveEquipmentSet(const std::string& name, const std::string& iconName = "INV_Misc_QuestionMark", + uint64_t existingGuid = 0, uint32_t setIndex = 0xFFFFFFFF); + void deleteEquipmentSet(uint64_t setGuid); + // NPC Gossip void interactWithNpc(uint64_t guid); void interactWithGameObject(uint64_t guid); @@ -959,9 +1608,23 @@ public: void acceptQuest(); void declineQuest(); void closeGossip(); + // Quest-starting items: right-click triggers quest offer dialog via questgiver protocol + void offerQuestFromItem(uint64_t itemGuid, uint32_t questId); + uint64_t getBagItemGuid(int bagIndex, int slotIndex) const; bool isGossipWindowOpen() const { return gossipWindowOpen; } const GossipMessageData& getCurrentGossip() const { return currentGossip; } - bool isQuestDetailsOpen() const { return questDetailsOpen; } + bool isQuestDetailsOpen() { + // Check if delayed opening timer has expired + if (questDetailsOpen) return true; + if (questDetailsOpenTime != std::chrono::steady_clock::time_point{}) { + if (std::chrono::steady_clock::now() >= questDetailsOpenTime) { + questDetailsOpen = true; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; + return true; + } + } + return false; + } const QuestDetailsData& getQuestDetails() const { return currentQuestDetails; } // Gossip / quest map POI markers (SMSG_GOSSIP_POI) @@ -992,20 +1655,43 @@ public: std::string title; std::string objectives; bool complete = false; - // Objective kill counts: objectiveIndex -> (current, required) + // Objective kill counts: npcOrGoEntry -> (current, required) std::unordered_map> killCounts; // Quest item progress: itemId -> current count std::unordered_map itemCounts; // Server-authoritative quest item requirements from REQUEST_ITEMS std::unordered_map requiredItemCounts; + // Structured kill objectives parsed from SMSG_QUEST_QUERY_RESPONSE. + // Index 0-3 map to the server's objective slot order (packed into update fields). + // npcOrGoId != 0 => entity objective (kill NPC or interact with GO). + struct KillObjective { + int32_t npcOrGoId = 0; // negative = game-object entry + uint32_t required = 0; + }; + std::array killObjectives{}; // zeroed by default + // Required item objectives parsed from SMSG_QUEST_QUERY_RESPONSE. + // itemId != 0 => collect items of that type. + struct ItemObjective { + uint32_t itemId = 0; + uint32_t required = 0; + }; + std::array itemObjectives{}; // zeroed by default + // Reward data parsed from SMSG_QUEST_QUERY_RESPONSE + int32_t rewardMoney = 0; // copper; positive=reward, negative=cost + std::array rewardItems{}; // guaranteed reward items + std::array rewardChoiceItems{}; // player picks one of these }; const std::vector& getQuestLog() const { return questLog_; } + int getSelectedQuestLogIndex() const { return selectedQuestLogIndex_; } + void setSelectedQuestLogIndex(int idx) { selectedQuestLogIndex_ = idx; } void abandonQuest(uint32_t questId); + void shareQuestWithParty(uint32_t questId); // CMSG_PUSHQUESTTOPARTY 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); + saveCharacterConfig(); } const std::unordered_set& getTrackedQuestIds() const { return trackedQuestIds_; } bool isQuestQueryPending(uint32_t questId) const { @@ -1047,13 +1733,138 @@ public: }; const std::array& getPlayerRunes() const { return playerRunes_; } + // Talent-driven spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) + // SpellModOp matches WotLK SpellModOp enum (server-side). + enum class SpellModOp : uint8_t { + Damage = 0, + Duration = 1, + Threat = 2, + Effect1 = 3, + Charges = 4, + Range = 5, + Radius = 6, + CritChance = 7, + AllEffects = 8, + NotLoseCastingTime = 9, + CastingTime = 10, + Cooldown = 11, + Effect2 = 12, + IgnoreArmor = 13, + Cost = 14, + CritDamageBonus = 15, + ResistMissChance = 16, + JumpTargets = 17, + ChanceOfSuccess = 18, + ActivationTime = 19, + Efficiency = 20, + MultipleValue = 21, + ResistDispelChance = 22, + Effect3 = 23, + BonusMultiplier = 24, + ProcPerMinute = 25, + ValueMultiplier = 26, + ResistPushback = 27, + MechanicDuration = 28, + StartCooldown = 29, + PeriodicBonus = 30, + AttackPower = 31, + }; + static constexpr int SPELL_MOD_OP_COUNT = 32; + + // Key: (SpellModOp, groupIndex) — value: accumulated flat or pct modifier + // pct values are stored in integer percent (e.g. -20 means -20% reduction). + struct SpellModKey { + SpellModOp op; + uint8_t group; + bool operator==(const SpellModKey& o) const { + return op == o.op && group == o.group; + } + }; + struct SpellModKeyHash { + std::size_t operator()(const SpellModKey& k) const { + return std::hash()( + (static_cast(static_cast(k.op)) << 8) | k.group); + } + }; + + // Returns the sum of all flat modifiers for a given op across all groups. + // (Callers that need per-group resolution can use getSpellFlatMods() directly.) + int32_t getSpellFlatMod(SpellModOp op) const { + int32_t total = 0; + for (const auto& [k, v] : spellFlatMods_) + if (k.op == op) total += v; + return total; + } + // Returns the sum of all pct modifiers for a given op across all groups (in %). + int32_t getSpellPctMod(SpellModOp op) const { + int32_t total = 0; + for (const auto& [k, v] : spellPctMods_) + if (k.op == op) total += v; + return total; + } + + // Convenience: apply flat+pct modifier to a base value. + // result = (base + flatMod) * (1.0 + pctMod/100.0), clamped to >= 0. + static int32_t applySpellMod(int32_t base, int32_t flat, int32_t pct) { + int64_t v = static_cast(base) + flat; + if (pct != 0) v = v + (v * pct + 50) / 100; // round half-up + return static_cast(v < 0 ? 0 : v); + } + struct FactionStandingInit { uint8_t flags = 0; int32_t standing = 0; }; + // Faction flag bitmask constants (from Faction.dbc ReputationFlags / SMSG_INITIALIZE_FACTIONS) + static constexpr uint8_t FACTION_FLAG_VISIBLE = 0x01; // shown in reputation list + static constexpr uint8_t FACTION_FLAG_AT_WAR = 0x02; // player is at war + static constexpr uint8_t FACTION_FLAG_HIDDEN = 0x04; // never shown + static constexpr uint8_t FACTION_FLAG_INVISIBLE_FORCED = 0x08; + static constexpr uint8_t FACTION_FLAG_PEACE_FORCED = 0x10; + const std::vector& getInitialFactions() const { return initialFactions_; } const std::unordered_map& getFactionStandings() const { return factionStandings_; } + + // Returns true if the player has "at war" toggled for the faction at repListId + bool isFactionAtWar(uint32_t repListId) const { + if (repListId >= initialFactions_.size()) return false; + return (initialFactions_[repListId].flags & FACTION_FLAG_AT_WAR) != 0; + } + // Returns true if the faction is visible in the reputation list + bool isFactionVisible(uint32_t repListId) const { + if (repListId >= initialFactions_.size()) return false; + const uint8_t f = initialFactions_[repListId].flags; + if (f & FACTION_FLAG_HIDDEN) return false; + if (f & FACTION_FLAG_INVISIBLE_FORCED) return false; + return (f & FACTION_FLAG_VISIBLE) != 0; + } + // Returns the faction ID for a given repListId (0 if unknown) + uint32_t getFactionIdByRepListId(uint32_t repListId) const; + // Returns the repListId for a given faction ID (0xFFFFFFFF if not found) + uint32_t getRepListIdByFactionId(uint32_t factionId) const; + // Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air) + struct TotemSlot { + uint32_t spellId = 0; + uint32_t durationMs = 0; + std::chrono::steady_clock::time_point placedAt{}; + bool active() const { return spellId != 0 && remainingMs() > 0; } + float remainingMs() const { + if (spellId == 0 || durationMs == 0) return 0.0f; + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - placedAt).count(); + float rem = static_cast(durationMs) - static_cast(elapsed); + return rem > 0.0f ? rem : 0.0f; + } + }; + static constexpr int NUM_TOTEM_SLOTS = 4; + const TotemSlot& getTotemSlot(int slot) const { + static TotemSlot empty; + return (slot >= 0 && slot < NUM_TOTEM_SLOTS) ? activeTotemSlots_[slot] : empty; + } + const std::string& getFactionNamePublic(uint32_t factionId) const; + uint32_t getWatchedFactionId() const { return watchedFactionId_; } + void setWatchedFactionId(uint32_t factionId); uint32_t getLastContactListMask() const { return lastContactListMask_; } uint32_t getLastContactListCount() const { return lastContactListCount_; } bool isServerMovementAllowed() const { return serverMovementAllowed_; } @@ -1074,13 +1885,90 @@ public: using LevelUpCallback = std::function; void setLevelUpCallback(LevelUpCallback cb) { levelUpCallback_ = std::move(cb); } + // Stat deltas from the last SMSG_LEVELUP_INFO (valid until next level-up) + struct LevelUpDeltas { + uint32_t hp = 0; + uint32_t mana = 0; + uint32_t str = 0, agi = 0, sta = 0, intel = 0, spi = 0; + }; + const LevelUpDeltas& getLastLevelUpDeltas() const { return lastLevelUpDeltas_; } + + // Temporary weapon enchant timers (from SMSG_ITEM_ENCHANT_TIME_UPDATE) + // Slot: 0=main-hand, 1=off-hand, 2=ranged. Value: expire time (steady_clock ms). + struct TempEnchantTimer { + uint32_t slot = 0; + uint64_t expireMs = 0; // std::chrono::steady_clock ms timestamp when it expires + }; + const std::vector& getTempEnchantTimers() const { return tempEnchantTimers_; } + // Returns remaining ms for a given slot, or 0 if absent/expired. + uint32_t getTempEnchantRemainingMs(uint32_t slot) const; + static constexpr const char* kTempEnchantSlotNames[] = { "Main Hand", "Off Hand", "Ranged" }; + + // ---- Readable text (books / scrolls / notes) ---- + // Populated by handlePageTextQueryResponse(); multi-page items chain via nextPageId. + struct BookPage { uint32_t pageId = 0; std::string text; }; + const std::vector& getBookPages() const { return bookPages_; } + bool hasBookOpen() const { return !bookPages_.empty(); } + void clearBook() { bookPages_.clear(); } + // Other player level-up callback — fires when another player gains a level using OtherPlayerLevelUpCallback = std::function; void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } // Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received - using AchievementEarnedCallback = std::function; + using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } + const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + + // Title system — earned title bits and the currently displayed title + const std::unordered_set& getKnownTitleBits() const { return knownTitleBits_; } + int32_t getChosenTitleBit() const { return chosenTitleBit_; } + /// Returns the formatted title string for a given bit (replaces %s with player name), or empty. + std::string getFormattedTitle(uint32_t bit) const; + /// Send CMSG_SET_TITLE to activate a title (bit >= 0) or clear it (bit = -1). + void sendSetTitle(int32_t bit); + + // Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received + using AreaDiscoveryCallback = std::function; + void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); } + + // Quest objective progress callback — fires on SMSG_QUESTUPDATE_ADD_KILL / ADD_ITEM + // questTitle: name of the quest; objectiveName: creature/item name; current/required counts + using QuestProgressCallback = std::function; + void setQuestProgressCallback(QuestProgressCallback cb) { questProgressCallback_ = std::move(cb); } + const std::unordered_map& getCriteriaProgress() const { return criteriaProgress_; } + /// Returns the WoW PackedTime earn date for an achievement, or 0 if unknown. + uint32_t getAchievementDate(uint32_t id) const { + auto it = achievementDates_.find(id); + return (it != achievementDates_.end()) ? it->second : 0u; + } + /// Returns the name of an achievement by ID, or empty string if unknown. + const std::string& getAchievementName(uint32_t id) const { + auto it = achievementNameCache_.find(id); + if (it != achievementNameCache_.end()) return it->second; + static const std::string kEmpty; + return kEmpty; + } + /// Returns the description of an achievement by ID, or empty string if unknown. + const std::string& getAchievementDescription(uint32_t id) const { + auto it = achievementDescCache_.find(id); + if (it != achievementDescCache_.end()) return it->second; + static const std::string kEmpty; + return kEmpty; + } + /// Returns the point value of an achievement by ID, or 0 if unknown. + uint32_t getAchievementPoints(uint32_t id) const { + auto it = achievementPointsCache_.find(id); + return (it != achievementPointsCache_.end()) ? it->second : 0u; + } + /// Returns the set of achievement IDs earned by an inspected player (via SMSG_RESPOND_INSPECT_ACHIEVEMENTS). + /// Returns nullptr if no inspect data is available for the given GUID. + const std::unordered_set* getInspectedPlayerAchievements(uint64_t guid) const { + auto it = inspectedPlayerAchievements_.find(guid); + return (it != inspectedPlayerAchievements_.end()) ? &it->second : nullptr; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -1098,6 +1986,27 @@ public: using PlayPositionalSoundCallback = std::function; void setPlayPositionalSoundCallback(PlayPositionalSoundCallback cb) { playPositionalSoundCallback_ = std::move(cb); } + // UI error frame: prominent on-screen error messages (spell can't be cast, etc.) + using UIErrorCallback = std::function; + void setUIErrorCallback(UIErrorCallback cb) { uiErrorCallback_ = std::move(cb); } + void addUIError(const std::string& msg) { if (uiErrorCallback_) uiErrorCallback_(msg); } + + // Reputation change toast: factionName, delta, new standing + using RepChangeCallback = std::function; + void setRepChangeCallback(RepChangeCallback cb) { repChangeCallback_ = std::move(cb); } + + // PvP honor credit callback (honorable kill or BG reward) + using PvpHonorCallback = std::function; + void setPvpHonorCallback(PvpHonorCallback cb) { pvpHonorCallback_ = std::move(cb); } + + // Item looted / received callback (SMSG_ITEM_PUSH_RESULT when showInChat is set) + using ItemLootCallback = std::function; + void setItemLootCallback(ItemLootCallback cb) { itemLootCallback_ = std::move(cb); } + + // Quest turn-in completion callback + using QuestCompleteCallback = std::function; + void setQuestCompleteCallback(QuestCompleteCallback cb) { questCompleteCallback_ = std::move(cb); } + // Mount state using MountCallback = std::function; // 0 = dismount void setMountCallback(MountCallback cb) { mountCallback_ = std::move(cb); } @@ -1114,9 +2023,47 @@ public: using TaxiFlightStartCallback = std::function; void setTaxiFlightStartCallback(TaxiFlightStartCallback cb) { taxiFlightStartCallback_ = std::move(cb); } + // Callback fired when server sends SMSG_OPEN_LFG_DUNGEON_FINDER (open dungeon finder UI) + using OpenLfgCallback = std::function; + void setOpenLfgCallback(OpenLfgCallback cb) { openLfgCallback_ = std::move(cb); } + bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } + bool isHostileFactionPublic(uint32_t factionTemplateId) const { return isHostileFaction(factionTemplateId); } float getServerRunSpeed() const { return serverRunSpeed_; } + float getServerWalkSpeed() const { return serverWalkSpeed_; } + float getServerSwimSpeed() const { return serverSwimSpeed_; } + float getServerSwimBackSpeed() const { return serverSwimBackSpeed_; } + float getServerFlightSpeed() const { return serverFlightSpeed_; } + float getServerFlightBackSpeed() const { return serverFlightBackSpeed_; } + float getServerRunBackSpeed() const { return serverRunBackSpeed_; } + float getServerTurnRate() const { return serverTurnRate_; } + bool isPlayerRooted() const { + return (movementInfo.flags & static_cast(MovementFlags::ROOT)) != 0; + } + bool isGravityDisabled() const { + return (movementInfo.flags & static_cast(MovementFlags::LEVITATING)) != 0; + } + bool isFeatherFalling() const { + return (movementInfo.flags & static_cast(MovementFlags::FEATHER_FALL)) != 0; + } + bool isWaterWalking() const { + return (movementInfo.flags & static_cast(MovementFlags::WATER_WALK)) != 0; + } + bool isPlayerFlying() const { + const uint32_t flyMask = static_cast(MovementFlags::CAN_FLY) | + static_cast(MovementFlags::FLYING); + return (movementInfo.flags & flyMask) == flyMask; + } + bool isHovering() const { + return (movementInfo.flags & static_cast(MovementFlags::HOVER)) != 0; + } + bool isSwimming() const { + return (movementInfo.flags & static_cast(MovementFlags::SWIMMING)) != 0; + } + // Set the character pitch angle (radians) for movement packets (flight / swimming). + // Positive = nose up, negative = nose down. + void setMovementPitch(float radians) { movementInfo.pitch = radians; } void dismount(); // Taxi / Flight Paths @@ -1127,6 +2074,7 @@ public: bool isTaxiMountActive() const { return taxiMountActive_; } bool isTaxiActivationPending() const { return taxiActivatePending_; } void forceClearTaxiAndMovementState(); + const std::string& getTaxiDestName() const { return taxiDestName_; } const ShowTaxiNodesData& getTaxiData() const { return currentTaxiData_; } uint32_t getTaxiCurrentNode() const { return currentTaxiData_.nearestNode; } @@ -1151,12 +2099,22 @@ public: float x = 0, y = 0, z = 0; }; const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } + bool isKnownTaxiNode(uint32_t nodeId) const { + if (nodeId == 0 || nodeId > 384) return false; + uint32_t idx = nodeId - 1; + return (knownTaxiMask_[idx / 32] & (1u << (idx % 32))) != 0; + } uint32_t getTaxiCostTo(uint32_t destNodeId) const; bool taxiNpcHasRoutes(uint64_t guid) const { auto it = taxiNpcHasRoutes_.find(guid); return it != taxiNpcHasRoutes_.end() && it->second; } + // Vehicle (WotLK) + bool isInVehicle() const { return vehicleId_ != 0; } + uint32_t getVehicleId() const { return vehicleId_; } + void sendRequestVehicleExit(); + // Vendor void openVendor(uint64_t npcGuid); void closeVendor(); @@ -1170,17 +2128,24 @@ public: uint32_t count = 1; }; void buyBackItem(uint32_t buybackSlot); + void repairItem(uint64_t vendorGuid, uint64_t itemGuid); + void repairAll(uint64_t vendorGuid, bool useGuildBank = false); const std::deque& getBuybackItems() const { return buybackItems_; } void autoEquipItemBySlot(int backpackIndex); void autoEquipItemInBag(int bagIndex, int slotIndex); void useItemBySlot(int backpackIndex); void useItemInBag(int bagIndex, int slotIndex); + // CMSG_OPEN_ITEM — for locked containers (lockboxes); server checks keyring automatically + void openItemBySlot(int backpackIndex); + void openItemInBag(int bagIndex, int slotIndex); void destroyItem(uint8_t bag, uint8_t slot, uint8_t count = 1); + void splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count); void swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot); void swapBagSlots(int srcBagIndex, int dstBagIndex); void useItemById(uint32_t itemId); bool isVendorWindowOpen() const { return vendorWindowOpen; } const ListInventoryData& getVendorItems() const { return currentVendorItems; } + void setVendorCanRepair(bool v) { currentVendorItems.canRepair = v; } // Mail bool isMailboxOpen() const { return mailboxOpen_; } @@ -1211,7 +2176,7 @@ public: const std::array& getMailAttachments() const { return mailAttachments_; } int getMailAttachmentCount() const; void mailTakeMoney(uint32_t mailId); - void mailTakeItem(uint32_t mailId, uint32_t itemIndex); + void mailTakeItem(uint32_t mailId, uint32_t itemGuidLow); void mailDelete(uint32_t mailId); void mailMarkAsRead(uint32_t mailId); void refreshMailList(); @@ -1270,7 +2235,18 @@ public: void closeTrainer(); const std::string& getSpellName(uint32_t spellId) const; const std::string& getSpellRank(uint32_t spellId) const; + /// Returns the tooltip/description text from Spell.dbc (empty if unknown or has no text). + const std::string& getSpellDescription(uint32_t spellId) const; const std::string& getSkillLineName(uint32_t spellId) const; + /// Returns the DispelType for a spell (0=none,1=magic,2=curse,3=disease,4=poison,5+=other) + uint8_t getSpellDispelType(uint32_t spellId) const; + /// Returns true if the spell can be interrupted by abilities like Kick/Counterspell. + /// False for spells with SPELL_ATTR_EX_NOT_INTERRUPTIBLE (attrEx bit 4 = 0x10). + bool isSpellInterruptible(uint32_t spellId) const; + /// Returns the school bitmask for the spell from Spell.dbc + /// (0x01=Physical, 0x02=Holy, 0x04=Fire, 0x08=Nature, 0x10=Frost, 0x20=Shadow, 0x40=Arcane). + /// Returns 0 if unknown. + uint32_t getSpellSchoolMask(uint32_t spellId) const; struct TrainerTab { std::string name; @@ -1281,6 +2257,7 @@ public: auto it = itemInfoCache_.find(itemId); return (it != itemInfoCache_.end()) ? &it->second : nullptr; } + const std::unordered_map& getItemInfoCache() const { return itemInfoCache_; } // Request item info from server if not already cached/pending void ensureItemInfo(uint32_t entry) { if (entry == 0 || itemInfoCache_.count(entry) || pendingItemQueries_.count(entry)) return; @@ -1290,6 +2267,22 @@ public: if (index < 0 || index >= static_cast(backpackSlotGuids_.size())) return 0; return backpackSlotGuids_[index]; } + uint64_t getEquipSlotGuid(int slot) const { + if (slot < 0 || slot >= static_cast(equipSlotGuids_.size())) return 0; + return equipSlotGuids_[slot]; + } + // Returns the permanent and temporary enchant IDs for an item by GUID (0 if unknown). + std::pair getItemEnchantIds(uint64_t guid) const { + auto it = onlineItems_.find(guid); + if (it == onlineItems_.end()) return {0, 0}; + return {it->second.permanentEnchantId, it->second.temporaryEnchantId}; + } + // Returns the socket gem enchant IDs (3 slots; 0 = empty socket) for an item by GUID. + std::array getItemSocketEnchantIds(uint64_t guid) const { + auto it = onlineItems_.find(guid); + if (it == onlineItems_.end()) return {}; + return it->second.socketEnchantIds; + } uint64_t getVendorGuid() const { return currentVendorItems.vendorGuid; } /** @@ -1318,6 +2311,15 @@ private: * Handle incoming packet from world server */ void handlePacket(network::Packet& packet); + void enqueueIncomingPacket(const network::Packet& packet); + void enqueueIncomingPacketFront(network::Packet&& packet); + void processQueuedIncomingPackets(); + void enqueueUpdateObjectWork(UpdateObjectData&& data); + void processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs); + void processOutOfRangeObjects(const std::vector& guids); + void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated); + void finalizeUpdateObjectBatch(bool newItemCreated); /** * Handle SMSG_AUTH_CHALLENGE from server @@ -1465,6 +2467,9 @@ private: void handleGuildInvite(network::Packet& packet); void handleGuildCommandResult(network::Packet& packet); void handlePetitionShowlist(network::Packet& packet); + void handlePetitionQueryResponse(network::Packet& packet); + void handlePetitionShowSignatures(network::Packet& packet); + void handlePetitionSignResults(network::Packet& packet); void handlePetSpells(network::Packet& packet); void handleTurnInPetitionResults(network::Packet& packet); @@ -1481,6 +2486,7 @@ private: // ---- Other player movement (MSG_MOVE_* from server) ---- void handleOtherPlayerMovement(network::Packet& packet); + void handleMoveSetSpeed(network::Packet& packet); // ---- Phase 5 handlers ---- void handleLootResponse(network::Packet& packet); @@ -1489,6 +2495,7 @@ private: void handleGossipMessage(network::Packet& packet); void handleQuestgiverQuestList(network::Packet& packet); void handleGossipComplete(network::Packet& packet); + void handleQuestPoiQueryResponse(network::Packet& packet); void handleQuestDetails(network::Packet& packet); void handleQuestRequestItems(network::Packet& packet); void handleQuestOfferReward(network::Packet& packet); @@ -1510,6 +2517,7 @@ private: void handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage); void handleForceMoveRootState(network::Packet& packet, bool rooted); void handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set); + void handleMoveSetCollisionHeight(network::Packet& packet); void handleMoveKnockBack(network::Packet& packet); // ---- Area trigger detection ---- @@ -1522,6 +2530,8 @@ private: void handleQuestConfirmAccept(network::Packet& packet); void handleSummonRequest(network::Packet& packet); void handleTradeStatus(network::Packet& packet); + void handleTradeStatusExtended(network::Packet& packet); + void resetTradeState(); void handleDuelRequested(network::Packet& packet); void handleDuelComplete(network::Packet& packet); void handleDuelWinner(network::Packet& packet); @@ -1543,9 +2553,12 @@ private: void handleInstanceDifficulty(network::Packet& packet); void handleArenaTeamCommandResult(network::Packet& packet); void handleArenaTeamQueryResponse(network::Packet& packet); + void handleArenaTeamRoster(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); void handleArenaTeamEvent(network::Packet& packet); + void handleArenaTeamStats(network::Packet& packet); void handleArenaError(network::Packet& packet); + void handlePvpLogData(network::Packet& packet); // ---- Bank handlers ---- void handleShowBank(network::Packet& packet); @@ -1588,7 +2601,9 @@ private: void handleLogoutResponse(network::Packet& packet); void handleLogoutComplete(network::Packet& packet); - void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource); + void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0, + uint64_t srcGuid = 0, uint64_t dstGuid = 0); + bool shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId); void addSystemChatMessage(const std::string& message); /** @@ -1632,6 +2647,14 @@ private: // Network std::unique_ptr socket; + std::deque pendingIncomingPackets_; + struct PendingUpdateObjectWork { + UpdateObjectData data; + size_t nextBlockIndex = 0; + bool outOfRangeProcessed = false; + bool newItemCreated = false; + }; + std::deque pendingUpdateObjectWork_; // State WorldState state = WorldState::DISCONNECTED; @@ -1653,6 +2676,14 @@ private: std::chrono::steady_clock::time_point movementClockStart_ = std::chrono::steady_clock::now(); uint32_t lastMovementTimestampMs_ = 0; bool serverMovementAllowed_ = true; + uint32_t monsterMovePacketsThisTick_ = 0; + uint32_t monsterMovePacketsDroppedThisTick_ = 0; + + // Fall/jump tracking for movement packet correctness. + // fallTime must be the elapsed ms since the FALLING flag was set; the server + // uses it for fall-damage calculations and anti-cheat validation. + bool isFalling_ = false; + uint32_t fallStartMs_ = 0; // movementInfo.time value when FALLING started // Inventory Inventory inventory; @@ -1665,12 +2696,19 @@ private: size_t maxChatHistory = 100; // Maximum chat messages to keep std::vector joinedChannels_; // Active channel memberships ChatBubbleCallback chatBubbleCallback_; + AddonChatCallback addonChatCallback_; + AddonEventCallback addonEventCallback_; + SpellIconPathResolver spellIconPathResolver_; + ItemIconPathResolver itemIconPathResolver_; + SpellDataResolver spellDataResolver_; + RandomPropertyNameResolver randomPropertyNameResolver_; EmoteAnimCallback emoteAnimCallback_; // Targeting uint64_t targetGuid = 0; uint64_t focusGuid = 0; // Focus target uint64_t lastTargetGuid = 0; // Previous target + uint64_t mouseoverGuid_ = 0; // Set each frame by nameplate renderer std::vector tabCycleList; int tabCycleIndex = -1; bool tabCycleStale = true; @@ -1681,17 +2719,31 @@ private: float pingInterval = 30.0f; // Ping interval (30 seconds) float timeSinceLastMoveHeartbeat_ = 0.0f; // Periodic movement heartbeat to keep server position synced float moveHeartbeatInterval_ = 0.5f; + uint32_t lastHeartbeatSendTimeMs_ = 0; + float lastHeartbeatX_ = 0.0f; + float lastHeartbeatY_ = 0.0f; + float lastHeartbeatZ_ = 0.0f; + uint32_t lastHeartbeatFlags_ = 0; + uint64_t lastHeartbeatTransportGuid_ = 0; + uint32_t lastNonHeartbeatMoveSendTimeMs_ = 0; + uint32_t lastFacingSendTimeMs_ = 0; + float lastFacingSentOrientation_ = 0.0f; uint32_t lastLatency = 0; // Last measured latency (milliseconds) + std::chrono::steady_clock::time_point pingTimestamp_; // Time CMSG_PING was sent // Player GUID and map uint64_t playerGuid = 0; uint32_t currentMapId_ = 0; bool hasHomeBind_ = false; uint32_t homeBindMapId_ = 0; + uint32_t homeBindZoneId_ = 0; glm::vec3 homeBindPos_{0.0f}; // ---- Phase 1: Name caches ---- std::unordered_map playerNameCache; + // Class/race cache from SMSG_NAME_QUERY_RESPONSE (guid → {classId, raceId}) + struct PlayerClassRace { uint8_t classId = 0; uint8_t raceId = 0; }; + std::unordered_map playerClassRaceCache_; std::unordered_set pendingNameQueries; std::unordered_map creatureInfoCache; std::unordered_set pendingCreatureQueries; @@ -1715,7 +2767,8 @@ private: std::unordered_map ignoreCache; // name -> guid // ---- Logout state ---- - bool loggingOut_ = false; + bool loggingOut_ = false; + float logoutCountdown_ = 0.0f; // seconds remaining before server logs us out (0 = instant/done) // ---- Display state ---- bool helmVisible_ = true; @@ -1736,12 +2789,26 @@ private: struct OnlineItemInfo { uint32_t entry = 0; uint32_t stackCount = 1; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; + uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting) + uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons) + std::array socketEnchantIds{}; // ITEM_ENCHANTMENT_SLOT 2-4 (gems) }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; std::unordered_set pendingItemQueries_; + + // Deferred SMSG_ITEM_PUSH_RESULT notifications for items whose info wasn't + // cached at arrival time; emitted once the query response arrives. + struct PendingItemPushNotif { + uint32_t itemId = 0; + uint32_t count = 1; + }; + std::vector pendingItemPushNotifs_; std::array equipSlotGuids_{}; std::array backpackSlotGuids_{}; + std::array keyringSlotGuids_{}; // Container (bag) contents: containerGuid -> array of item GUIDs per slot struct ContainerInfo { uint32_t numSlots = 0; @@ -1768,12 +2835,14 @@ private: // Inspect fallback (when visible item fields are missing/unreliable) std::unordered_map> inspectedPlayerItemEntries_; + InspectResult inspectResult_; // most-recently received inspect response std::unordered_set pendingAutoInspect_; float inspectRateLimit_ = 0.0f; // ---- Phase 2: Combat ---- bool autoAttacking = false; bool autoAttackRequested_ = false; // local intent (CMSG_ATTACKSWING sent) + bool autoAttackRetryPending_ = false; // one-shot retry after local start or server stop uint64_t autoAttackTarget = 0; bool autoAttackOutOfRange_ = false; float autoAttackOutOfRangeTime_ = 0.0f; @@ -1781,10 +2850,26 @@ private: float autoAttackResendTimer_ = 0.0f; // Re-send CMSG_ATTACKSWING every ~1s while attacking float autoAttackFacingSyncTimer_ = 0.0f; // Periodic facing sync while meleeing std::unordered_set hostileAttackers_; + bool wasCombat_ = false; // Previous frame combat state for PLAYER_REGEN edge detection std::vector combatText; + static constexpr size_t MAX_COMBAT_LOG = 500; + struct RecentSpellstealLogEntry { + uint64_t casterGuid = 0; + uint64_t victimGuid = 0; + uint32_t spellId = 0; + std::chrono::steady_clock::time_point timestamp{}; + }; + static constexpr size_t MAX_RECENT_SPELLSTEAL_LOGS = 32; + std::deque combatLog_; + std::deque recentSpellstealLogs_; + std::deque areaTriggerMsgs_; + // unitGuid → sorted threat list (descending by threat value) + std::unordered_map> threatLists_; // ---- Phase 3: Spells ---- WorldEntryCallback worldEntryCallback_; + KnockBackCallback knockBackCallback_; + CameraShakeCallback cameraShakeCallback_; UnstuckCallback unstuckCallback_; UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; @@ -1826,8 +2911,15 @@ private: std::vector minimapPings_; uint8_t castCount = 0; bool casting = false; + bool castIsChannel = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; + // Repeat-craft queue: re-cast the same profession spell N more times after current cast finishes + uint32_t craftQueueSpellId_ = 0; + int craftQueueRemaining_ = 0; + // Spell queue: next spell to cast within the 400ms window before current cast ends + uint32_t queuedSpellId_ = 0; + uint64_t queuedSpellTarget_ = 0; // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) std::unordered_map unitCastStates_; uint64_t pendingGameObjectInteractGuid_ = 0; @@ -1836,9 +2928,11 @@ private: uint8_t activeTalentSpec_ = 0; // Currently active spec (0 or 1) uint8_t unspentTalentPoints_[2] = {0, 0}; // Unspent points per spec std::unordered_map learnedTalents_[2]; // Learned talents per spec + std::array, 2> learnedGlyphs_{}; // Glyphs per spec std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; + bool talentsInitialized_ = false; // Reset on world entry; guards first-spec selection // ---- Area trigger detection ---- struct AreaTriggerEntry { @@ -1857,27 +2951,36 @@ private: float castTimeTotal = 0.0f; std::array actionBar{}; + std::unordered_map macros_; // client-side macro text (persisted in char config) std::vector playerAuras; std::vector targetAuras; + std::unordered_map> unitAurasCache_; // per-unit aura cache uint64_t petGuid_ = 0; uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss uint8_t petReact_ = 1; // 0=passive,1=defensive,2=aggressive + bool petRenameablePending_ = false; // set by SMSG_PET_RENAMEABLE, consumed by UI std::vector petSpellList_; // known pet spells std::unordered_set petAutocastSpells_; // spells with autocast on + // ---- Pet Stable ---- + bool stableWindowOpen_ = false; + uint64_t stableMasterGuid_ = 0; + uint8_t stableNumSlots_ = 0; + std::vector stabledPets_; + void handleListStabledPets(network::Packet& packet); + // ---- Battleground queue state ---- - struct BgQueueSlot { - uint32_t queueSlot = 0; - uint32_t bgTypeId = 0; - uint8_t arenaType = 0; - uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress - }; std::array bgQueues_{}; + // ---- Available battleground list (SMSG_BATTLEFIELD_LIST) ---- + std::vector availableBgs_; + void handleBattlefieldList(network::Packet& packet); + // Instance difficulty uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; + bool inInstance_ = false; // Raid target markers (icon 0-7 -> guid; 0 = empty slot) std::array raidTargetGuids_ = {}; @@ -1892,6 +2995,17 @@ private: // Instance / raid lockouts std::vector instanceLockouts_; + // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) + std::vector arenaTeamStats_; + // Arena team rosters (updated by SMSG_ARENA_TEAM_ROSTER) + std::vector arenaTeamRosters_; + + // BG scoreboard (MSG_PVP_LOG_DATA) + BgScoreboardData bgScoreboard_; + + // BG flag carrier / player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + std::vector bgPlayerPositions_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) std::array encounterUnitGuids_ = {}; // 0 = empty slot @@ -1901,17 +3015,28 @@ private: uint32_t lfgProposalId_ = 0; // pending proposal id (0 = none) int32_t lfgAvgWaitSec_ = -1; // estimated wait, -1=unknown uint32_t lfgTimeInQueueMs_= 0; // ms already in queue + uint32_t lfgBootVotes_ = 0; // current boot-yes votes + uint32_t lfgBootTotal_ = 0; // total votes cast + uint32_t lfgBootTimeLeft_ = 0; // seconds remaining + uint32_t lfgBootNeeded_ = 0; // votes needed to kick + std::string lfgBootTargetName_; // name of player being voted on + std::string lfgBootReason_; // reason given for kick // Ready check state bool pendingReadyCheck_ = false; uint32_t readyCheckReadyCount_ = 0; uint32_t readyCheckNotReadyCount_ = 0; std::string readyCheckInitiator_; + std::vector readyCheckResults_; // per-player status live during check // Faction standings (factionId → absolute standing value) std::unordered_map factionStandings_; // Faction name cache (factionId → name), populated lazily from Faction.dbc std::unordered_map factionNameCache_; + // repListId → factionId mapping (populated with factionNameCache) + std::unordered_map factionRepListToId_; + // factionId → repListId reverse mapping + std::unordered_map factionIdToRepList_; bool factionNameCacheLoaded_ = false; void loadFactionNameCache(); std::string getFactionName(uint32_t factionId) const; @@ -1937,17 +3062,32 @@ private: uint64_t summonerGuid_ = 0; std::string summonerName_; float summonTimeoutSec_ = 0.0f; + uint32_t totalTimePlayed_ = 0; + uint32_t levelTimePlayed_ = 0; + + // Who results (last SMSG_WHO response) + std::vector whoResults_; + uint32_t whoOnlineCount_ = 0; // Trade state TradeStatus tradeStatus_ = TradeStatus::None; uint64_t tradePeerGuid_= 0; std::string tradePeerName_; + std::array myTradeSlots_{}; + std::array peerTradeSlots_{}; + uint64_t myTradeGold_ = 0; + uint64_t peerTradeGold_ = 0; + + // Shaman totem state + TotemSlot activeTotemSlots_[NUM_TOTEM_SLOTS]; // Duel state bool pendingDuelRequest_ = false; uint64_t duelChallengerGuid_= 0; uint64_t duelFlagGuid_ = 0; std::string duelChallengerName_; + uint32_t duelCountdownMs_ = 0; // 0 = no active countdown + std::chrono::steady_clock::time_point duelCountdownStartedAt_{}; // ---- Guild state ---- std::string guildName_; @@ -1956,20 +3096,29 @@ private: GuildInfoData guildInfoData_; GuildQueryResponseData guildQueryData_; bool hasGuildRoster_ = false; + std::unordered_map guildNameCache_; // guildId → guild name + std::unordered_set pendingGuildNameQueries_; // in-flight guild queries bool pendingGuildInvite_ = false; std::string pendingGuildInviterName_; std::string pendingGuildInviteGuildName_; bool showPetitionDialog_ = false; uint32_t petitionCost_ = 0; uint64_t petitionNpcGuid_ = 0; + PetitionInfo petitionInfo_; uint64_t activeCharacterGuid_ = 0; Race playerRace_ = Race::HUMAN; + // Barber shop + bool barberShopOpen_ = false; + // ---- Phase 5: Loot ---- bool lootWindowOpen = false; bool autoLoot_ = false; + bool autoSellGrey_ = false; + bool autoRepair_ = false; LootResponseData currentLoot; + std::vector masterLootCandidates_; // from SMSG_LOOT_MASTER_LIST // Group loot roll state bool pendingLootRollActive_ = false; @@ -1977,6 +3126,7 @@ private: struct LocalLootState { LootResponseData data; bool moneyTaken = false; + bool itemAutoLootSent = false; }; std::unordered_map localLootState_; struct PendingLootRetry { @@ -1991,12 +3141,32 @@ private: float timer = 0.0f; }; std::vector pendingGameObjectLootOpens_; + // Tracks the last GO we sent CMSG_GAMEOBJ_USE to; used in handleSpellGo + // to send CMSG_LOOT after a gather cast (mining/herbalism) completes. + uint64_t lastInteractedGoGuid_ = 0; uint64_t pendingLootMoneyGuid_ = 0; uint32_t pendingLootMoneyAmount_ = 0; float pendingLootMoneyNotifyTimer_ = 0.0f; std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; + uint32_t playerHonorPoints_ = 0; + uint32_t playerArenaPoints_ = 0; int32_t playerArmorRating_ = 0; + int32_t playerResistances_[6] = {}; // [0]=Holy,[1]=Fire,[2]=Nature,[3]=Frost,[4]=Shadow,[5]=Arcane + // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet + int32_t playerStats_[5] = {-1, -1, -1, -1, -1}; + // WotLK secondary combat stats (-1 = not yet received) + int32_t playerMeleeAP_ = -1; + int32_t playerRangedAP_ = -1; + int32_t playerSpellDmgBonus_[7] = {-1,-1,-1,-1,-1,-1,-1}; // per school 0-6 + int32_t playerHealBonus_ = -1; + float playerDodgePct_ = -1.0f; + float playerParryPct_ = -1.0f; + float playerBlockPct_ = -1.0f; + float playerCritPct_ = -1.0f; + float playerRangedCritPct_ = -1.0f; + float playerSpellCritPct_[7] = {-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f,-1.0f}; + int32_t playerCombatRatings_[25] = {-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating // money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime. uint32_t pendingMoneyDelta_ = 0; @@ -2011,6 +3181,7 @@ private: // Quest details bool questDetailsOpen = false; + std::chrono::steady_clock::time_point questDetailsOpenTime{}; // Delayed opening to allow item data to load QuestDetailsData currentQuestDetails; // Quest turn-in @@ -2026,6 +3197,7 @@ private: // Quest log std::vector questLog_; + int selectedQuestLogIndex_ = 0; std::unordered_set pendingQuestQueryIds_; std::unordered_set trackedQuestIds_; bool pendingLoginQuestResync_ = false; @@ -2041,6 +3213,9 @@ private: return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown } + // Vehicle (WotLK): non-zero when player is seated in a vehicle + uint32_t vehicleId_ = 0; + // Taxi / Flight Paths std::unordered_map taxiNpcHasRoutes_; // guid -> has new/available routes std::unordered_map taxiNodes_; @@ -2051,6 +3226,7 @@ private: ShowTaxiNodesData currentTaxiData_; uint64_t taxiNpcGuid_ = 0; bool onTaxiFlight_ = false; + std::string taxiDestName_; bool taxiMountActive_ = false; uint32_t taxiMountDisplayId_ = 0; bool taxiActivatePending_ = false; @@ -2136,14 +3312,55 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; }; + struct SpellNameEntry { std::string name; std::string rank; std::string description; uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; - // Achievement name cache (lazy-loaded from Achievement.dbc on first earned event) + // Title cache: maps titleBit → title string (lazy-loaded from CharTitles.dbc) + // The strings use "%s" as a player-name placeholder (e.g. "Commander %s", "%s the Explorer"). + std::unordered_map titleNameCache_; + bool titleNameCacheLoaded_ = false; + void loadTitleNameCache(); + // Set of title bit-indices known to the player (from SMSG_TITLE_EARNED). + std::unordered_set knownTitleBits_; + // Currently selected title bit, or -1 for no title. Updated from PLAYER_CHOSEN_TITLE. + int32_t chosenTitleBit_ = -1; + + // Achievement caches (lazy-loaded from Achievement.dbc on first earned event) std::unordered_map achievementNameCache_; + std::unordered_map achievementDescCache_; + std::unordered_map achievementPointsCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); + // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_set earnedAchievements_; + // Earn dates: achievementId → WoW PackedTime (from SMSG_ACHIEVEMENT_EARNED / SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_map achievementDates_; + // Criteria progress: criteriaId → current value (from SMSG_CRITERIA_UPDATE) + std::unordered_map criteriaProgress_; + void handleAllAchievementData(network::Packet& packet); + + // Per-player achievement data from SMSG_RESPOND_INSPECT_ACHIEVEMENTS + // Key: inspected player's GUID; value: set of earned achievement IDs + std::unordered_map> inspectedPlayerAchievements_; + void handleRespondInspectAchievements(network::Packet& packet); + + // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) + std::unordered_map areaNameCache_; + bool areaNameCacheLoaded_ = false; + void loadAreaNameCache(); + std::string getAreaName(uint32_t areaId) const; + + // Map name cache (lazy-loaded from Map.dbc; maps mapId → localized display name) + std::unordered_map mapNameCache_; + bool mapNameCacheLoaded_ = false; + void loadMapNameCache(); + + // LFG dungeon name cache (lazy-loaded from LFGDungeons.dbc; WotLK only) + std::unordered_map lfgDungeonNameCache_; + bool lfgDungeonNameCacheLoaded_ = false; + void loadLfgDungeonDbc(); + std::string getLfgDungeonName(uint32_t dungeonId) const; std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); @@ -2196,9 +3413,19 @@ private: uint8_t wardenCheckOpcodes_[9] = {}; bool loadWardenCRFile(const std::string& moduleHashHex); + // Async Warden response: avoids 5-second main-loop stalls from PAGE_A/PAGE_B code pattern searches + std::future> wardenPendingEncrypted_; // encrypted response bytes + bool wardenResponsePending_ = false; + + // ---- RX silence detection ---- + std::chrono::steady_clock::time_point lastRxTime_{}; + bool rxSilenceLogged_ = false; + // ---- 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); @@ -2207,6 +3434,10 @@ private: float timeSpeed_ = 0.0166f; // Time scale (default: 1 game day = 1 real hour) void handleLoginSetTimeSpeed(network::Packet& packet); + // ---- Global Cooldown (GCD) ---- + float gcdTotal_ = 0.0f; + std::chrono::steady_clock::time_point gcdStartedAt_{}; + // ---- Weather state (SMSG_WEATHER) ---- uint32_t weatherType_ = 0; // 0=clear, 1=rain, 2=snow, 3=storm float weatherIntensity_ = 0.0f; // 0.0 to 1.0 @@ -2220,6 +3451,8 @@ private: std::unordered_map skillLineNames_; std::unordered_map skillLineCategories_; std::unordered_map spellToSkillLine_; // spellID -> skillLineID + std::vector spellBookTabs_; + bool spellBookTabsDirty_ = true; bool skillLineDbcLoaded_ = false; bool skillLineAbilityLoaded_ = false; static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128; @@ -2230,23 +3463,41 @@ private: void loadSkillLineAbilityDbc(); void extractSkillFields(const std::map& fields); void extractExploredZoneFields(const std::map& fields); + void applyQuestStateFromFields(const std::map& fields); + // Apply packed kill counts from player update fields to a quest entry that has + // already had its killObjectives populated from SMSG_QUEST_QUERY_RESPONSE. + void applyPackedKillCountsFromFields(QuestLogEntry& quest); NpcDeathCallback npcDeathCallback_; NpcAggroCallback npcAggroCallback_; NpcRespawnCallback npcRespawnCallback_; + StandStateCallback standStateCallback_; + AppearanceChangedCallback appearanceChangedCallback_; + GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; + uint64_t lastMeleeSwingMs_ = 0; // system_clock ms at last player auto-attack swing + SpellCastAnimCallback spellCastAnimCallback_; + SpellCastFailedCallback spellCastFailedCallback_; + UnitAnimHintCallback unitAnimHintCallback_; + UnitMoveFlagsCallback unitMoveFlagsCallback_; NpcSwingCallback npcSwingCallback_; NpcGreetingCallback npcGreetingCallback_; NpcFarewellCallback npcFarewellCallback_; NpcVendorCallback npcVendorCallback_; ChargeCallback chargeCallback_; LevelUpCallback levelUpCallback_; + LevelUpDeltas lastLevelUpDeltas_; + std::vector tempEnchantTimers_; + std::vector bookPages_; // pages collected for the current readable item OtherPlayerLevelUpCallback otherPlayerLevelUpCallback_; AchievementEarnedCallback achievementEarnedCallback_; + AreaDiscoveryCallback areaDiscoveryCallback_; + QuestProgressCallback questProgressCallback_; MountCallback mountCallback_; TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; TaxiFlightStartCallback taxiFlightStartCallback_; + OpenLfgCallback openLfgCallback_; uint32_t currentMountDisplayId_ = 0; uint32_t mountAuraSpellId_ = 0; // Spell ID of the aura that caused mounting (for CMSG_CANCEL_AURA fallback) float serverRunSpeed_ = 7.0f; @@ -2262,6 +3513,10 @@ private: bool releasedSpirit_ = false; uint32_t corpseMapId_ = 0; float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f; + uint64_t corpseGuid_ = 0; + // Absolute time (ms since epoch) when PvP corpse-reclaim delay expires. + // 0 means no active delay (reclaim allowed immediately upon proximity). + uint64_t corpseReclaimAvailableMs_ = 0; // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially std::array playerRunes_ = [] { std::array r{}; @@ -2273,6 +3528,15 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + bool selfResAvailable_ = false; // SMSG_PRE_RESURRECT received — Reincarnation/Twisting Nether + // ---- Talent wipe confirm dialog ---- + bool talentWipePending_ = false; + uint64_t talentWipeNpcGuid_ = 0; + uint32_t talentWipeCost_ = 0; + // ---- Pet talent respec confirm dialog ---- + bool petUnlearnPending_ = false; + uint64_t petUnlearnGuid_ = 0; + uint32_t petUnlearnCost_ = 0; bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST uint64_t resurrectCasterGuid_ = 0; std::string resurrectCasterName_; @@ -2292,6 +3556,9 @@ private: std::array itemGuids{}; }; std::vector equipmentSets_; + std::string pendingSaveSetName_; // Saved between CMSG_EQUIPMENT_SET_SAVE and SMSG_EQUIPMENT_SET_SAVED + std::string pendingSaveSetIcon_; + std::vector equipmentSetInfo_; // public-facing copy // ---- Forced faction reactions (SMSG_SET_FORCED_REACTIONS) ---- std::unordered_map forcedReactions_; // factionId -> reaction tier @@ -2300,6 +3567,41 @@ private: PlayMusicCallback playMusicCallback_; PlaySoundCallback playSoundCallback_; PlayPositionalSoundCallback playPositionalSoundCallback_; + + // ---- UI error frame callback ---- + UIErrorCallback uiErrorCallback_; + + // ---- Reputation change callback ---- + RepChangeCallback repChangeCallback_; + uint32_t watchedFactionId_ = 0; // auto-set to most recently changed faction + + // ---- PvP honor credit callback ---- + PvpHonorCallback pvpHonorCallback_; + + // ---- Item loot callback ---- + ItemLootCallback itemLootCallback_; + + // ---- Quest completion callback ---- + QuestCompleteCallback questCompleteCallback_; + + // ---- GM Ticket state (SMSG_GMTICKET_GETTICKET / SMSG_GMTICKET_SYSTEMSTATUS) ---- + bool gmTicketActive_ = false; ///< True when an open ticket exists on the server + std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET) + float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours + bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS) + + // ---- Battlefield Manager state (WotLK Wintergrasp / outdoor battlefields) ---- + bool bfMgrInvitePending_ = false; ///< True when an entry/queue invite is pending acceptance + bool bfMgrActive_ = false; ///< True while the player is inside an outdoor battlefield + uint32_t bfMgrZoneId_ = 0; ///< Zone ID of the pending/active battlefield + + // ---- WotLK Calendar: pending invite counter ---- + uint32_t calendarPendingInvites_ = 0; ///< Unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING) + + // ---- Spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) ---- + // Keyed by (SpellModOp, groupIndex); cleared on logout/character change. + std::unordered_map spellFlatMods_; + std::unordered_map spellPctMods_; }; } // namespace game diff --git a/include/game/inventory.hpp b/include/game/inventory.hpp index b25d5234..cf092ac4 100644 --- a/include/game/inventory.hpp +++ b/include/game/inventory.hpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -14,6 +15,8 @@ enum class ItemQuality : uint8_t { RARE = 3, // Blue EPIC = 4, // Purple LEGENDARY = 5, // Orange + ARTIFACT = 6, // Yellow (unused in 3.3.5a but valid quality value) + HEIRLOOM = 7, // Yellow/gold (WotLK bind-on-account heirlooms) }; enum class EquipSlot : uint8_t { @@ -46,6 +49,16 @@ struct ItemDef { int32_t spirit = 0; uint32_t displayInfoId = 0; uint32_t sellPrice = 0; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; + uint32_t itemLevel = 0; + uint32_t requiredLevel = 0; + uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ + std::string description; // Flavor/lore text shown in tooltip (italic yellow) + // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) + struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; + std::vector extraStats; + uint32_t startQuestId = 0; // Non-zero: item begins a quest }; struct ItemSlot { @@ -56,6 +69,7 @@ struct ItemSlot { class Inventory { public: static constexpr int BACKPACK_SLOTS = 16; + static constexpr int KEYRING_SLOTS = 32; static constexpr int NUM_EQUIP_SLOTS = 23; static constexpr int NUM_BAG_SLOTS = 4; static constexpr int MAX_BAG_SIZE = 36; @@ -75,6 +89,12 @@ public: bool setEquipSlot(EquipSlot slot, const ItemDef& item); bool clearEquipSlot(EquipSlot slot); + // Keyring + const ItemSlot& getKeyringSlot(int index) const; + bool setKeyringSlot(int index, const ItemDef& item); + bool clearKeyringSlot(int index); + int getKeyringSize() const { return KEYRING_SLOTS; } + // Extra bags int getBagSize(int bagIndex) const; void setBagSize(int bagIndex, int size); @@ -105,11 +125,16 @@ public: int findFreeBackpackSlot() const; bool addItem(const ItemDef& item); + // Sort all bag slots (backpack + equip bags) by quality desc → itemId asc → stackCount desc. + // Purely client-side: reorders the local inventory struct without server interaction. + void sortBags(); + // Test data void populateTestItems(); private: std::array backpack{}; + std::array keyring_{}; std::array equipment{}; struct BagData { diff --git a/include/game/opcode_aliases_generated.inc b/include/game/opcode_aliases_generated.inc index ad488110..ed20d098 100644 --- a/include/game/opcode_aliases_generated.inc +++ b/include/game/opcode_aliases_generated.inc @@ -41,5 +41,4 @@ {"SMSG_SPLINE_MOVE_SET_RUN_BACK_SPEED", "SMSG_SPLINE_SET_RUN_BACK_SPEED"}, {"SMSG_SPLINE_MOVE_SET_RUN_SPEED", "SMSG_SPLINE_SET_RUN_SPEED"}, {"SMSG_SPLINE_MOVE_SET_SWIM_SPEED", "SMSG_SPLINE_SET_SWIM_SPEED"}, - {"SMSG_UPDATE_AURA_DURATION", "SMSG_EQUIPMENT_SET_SAVED"}, {"SMSG_VICTIMSTATEUPDATE_OBSOLETE", "SMSG_BATTLEFIELD_PORT_DENIED"}, diff --git a/include/game/opcode_table.hpp b/include/game/opcode_table.hpp index 91242206..966542b9 100644 --- a/include/game/opcode_table.hpp +++ b/include/game/opcode_table.hpp @@ -33,7 +33,10 @@ class OpcodeTable { public: /** * Load opcode mappings from a JSON file. - * Format: { "CMSG_PING": "0x1DC", "SMSG_AUTH_CHALLENGE": "0x1EC", ... } + * Format: + * { "CMSG_PING": "0x1DC", "SMSG_AUTH_CHALLENGE": "0x1EC", ... } + * or a delta file with: + * { "_extends": "../classic/opcodes.json", "_remove": ["MSG_FOO"], ...overrides } */ bool loadFromJson(const std::string& path); @@ -49,12 +52,14 @@ public: /** Number of mapped opcodes. */ size_t size() const { return logicalToWire_.size(); } + /** Get canonical enum name for a logical opcode. */ + static const char* logicalToName(LogicalOpcode op); + private: std::unordered_map logicalToWire_; // LogicalOpcode → wire std::unordered_map wireToLogical_; // wire → LogicalOpcode static std::optional nameToLogical(const std::string& name); - static const char* logicalToName(LogicalOpcode op); }; /** diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index d2556e7b..b229dc80 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -26,6 +26,10 @@ public: // Classic: none, TBC: u8, WotLK: u16. virtual uint8_t movementFlags2Size() const { return 2; } + // Wire-format movement flag that gates transport data in MSG_MOVE_* payloads. + // WotLK/TBC: 0x200, Classic/Turtle: 0x02000000. + virtual uint32_t wireOnTransportFlag() const { return 0x00000200; } + // --- Movement --- /** Parse movement block from SMSG_UPDATE_OBJECT */ @@ -207,6 +211,11 @@ public: * WotLK: 5 fields per slot, Classic/Vanilla: 3. */ virtual uint8_t questLogStride() const { return 5; } + /** Number of PLAYER_EXPLORED_ZONES uint32 fields in update-object blocks. + * Classic/Vanilla/Turtle: 64 (bit-packs up to zone ID 2047). + * TBC/WotLK: 128 (covers Outland/Northrend zone IDs up to 4095). */ + virtual uint8_t exploredZonesCount() const { return 128; } + // --- Quest Giver Status --- /** Read quest giver status from packet. @@ -261,8 +270,8 @@ public: virtual bool parseMailList(network::Packet& packet, std::vector& inbox); /** Build CMSG_MAIL_TAKE_ITEM */ - virtual network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) { - return MailTakeItemPacket::build(mailboxGuid, mailId, itemSlot); + virtual network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) { + return MailTakeItemPacket::build(mailboxGuid, mailId, itemGuidLow); } /** Build CMSG_MAIL_DELETE */ @@ -356,6 +365,20 @@ public: // 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; + // TBC 2.4.3 SMSG_GUILD_ROSTER: same rank structure as WotLK (variable rankCount + + // goldLimit + bank tabs), but NO gender byte per member (WotLK added it) + bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; + // TBC 2.4.3 SMSG_QUESTGIVER_STATUS: uint32 status (WotLK uses uint8) + uint8_t readQuestGiverStatus(network::Packet& packet) override; + // TBC 2.4.3 SMSG_MESSAGECHAT: no senderGuid/unknown prefix before type-specific data + bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; + // TBC 2.4.3 SMSG_GAMEOBJECT_QUERY_RESPONSE: 2 extra strings after names + // (iconName + castBarCaption); WotLK has 3 (adds unk1) + bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; + // TBC 2.4.3 CMSG_JOIN_CHANNEL: name+password only (WotLK prepends channelId+hasVoice+joinedByZone) + network::Packet buildJoinChannel(const std::string& channelName, const std::string& password) override; + // TBC 2.4.3 CMSG_LEAVE_CHANNEL: name only (WotLK prepends channelId) + network::Packet buildLeaveChannel(const std::string& channelName) override; }; /** @@ -375,6 +398,7 @@ public: class ClassicPacketParsers : public TbcPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } + uint32_t wireOnTransportFlag() const override { return 0x02000000; } bool parseCharEnum(network::Packet& packet, CharEnumResponse& response) override; bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; void writeMovementPayload(network::Packet& packet, const MovementInfo& info) override; @@ -384,6 +408,7 @@ public: network::Packet buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) override; network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) override; bool parseCastFailed(network::Packet& packet, CastFailedData& data) override; + bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) override; bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; // Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName string that TBC/WotLK include @@ -398,7 +423,7 @@ public: uint32_t money, uint32_t cod, const std::vector& itemGuids = {}) override; bool parseMailList(network::Packet& packet, std::vector& inbox) override; - network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemSlot) override; + network::Packet buildMailTakeItem(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) override; network::Packet buildMailDelete(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) override; network::Packet buildItemQuery(uint32_t entry, uint64_t guid) override; bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override; @@ -407,9 +432,16 @@ public: network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; // parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3) uint8_t questLogStride() const override { return 3; } + // Classic 1.12 has 64 explored-zone uint32 fields (zone IDs fit in 2048 bits). + // TBC/WotLK use 128 (needed for Outland/Northrend zone IDs up to 4095). + uint8_t exploredZonesCount() const override { return 64; } bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); } + // Classic 1.12 SMSG_INITIAL_SPELLS: uint16 spellId + uint16 slot per entry (not uint32 + uint16) + bool parseInitialSpells(network::Packet& packet, InitialSpellsData& data) override { + return InitialSpellsParser::parse(packet, data, /*vanillaFormat=*/true); + } // Classic 1.12 uses PackedGuid (not full uint64) and uint16 castFlags (not uint32) bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; bool parseSpellGo(network::Packet& packet, SpellGoData& data) override; @@ -426,18 +458,21 @@ public: }; /** - * Turtle WoW (build 7234) packet parsers. + * Turtle WoW packet parsers. * - * Turtle WoW is a heavily modified vanilla server that sends TBC-style - * movement blocks (moveFlags2, transport timestamps, 8 speeds including flight) - * while keeping all other Classic packet formats. + * Turtle is Classic-based but not wire-identical to vanilla MaNGOS. It keeps + * most Classic packet formats, while overriding the movement-bearing paths that + * have proven to vary in live traffic: + * - update-object movement blocks use a Turtle-specific hybrid layout + * - update-object parsing falls back through Classic/TBC/WotLK movement layouts + * - monster-move parsing falls back through Vanilla, TBC, and guarded WotLK layouts * - * Inherits all Classic overrides (charEnum, chat, gossip, mail, items, etc.) - * but delegates movement block parsing to TBC format. + * Everything else inherits the Classic parser behavior. */ class TurtlePacketParsers : public ClassicPacketParsers { public: uint8_t movementFlags2Size() const override { return 0; } + bool parseUpdateObject(network::Packet& packet, UpdateObjectData& data) override; bool parseMovementBlock(network::Packet& packet, UpdateBlock& block) override; bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; }; diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 041b44f6..c6fe3663 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -50,19 +51,38 @@ struct ActionBarSlot { struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, - CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE + EVADE, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, + ENERGIZE, POWER_DRAIN, XP_GAIN, IMMUNE, ABSORB, RESIST, DEFLECT, REFLECT, PROC_TRIGGER, + DISPEL, STEAL, INTERRUPT, INSTAKILL, HONOR_GAIN, GLANCING, CRUSHING }; Type type; int32_t amount = 0; uint32_t spellId = 0; float age = 0.0f; // Seconds since creation (for fadeout) bool isPlayerSource = false; // True if player dealt this + uint8_t powerType = 0; // For ENERGIZE/POWER_DRAIN: 0=mana,1=rage,2=focus,3=energy,6=runicpower + uint64_t srcGuid = 0; // Source entity (attacker/caster) + uint64_t dstGuid = 0; // Destination entity (victim/target) — used for world-space positioning + float xSeed = 0.0f; // Random horizontal offset seed (-1..1) to stagger overlapping text static constexpr float LIFETIME = 2.5f; bool isExpired() const { return age >= LIFETIME; } }; +/** + * Persistent combat log entry (stored in a rolling deque, survives beyond floating-text lifetime) + */ +struct CombatLogEntry { + CombatTextEntry::Type type = CombatTextEntry::MELEE_DAMAGE; + int32_t amount = 0; + uint32_t spellId = 0; + bool isPlayerSource = false; + uint8_t powerType = 0; // For ENERGIZE/DRAIN: power type; for ENVIRONMENTAL: env damage type + time_t timestamp = 0; // Wall-clock time (std::time(nullptr)) + std::string sourceName; // Resolved display name of attacker/caster + std::string targetName; // Resolved display name of victim/target +}; + /** * Spell cooldown entry received from server */ diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index b841925e..4cc2a44a 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -14,6 +14,7 @@ namespace game { enum class UF : uint16_t { // Object fields OBJECT_FIELD_ENTRY, + OBJECT_FIELD_SCALE_X, // Unit fields UNIT_FIELD_TARGET_LO, @@ -30,31 +31,63 @@ enum class UF : uint16_t { UNIT_FIELD_DISPLAYID, UNIT_FIELD_MOUNTDISPLAYID, UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only) + UNIT_FIELD_AURAFLAGS, // Aura flags packed 4-per-uint32 (12 uint32 slots); 0x01=cancelable,0x02=harmful,0x04=helpful UNIT_NPC_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array) + UNIT_FIELD_STAT0, // Strength (effective base, includes items) + UNIT_FIELD_STAT1, // Agility + UNIT_FIELD_STAT2, // Stamina + UNIT_FIELD_STAT3, // Intellect + UNIT_FIELD_STAT4, // Spirit UNIT_END, + // Unit combat fields (WotLK: PRIVATE+OWNER — only visible for the player character) + UNIT_FIELD_ATTACK_POWER, // Melee attack power (int32) + UNIT_FIELD_RANGED_ATTACK_POWER, // Ranged attack power (int32) + // Player fields PLAYER_FLAGS, PLAYER_BYTES, 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, PLAYER_FIELD_PACK_SLOT_1, + PLAYER_FIELD_KEYRING_SLOT_1, PLAYER_FIELD_BANK_SLOT_1, PLAYER_FIELD_BANKBAG_SLOT_1, PLAYER_SKILL_INFO_START, PLAYER_EXPLORED_ZONES_START, + PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) + + // Player spell power / healing bonus (WotLK: PRIVATE — int32 per school) + PLAYER_FIELD_MOD_DAMAGE_DONE_POS, // Spell damage bonus (first of 7 schools) + PLAYER_FIELD_MOD_HEALING_DONE_POS, // Healing bonus + + // Player combat stats (WotLK: PRIVATE — float values) + PLAYER_BLOCK_PERCENTAGE, // Block chance % + PLAYER_DODGE_PERCENTAGE, // Dodge chance % + PLAYER_PARRY_PERCENTAGE, // Parry chance % + PLAYER_CRIT_PERCENTAGE, // Melee crit chance % + PLAYER_RANGED_CRIT_PERCENTAGE, // Ranged crit chance % + PLAYER_SPELL_CRIT_PERCENTAGE1, // Spell crit chance % (first school; 7 consecutive float fields) + PLAYER_FIELD_COMBAT_RATING_1, // First of 25 int32 combat rating slots (CR_* indices) + + // Player PvP currency (TBC/WotLK only — Classic uses the old weekly honor system) + PLAYER_FIELD_HONOR_CURRENCY, // Accumulated honor points (uint32) + PLAYER_FIELD_ARENA_CURRENCY, // Accumulated arena points (uint32) // GameObject fields GAMEOBJECT_DISPLAYID, // Item fields ITEM_FIELD_STACK_COUNT, + ITEM_FIELD_DURABILITY, + ITEM_FIELD_MAXDURABILITY, // Container fields CONTAINER_FIELD_NUM_SLOTS, diff --git a/include/game/warden_emulator.hpp b/include/game/warden_emulator.hpp index 320afd0d..30a0759f 100644 --- a/include/game/warden_emulator.hpp +++ b/include/game/warden_emulator.hpp @@ -147,11 +147,21 @@ private: uint32_t heapSize_; // Heap size uint32_t apiStubBase_; // API stub base address - // API hooks: DLL name -> Function name -> Handler + // API hooks: DLL name -> Function name -> stub address std::map> apiAddresses_; + // API stub dispatch: stub address -> {argCount, handler} + struct ApiHookEntry { + int argCount; + std::function&)> handler; + }; + std::map apiHandlers_; + uint32_t nextApiStubAddr_; // tracks next free stub slot (replaces static local) + bool apiCodeHookRegistered_; // true once UC_HOOK_CODE for stub range is added + // Memory allocation tracking std::map allocations_; + std::map freeBlocks_; // free-list keyed by base address uint32_t nextHeapAddr_; // Hook handles for cleanup diff --git a/include/game/warden_memory.hpp b/include/game/warden_memory.hpp index 335ad7a9..39a2abf2 100644 --- a/include/game/warden_memory.hpp +++ b/include/game/warden_memory.hpp @@ -3,6 +3,7 @@ #include #include #include +#include namespace wowee { namespace game { @@ -18,8 +19,9 @@ public: ~WardenMemory(); /** Search standard candidate dirs for WoW.exe and load it. - * @param build Client build number (e.g. 5875 for Classic 1.12.1) to select the right exe. */ - bool load(uint16_t build = 0); + * @param build Client build number (e.g. 5875 for Classic 1.12.1) to select the right exe. + * @param isTurtle If true, prefer the Turtle WoW custom exe (different code bytes). */ + bool load(uint16_t build = 0, bool isTurtle = false); /** Load PE image from a specific file path. */ bool loadFromFile(const std::string& exePath); @@ -32,6 +34,23 @@ public: bool isLoaded() const { return loaded_; } + /** + * Search PE image for a byte pattern matching HMAC-SHA1(seed, pattern). + * Used for FIND_MEM_IMAGE_CODE_BY_HASH and FIND_CODE_BY_HASH scans. + * @param seed 4-byte HMAC key + * @param expectedHash 20-byte expected HMAC-SHA1 digest + * @param patternLen Length of the pattern to search for + * @param imageOnly If true, search only executable sections (.text) + * @param hintOffset RVA hint from PAGE_A request — check this position first + * @return true if a matching pattern was found in the PE image + */ + bool searchCodePattern(const uint8_t seed[4], const uint8_t expectedHash[20], + uint8_t patternLen, bool imageOnly, + uint32_t hintOffset = 0, bool hintOnly = false) const; + + /** Write a little-endian uint32 at the given virtual address in the PE image. */ + void writeLE32(uint32_t va, uint32_t value); + private: bool loaded_ = false; uint32_t imageBase_ = 0; @@ -46,9 +65,15 @@ private: bool parsePE(const std::vector& fileData); void initKuserSharedData(); void patchRuntimeGlobals(); - void writeLE32(uint32_t va, uint32_t value); + void patchTurtleWowBinary(); + void verifyWardenScanEntries(); + bool isTurtle_ = false; std::string findWowExe(uint16_t build) const; - static uint32_t expectedImageSizeForBuild(uint16_t build); + static uint32_t expectedImageSizeForBuild(uint16_t build, bool isTurtle); + + // Cache for searchCodePattern results to avoid repeated 5-second brute-force searches. + // Key: hex string of seed(4)+hash(20)+patLen(1)+imageOnly(1) = 26 bytes. + mutable std::unordered_map codePatternCache_; }; } // namespace game diff --git a/include/game/warden_module.hpp b/include/game/warden_module.hpp index bcea989f..e11bc4f9 100644 --- a/include/game/warden_module.hpp +++ b/include/game/warden_module.hpp @@ -140,6 +140,7 @@ private: size_t relocDataOffset_ = 0; // Offset into decompressedData_ where relocation data starts WardenFuncList funcList_; // Callback functions std::unique_ptr emulator_; // Cross-platform x86 emulator + uint32_t emulatedPacketHandlerAddr_ = 0; // Raw emulated VA for 4-arg PacketHandler call // Validation and loading steps bool verifyMD5(const std::vector& data, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 7f62b622..d72aebe6 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -395,11 +395,14 @@ enum class MovementFlags : uint32_t { ROOT = 0x00000800, FALLING = 0x00001000, FALLINGFAR = 0x00002000, + FEATHER_FALL = 0x00004000, // Slow fall / Parachute + WATER_WALK = 0x00008000, // Walk on water surface SWIMMING = 0x00200000, ASCENDING = 0x00400000, - CAN_FLY = 0x00800000, - FLYING = 0x01000000, - HOVER = 0x02000000, + DESCENDING = 0x00800000, + CAN_FLY = 0x01000000, + FLYING = 0x02000000, + HOVER = 0x40000000, }; /** @@ -481,6 +484,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; @@ -609,7 +616,11 @@ enum class ChatType : uint8_t { MONSTER_WHISPER = 42, RAID_BOSS_WHISPER = 43, RAID_BOSS_EMOTE = 44, - MONSTER_PARTY = 50 + MONSTER_PARTY = 50, + // BG/Arena system messages (WoW 3.3.5a — no sender, treated as SYSTEM in display) + BG_SYSTEM_NEUTRAL = 82, + BG_SYSTEM_ALLIANCE = 83, + BG_SYSTEM_HORDE = 84 }; /** @@ -746,38 +757,40 @@ public: * Channel notification types */ enum class ChannelNotifyType : uint8_t { - YOU_JOINED = 0x00, - YOU_LEFT = 0x01, - WRONG_PASSWORD = 0x02, - NOT_MEMBER = 0x03, - NOT_MODERATOR = 0x04, - PASSWORD_CHANGED = 0x05, - OWNER_CHANGED = 0x06, - PLAYER_NOT_FOUND = 0x07, - NOT_OWNER = 0x08, - CHANNEL_OWNER = 0x09, - MODE_CHANGE = 0x0A, - ANNOUNCEMENTS_ON = 0x0B, - ANNOUNCEMENTS_OFF = 0x0C, - MODERATION_ON = 0x0D, - MODERATION_OFF = 0x0E, - MUTED = 0x0F, - PLAYER_KICKED = 0x10, - BANNED = 0x11, - PLAYER_BANNED = 0x12, - PLAYER_UNBANNED = 0x13, - PLAYER_NOT_BANNED = 0x14, - PLAYER_ALREADY_MEMBER = 0x15, - INVITE = 0x16, - INVITE_WRONG_FACTION = 0x17, - WRONG_FACTION = 0x18, - INVALID_NAME = 0x19, - NOT_MODERATED = 0x1A, - PLAYER_INVITED = 0x1B, - PLAYER_INVITE_BANNED = 0x1C, - THROTTLED = 0x1D, - NOT_IN_AREA = 0x1E, - NOT_IN_LFG = 0x1F, + PLAYER_JOINED = 0x00, + PLAYER_LEFT = 0x01, + YOU_JOINED = 0x02, + YOU_LEFT = 0x03, + WRONG_PASSWORD = 0x04, + NOT_MEMBER = 0x05, + NOT_MODERATOR = 0x06, + PASSWORD_CHANGED = 0x07, + OWNER_CHANGED = 0x08, + PLAYER_NOT_FOUND = 0x09, + NOT_OWNER = 0x0A, + CHANNEL_OWNER = 0x0B, + MODE_CHANGE = 0x0C, + ANNOUNCEMENTS_ON = 0x0D, + ANNOUNCEMENTS_OFF = 0x0E, + MODERATION_ON = 0x0F, + MODERATION_OFF = 0x10, + MUTED = 0x11, + PLAYER_KICKED = 0x12, + BANNED = 0x13, + PLAYER_BANNED = 0x14, + PLAYER_UNBANNED = 0x15, + PLAYER_NOT_BANNED = 0x16, + PLAYER_ALREADY_MEMBER = 0x17, + INVITE = 0x18, + INVITE_WRONG_FACTION = 0x19, + WRONG_FACTION = 0x1A, + INVALID_NAME = 0x1B, + NOT_MODERATED = 0x1C, + PLAYER_INVITED = 0x1D, + PLAYER_INVITE_BANNED = 0x1E, + THROTTLED = 0x1F, + NOT_IN_AREA = 0x20, + NOT_IN_LFG = 0x21, }; /** @@ -937,6 +950,21 @@ public: static network::Packet build(uint8_t state); }; +// ============================================================ +// Action Bar +// ============================================================ + +/** CMSG_SET_ACTION_BUTTON packet builder */ +class SetActionButtonPacket { +public: + // button: 0-based slot index + // type: ActionBarSlot::Type (SPELL=0, ITEM=1, MACRO=2, EMPTY=0) + // id: spellId, itemId, or macroId (0 to clear) + // isClassic: true for Vanilla/Turtle format (5-byte payload), + // false for TBC/WotLK (5-byte packed uint32) + static network::Packet build(uint8_t button, uint8_t type, uint32_t id, bool isClassic); +}; + // ============================================================ // Display Toggles // ============================================================ @@ -1346,6 +1374,33 @@ public: static network::Packet build(); }; +/** CMSG_SET_TRADE_ITEM packet builder (tradeSlot, bag, bagSlot) */ +class SetTradeItemPacket { +public: + // tradeSlot: 0-5 (normal) or 6 (backpack money-only slot) + // bag: 255 = main backpack, 19-22 = bag slots + // bagSlot: slot within bag + static network::Packet build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot); +}; + +/** CMSG_CLEAR_TRADE_ITEM packet builder (remove item from trade slot) */ +class ClearTradeItemPacket { +public: + static network::Packet build(uint8_t tradeSlot); +}; + +/** CMSG_SET_TRADE_GOLD packet builder (gold offered, in copper) */ +class SetTradeGoldPacket { +public: + static network::Packet build(uint64_t copper); +}; + +/** CMSG_UNACCEPT_TRADE packet builder (unaccept without cancelling) */ +class UnacceptTradePacket { +public: + static network::Packet build(); +}; + /** CMSG_ATTACKSWING packet builder */ class AttackSwingPacket { public: @@ -1411,6 +1466,12 @@ public: static network::Packet build(uint64_t targetGuid); }; +/** CMSG_QUERY_INSPECT_ACHIEVEMENTS packet builder (WotLK 3.3.5a) */ +class QueryInspectAchievementsPacket { +public: + static network::Packet build(uint64_t targetGuid); +}; + /** CMSG_NAME_QUERY packet builder */ class NameQueryPacket { public: @@ -1529,19 +1590,29 @@ struct ItemQueryResponseData { uint32_t subClass = 0; uint32_t displayInfoId = 0; uint32_t quality = 0; + uint32_t itemFlags = 0; // Item flag bitmask (Heroic=0x8, Unique-Equipped=0x1000000) uint32_t inventoryType = 0; + int32_t maxCount = 0; // Max that can be carried (1 = Unique, 0 = unlimited) int32_t maxStack = 1; uint32_t containerSlots = 0; float damageMin = 0.0f; float damageMax = 0.0f; uint32_t delayMs = 0; int32_t armor = 0; + int32_t holyRes = 0; + int32_t fireRes = 0; + int32_t natureRes = 0; + int32_t frostRes = 0; + int32_t shadowRes = 0; + int32_t arcaneRes = 0; int32_t stamina = 0; int32_t strength = 0; int32_t agility = 0; int32_t intellect = 0; int32_t spirit = 0; uint32_t sellPrice = 0; + uint32_t itemLevel = 0; + uint32_t requiredLevel = 0; std::string subclassName; // Item spells (up to 5) struct ItemSpell { @@ -1549,6 +1620,23 @@ struct ItemQueryResponseData { uint32_t spellTrigger = 0; // 0=Use, 1=Equip, 2=ChanceOnHit, 5=Learn }; std::array spells{}; + uint32_t bindType = 0; // 0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ + std::string description; // Flavor/lore text + // Generic stat pairs for non-primary stats (hit, crit, haste, AP, SP, etc.) + struct ExtraStat { uint32_t statType = 0; int32_t statValue = 0; }; + std::vector extraStats; + uint32_t startQuestId = 0; // Non-zero: item begins a quest + // Gem socket slots (WotLK/TBC): 0=no socket; color mask: 1=Meta,2=Red,4=Yellow,8=Blue + std::array socketColor{}; + uint32_t socketBonus = 0; // enchantmentId of socket bonus; 0=none + uint32_t itemSetId = 0; // ItemSet.dbc entry; 0=not part of a set + // Requirement fields + uint32_t requiredSkill = 0; // SkillLine.dbc ID (0 = no skill required) + uint32_t requiredSkillRank = 0; // Minimum skill value + uint32_t allowableClass = 0; // Class bitmask (0 = all classes) + uint32_t allowableRace = 0; // Race bitmask (0 = all races) + uint32_t requiredReputationFaction = 0; // Faction.dbc ID (0 = none) + uint32_t requiredReputationRank = 0; // 0=Hated..8=Exalted bool valid = false; }; @@ -1632,8 +1720,10 @@ struct AttackerStateUpdateData { uint32_t blocked = 0; bool isValid() const { return attackerGuid != 0; } - bool isCrit() const { return (hitInfo & 0x200) != 0; } - bool isMiss() const { return (hitInfo & 0x10) != 0; } + bool isCrit() const { return (hitInfo & 0x0200) != 0; } + bool isMiss() const { return (hitInfo & 0x0010) != 0; } + bool isGlancing() const { return (hitInfo & 0x0800) != 0; } + bool isCrushing() const { return (hitInfo & 0x1000) != 0; } }; class AttackerStateUpdateParser { @@ -1713,7 +1803,10 @@ struct InitialSpellsData { class InitialSpellsParser { public: - static bool parse(network::Packet& packet, InitialSpellsData& data); + // vanillaFormat=true: Classic 1.12 uint16 spellId + uint16 slot (4 bytes/spell) + // vanillaFormat=false: TBC/WotLK uint32 spellId + uint16 unk (6 bytes/spell) + static bool parse(network::Packet& packet, InitialSpellsData& data, + bool vanillaFormat = false); }; /** CMSG_CAST_SPELL packet builder */ @@ -1770,7 +1863,7 @@ public: /** SMSG_SPELL_GO data (simplified) */ struct SpellGoMissEntry { uint64_t targetGuid = 0; - uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST + uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST 11=REFLECT }; struct SpellGoData { @@ -1783,6 +1876,7 @@ struct SpellGoData { std::vector hitTargets; uint8_t missCount = 0; std::vector missTargets; + uint64_t targetGuid = 0; ///< Primary target GUID from SpellCastTargets (0 = none/AoE) bool isValid() const { return spellId != 0; } }; @@ -1934,6 +2028,12 @@ public: static network::Packet build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0); }; +/** CMSG_OPEN_ITEM packet builder (for locked containers / lockboxes) */ +class OpenItemPacket { +public: + static network::Packet build(uint8_t bagIndex, uint8_t slotIndex); +}; + /** CMSG_AUTOEQUIP_ITEM packet builder */ class AutoEquipItemPacket { public: @@ -1947,6 +2047,13 @@ public: static network::Packet build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot); }; +/** CMSG_SPLIT_ITEM packet builder */ +class SplitItemPacket { +public: + static network::Packet build(uint8_t srcBag, uint8_t srcSlot, + uint8_t dstBag, uint8_t dstSlot, uint8_t count); +}; + /** CMSG_SWAP_INV_ITEM packet builder */ class SwapInvItemPacket { public: @@ -1970,7 +2077,10 @@ public: /** SMSG_LOOT_RESPONSE parser */ class LootResponseParser { public: - static bool parse(network::Packet& packet, LootResponseData& data); + // isWotlkFormat: true for WotLK (has trailing quest item section), + // false for Classic/TBC (no quest item section). + // Per-item size is 22 bytes across all expansions. + static bool parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat = true); }; // ============================================================ @@ -2068,6 +2178,14 @@ public: static network::Packet build(uint64_t npcGuid, uint32_t questId); }; +/** Reward item entry (shared by quest detail/offer windows) */ +struct QuestRewardItem { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t displayInfoId = 0; + uint32_t choiceSlot = 0; // Original reward slot index from server payload +}; + /** SMSG_QUESTGIVER_QUEST_DETAILS data (simplified) */ struct QuestDetailsData { uint64_t npcGuid = 0; @@ -2078,6 +2196,8 @@ struct QuestDetailsData { uint32_t suggestedPlayers = 0; uint32_t rewardMoney = 0; uint32_t rewardXp = 0; + std::vector rewardChoiceItems; // Player picks one of these + std::vector rewardItems; // These are always given }; /** SMSG_QUESTGIVER_QUEST_DETAILS parser */ @@ -2086,14 +2206,6 @@ public: static bool parse(network::Packet& packet, QuestDetailsData& data); }; -/** Reward item entry (shared by quest detail/offer windows) */ -struct QuestRewardItem { - uint32_t itemId = 0; - uint32_t count = 0; - uint32_t displayInfoId = 0; - uint32_t choiceSlot = 0; // Original reward slot index from server payload -}; - /** SMSG_QUESTGIVER_REQUEST_ITEMS data (turn-in progress check) */ struct QuestRequestItemsData { uint64_t npcGuid = 0; @@ -2169,6 +2281,7 @@ struct VendorItem { struct ListInventoryData { uint64_t vendorGuid = 0; std::vector items; + bool canRepair = false; // Set when vendor was opened via GOSSIP_OPTION_ARMORER bool isValid() const { return true; } }; @@ -2348,6 +2461,12 @@ public: static network::Packet build(); }; +/** CMSG_RECLAIM_CORPSE packet builder */ +class ReclaimCorpsePacket { +public: + static network::Packet build(uint64_t guid); +}; + /** CMSG_SPIRIT_HEALER_ACTIVATE packet builder */ class SpiritHealerActivatePacket { public: @@ -2418,7 +2537,7 @@ public: /** CMSG_MAIL_TAKE_ITEM packet builder */ class MailTakeItemPacket { public: - static network::Packet build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemIndex); + static network::Packet build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow); }; /** CMSG_MAIL_DELETE packet builder */ @@ -2642,5 +2761,48 @@ public: static bool parse(network::Packet& packet, AuctionCommandResult& data); }; +/** Pet Stable packet builders */ +class ListStabledPetsPacket { +public: + /** MSG_LIST_STABLED_PETS (CMSG): request list from stable master */ + static network::Packet build(uint64_t stableMasterGuid); +}; + +class StablePetPacket { +public: + /** CMSG_STABLE_PET: store active pet in the given stable slot (1-based) */ + static network::Packet build(uint64_t stableMasterGuid, uint8_t slot); +}; + +class UnstablePetPacket { +public: + /** CMSG_UNSTABLE_PET: retrieve a stabled pet by its server-side petNumber */ + static network::Packet build(uint64_t stableMasterGuid, uint32_t petNumber); +}; + +class PetRenamePacket { +public: + /** CMSG_PET_RENAME: rename the player's active pet. + * petGuid: the pet's object GUID (from GameHandler::getPetGuid()) + * name: new name (max 12 chars; server validates and may reject) + * isDeclined: 0 for non-Cyrillic locales (no declined name forms) */ + static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0); +}; + +/** CMSG_SET_TITLE packet builder. + * titleBit >= 0: activate the title with that bit index. + * titleBit == -1: clear the current title (show no title). */ +class SetTitlePacket { +public: + static network::Packet build(int32_t titleBit); +}; + +/** CMSG_ALTER_APPEARANCE – barber shop: change hair style, color, facial hair. + * Payload: uint32 hairStyle, uint32 hairColor, uint32 facialHair. */ +class AlterAppearancePacket { +public: + static network::Packet build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair); +}; + } // namespace game } // namespace wowee diff --git a/include/network/world_socket.hpp b/include/network/world_socket.hpp index 1d8f1d00..543e570f 100644 --- a/include/network/world_socket.hpp +++ b/include/network/world_socket.hpp @@ -7,7 +7,14 @@ #include "auth/vanilla_crypt.hpp" #include #include +#include #include +#include +#include +#include +#include +#include +#include namespace wowee { namespace network { @@ -66,6 +73,8 @@ public: */ void initEncryption(const std::vector& sessionKey, uint32_t build = 12340); + void tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason); + /** * Check if header encryption is enabled */ @@ -76,11 +85,25 @@ private: * Try to parse complete packets from receive buffer */ void tryParsePackets(); + void pumpNetworkIO(); + void dispatchQueuedPackets(); + void asyncPumpLoop(); + void startAsyncPump(); + void stopAsyncPump(); + void closeSocketNoJoin(); + void recordRecentPacket(bool outbound, uint16_t opcode, uint16_t payloadLen); + void dumpRecentPacketHistoryLocked(const char* reason, size_t bufferedBytes); socket_t sockfd = INVALID_SOCK; bool connected = false; bool encryptionEnabled = false; bool useVanillaCrypt = false; // true = XOR cipher, false = RC4 + bool useAsyncPump_ = true; + std::thread asyncPumpThread_; + std::atomic asyncPumpStop_{false}; + std::atomic asyncPumpRunning_{false}; + mutable std::mutex ioMutex_; + mutable std::mutex callbackMutex_; // WotLK RC4 ciphers for header encryption/decryption auth::RC4 encryptCipher; @@ -94,6 +117,8 @@ private: size_t receiveReadOffset_ = 0; // Optional reused packet queue (feature-gated) to reduce per-update allocations. std::vector parsedPacketsScratch_; + // Parsed packets waiting for callback dispatch; drained with a strict per-update budget. + std::deque pendingPacketCallbacks_; // Runtime-gated network optimization toggles (default off). bool useFastRecvAppend_ = false; @@ -105,6 +130,17 @@ private: // Debug-only tracing window for post-auth packet framing verification. int headerTracePacketsLeft = 0; + std::chrono::steady_clock::time_point packetTraceStart_{}; + std::chrono::steady_clock::time_point packetTraceUntil_{}; + std::string packetTraceReason_; + + struct RecentPacketTrace { + std::chrono::steady_clock::time_point when{}; + bool outbound = false; + uint16_t opcode = 0; + uint16_t payloadLen = 0; + }; + std::deque recentPacketHistory_; // Packet callback std::function packetCallback; diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index d3949f88..185ca653 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -165,6 +165,29 @@ struct M2ParticleEmitter { bool enabled = true; }; +// Ribbon emitter definition parsed from M2 (WotLK format) +struct M2RibbonEmitter { + int32_t ribbonId = 0; + uint32_t bone = 0; // Bone that drives the ribbon spine + glm::vec3 position{0.0f}; // Offset from bone pivot + + uint16_t textureIndex = 0; // First texture lookup index + uint16_t materialIndex = 0; // First material lookup index (blend mode) + + // Animated tracks + M2AnimationTrack colorTrack; // RGB 0..1 + M2AnimationTrack alphaTrack; // float 0..1 (stored as fixed16 on disk) + M2AnimationTrack heightAboveTrack; // Half-width above bone + M2AnimationTrack heightBelowTrack; // Half-width below bone + M2AnimationTrack visibilityTrack; // 0=hidden, 1=visible + + float edgesPerSecond = 15.0f; // How many edge points are generated per second + float edgeLifetime = 0.5f; // Seconds before edges expire + float gravity = 0.0f; // Downward pull on edges per s² + uint16_t textureRows = 1; + uint16_t textureCols = 1; +}; + // Complete M2 model structure struct M2Model { // Model metadata @@ -213,6 +236,9 @@ struct M2Model { // Particle emitters std::vector particleEmitters; + // Ribbon emitters + std::vector ribbonEmitters; + // Collision mesh (simplified geometry for physics) std::vector collisionVertices; std::vector collisionIndices; // 3 per triangle diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 34600b47..fae92812 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -44,6 +44,7 @@ public: } void reset(); + void resetAngles(); void teleportTo(const glm::vec3& pos); void setOnlineMode(bool online) { onlineMode = online; } @@ -82,6 +83,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; } @@ -89,8 +91,28 @@ public: // Movement callback for sending opcodes to server using MovementCallback = std::function; void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } + + // Callback invoked when the player stands up via local input (space/X/movement key + // while server-sitting), so the caller can send CMSG_STAND_STATE_CHANGE(0). + using StandUpCallback = std::function; + void setStandUpCallback(StandUpCallback cb) { standUpCallback_ = std::move(cb); } void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } + void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } + void setSwimSpeedOverride(float speed) { swimSpeedOverride_ = speed; } + void setSwimBackSpeedOverride(float speed) { swimBackSpeedOverride_ = speed; } + void setFlightSpeedOverride(float speed) { flightSpeedOverride_ = speed; } + void setFlightBackSpeedOverride(float speed) { flightBackSpeedOverride_ = speed; } + void setRunBackSpeedOverride(float speed) { runBackSpeedOverride_ = speed; } + // Server turn rate in rad/s (SMSG_FORCE_TURN_RATE_CHANGE); 0 = use WOW_TURN_SPEED default + void setTurnRateOverride(float rateRadS) { turnRateOverride_ = rateRadS; } + void setMovementRooted(bool rooted) { movementRooted_ = rooted; } + bool isMovementRooted() const { return movementRooted_; } + void setGravityDisabled(bool disabled) { gravityDisabled_ = disabled; } + void setFeatherFallActive(bool active) { featherFallActive_ = active; } + void setWaterWalkActive(bool active) { waterWalkActive_ = active; } + void setFlyingActive(bool active) { flyingActive_ = active; } + void setHoverActive(bool active) { hoverActive_ = active; } void setMounted(bool m) { mounted_ = m; } void setMountHeightOffset(float offset) { mountHeightOffset_ = offset; } void setExternalFollow(bool enabled) { externalFollow_ = enabled; } @@ -102,6 +124,18 @@ public: // Trigger mount jump (applies vertical velocity for physics hop) void triggerMountJump(); + // Apply server-driven knockback impulse. + // dir: render-space 2D direction unit vector (from vcos/vsin in packet) + // hspeed: horizontal speed magnitude (units/s) + // vspeed: raw packet vspeed field (server sends negative for upward launch) + void applyKnockBack(float vcos, float vsin, float hspeed, float vspeed); + + // Trigger a camera shake effect (e.g. from SMSG_CAMERA_SHAKE). + // magnitude: peak positional offset in world units + // frequency: oscillation frequency in Hz + // duration: shake duration in seconds + void triggerShake(float magnitude, float frequency, float duration); + // For first-person player hiding void setCharacterRenderer(class CharacterRenderer* cr, uint32_t playerId) { characterRenderer = cr; @@ -129,7 +163,7 @@ private: // Mouse settings float mouseSensitivity = 0.2f; - bool invertMouse = false; + bool invertMouse = true; bool mouseButtonDown = false; bool leftMouseDown = false; bool rightMouseDown = false; @@ -153,9 +187,10 @@ private: static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 20.0f; // Reduced for performance static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 20.0f; static constexpr float MIN_PITCH = -88.0f; // Look almost straight down - static constexpr float MAX_PITCH = 35.0f; // Limited upward look + static constexpr float MAX_PITCH = 88.0f; // Look almost straight up (WoW standard) 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; @@ -233,6 +268,8 @@ private: bool wasTurningRight = false; bool wasJumping = false; bool wasFalling = false; + bool wasAscending_ = false; // Space held while flyingActive_ + bool wasDescending_ = false; // X held while flyingActive_ bool moveForwardActive = false; bool moveBackwardActive = false; bool strafeLeftActive = false; @@ -240,6 +277,7 @@ private: // Movement callback MovementCallback movementCallback; + StandUpCallback standUpCallback_; // Movement speeds bool useWoWSpeed = false; @@ -258,8 +296,27 @@ private: return std::sqrt(2.0f * std::abs(MOUNT_GRAVITY) * MOUNT_JUMP_HEIGHT); } - // Server-driven run speed override (0 = use default WOW_RUN_SPEED) + // Server-driven speed overrides (0 = use hardcoded default) float runSpeedOverride_ = 0.0f; + float walkSpeedOverride_ = 0.0f; + float swimSpeedOverride_ = 0.0f; + float swimBackSpeedOverride_ = 0.0f; + float flightSpeedOverride_ = 0.0f; + float flightBackSpeedOverride_ = 0.0f; + float runBackSpeedOverride_ = 0.0f; + float turnRateOverride_ = 0.0f; // rad/s; 0 = WOW_TURN_SPEED default (π rad/s) + // Server-driven root state: when true, block all horizontal movement input. + bool movementRooted_ = false; + // Server-driven gravity disable (levitate/hover): skip gravity accumulation. + bool gravityDisabled_ = false; + // Server-driven feather fall: cap downward velocity to slow-fall terminal. + bool featherFallActive_ = false; + // Server-driven water walk: treat water surface as ground (don't swim). + bool waterWalkActive_ = false; + // Player-controlled flight (CAN_FLY + FLYING): 3D movement, no gravity. + bool flyingActive_ = false; + // Server-driven hover (HOVER flag): float at fixed height above ground. + bool hoverActive_ = false; bool mounted_ = false; float mountHeightOffset_ = 0.0f; bool externalMoving_ = false; @@ -311,6 +368,20 @@ private: float cachedFloorHeight_ = 0.0f; bool hasCachedFloor_ = false; static constexpr float COLLISION_CACHE_DISTANCE = 0.15f; // Re-check every 15cm + + // Server-driven knockback state. + // When the server sends SMSG_MOVE_KNOCK_BACK, we apply horizontal + vertical + // impulse here and let the normal physics loop (gravity, collision) resolve it. + bool knockbackActive_ = false; + glm::vec2 knockbackHorizVel_ = glm::vec2(0.0f); // render-space horizontal velocity (units/s) + // Horizontal velocity decays via WoW-like drag so the player doesn't slide forever. + static constexpr float KNOCKBACK_HORIZ_DRAG = 4.5f; // exponential decay rate (1/s) + + // Camera shake state (SMSG_CAMERA_SHAKE) + float shakeElapsed_ = 0.0f; + float shakeDuration_ = 0.0f; + float shakeMagnitude_ = 0.0f; + float shakeFrequency_ = 0.0f; }; } // namespace rendering diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index f516b3a4..6129940f 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -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& geosets); void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture); @@ -216,6 +217,10 @@ private: static glm::quat interpolateQuat(const pipeline::M2AnimationTrack& track, int seqIdx, float time); + // Attachment point lookup helper — shared by attachWeapon() and getAttachmentTransform() + bool findAttachmentBone(uint32_t modelId, uint32_t attachmentId, + uint16_t& outBoneIndex, glm::vec3& outOffset) const; + public: /** * Build a composited character skin texture by alpha-blending overlay @@ -291,7 +296,9 @@ private: std::unordered_map textureColorKeyBlackByPtr_; std::unordered_map compositeCache_; // key → texture for reuse std::unordered_set failedTextureCache_; // negative cache for budget exhaustion + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; // dedup warning logs + uint64_t textureLookupSerial_ = 0; size_t textureCacheBytes_ = 0; uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024; diff --git a/include/rendering/loading_screen.hpp b/include/rendering/loading_screen.hpp index afd134b9..a0ed13a5 100644 --- a/include/rendering/loading_screen.hpp +++ b/include/rendering/loading_screen.hpp @@ -30,6 +30,7 @@ public: void setProgress(float progress) { loadProgress = progress; } void setStatus(const std::string& status) { statusText = status; } + void setZoneName(const std::string& name) { zoneName = name; } // Must be set before initialize() for Vulkan texture upload void setVkContext(VkContext* ctx) { vkCtx = ctx; } @@ -53,6 +54,7 @@ private: float loadProgress = 0.0f; std::string statusText = "Loading..."; + std::string zoneName; int imageWidth = 0; int imageHeight = 0; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index e26583b5..c50dfb0f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -9,9 +9,11 @@ #include #include #include +#include #include #include #include +#include #include namespace wowee { @@ -127,7 +129,13 @@ struct M2ModelGPU { // Particle emitter data (kept from M2Model) std::vector particleEmitters; - std::vector particleTextures; // Resolved Vulkan textures per emitter + std::vector particleTextures; // Resolved Vulkan textures per emitter + std::vector particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc) + + // Ribbon emitter data (kept from M2Model) + std::vector ribbonEmitters; + std::vector ribbonTextures; // Resolved texture per ribbon emitter + std::vector ribbonTexSets; // Descriptor sets per ribbon emitter // Texture transform data for UV animation std::vector textureTransforms; @@ -179,6 +187,19 @@ struct M2Instance { std::vector emitterAccumulators; // fractional particle counter per emitter std::vector particles; + // Ribbon emitter state + struct RibbonEdge { + glm::vec3 worldPos; // Spine world position when this edge was born + glm::vec3 color; // Interpolated color at birth + float alpha; // Interpolated alpha at birth + float heightAbove;// Half-width above spine + float heightBelow;// Half-width below spine + float age; // Seconds since spawned + }; + // One deque of edges per ribbon emitter on this instance + std::vector> ribbonEdges; + std::vector ribbonEdgeAccumulators; // fractional edge counter per emitter + // Cached model flags (set at creation to avoid per-frame hash lookups) bool cachedHasAnimation = false; bool cachedDisableAnimation = false; @@ -294,9 +315,15 @@ public: */ void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + /** + * Render M2 ribbon emitters (spell trails / wing effects) + */ + void renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + void setInstancePosition(uint32_t instanceId, const glm::vec3& position); void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen); + float getInstanceAnimDuration(uint32_t instanceId) const; void removeInstance(uint32_t instanceId); void removeInstances(const std::vector& instanceIds); void setSkipCollision(uint32_t instanceId, bool skip); @@ -373,6 +400,11 @@ private: VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE; + // Ribbon pipelines (additive + alpha-blend) + VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; // Alpha-blend ribbons + VkPipeline ribbonAdditivePipeline_ = VK_NULL_HANDLE; // Additive ribbons + VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE; + // Descriptor set layouts VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1 VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2 @@ -384,6 +416,19 @@ private: static constexpr uint32_t MAX_MATERIAL_SETS = 8192; static constexpr uint32_t MAX_BONE_SETS = 8192; + // Dummy identity bone buffer + descriptor set for non-animated models. + // The pipeline layout declares set 2 (bones) and some drivers (Intel ANV) + // require all declared sets to be bound even when the shader doesn't access them. + ::VkBuffer dummyBoneBuffer_ = VK_NULL_HANDLE; + VmaAllocation dummyBoneAlloc_ = VK_NULL_HANDLE; + VkDescriptorSet dummyBoneSet_ = VK_NULL_HANDLE; + + // Dynamic ribbon vertex buffer (CPU-written triangle strip) + static constexpr size_t MAX_RIBBON_VERTS = 2048; // 9 floats each + ::VkBuffer ribbonVB_ = VK_NULL_HANDLE; + VmaAllocation ribbonVBAlloc_ = VK_NULL_HANDLE; + void* ribbonVBMapped_ = nullptr; + // Dynamic particle buffers ::VkBuffer smokeVB_ = VK_NULL_HANDLE; VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE; @@ -398,6 +443,9 @@ private: void* glowVBMapped_ = nullptr; std::unordered_map models; + // Grace period for model cleanup: track when a model first became instanceless. + // Models are only evicted after 60 seconds with no instances. + std::unordered_map modelUnusedSince_; std::vector instances; // O(1) dedup: key = (modelId, quantized x, quantized y, quantized z) → instanceId @@ -441,7 +489,9 @@ private: uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024; std::unordered_set failedTextureCache_; + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; + uint64_t textureLookupSerial_ = 0; uint32_t textureBudgetRejectWarnings_ = 0; std::unique_ptr whiteTexture_; std::unique_ptr glowTexture_; @@ -534,6 +584,7 @@ private: glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio); void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt); void updateParticles(M2Instance& inst, float dt); + void updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt); // Helper to allocate descriptor sets VkDescriptorSet allocateMaterialSet(); diff --git a/include/rendering/minimap.hpp b/include/rendering/minimap.hpp index ca7c5345..906f4666 100644 --- a/include/rendering/minimap.hpp +++ b/include/rendering/minimap.hpp @@ -7,6 +7,7 @@ #include #include #include +#include #include namespace wowee { @@ -73,7 +74,10 @@ private: bool trsParsed = false; // Tile texture cache: hash → VkTexture + // Evicted (FIFO) when the count of successfully-loaded tiles exceeds MAX_TILE_CACHE. + static constexpr size_t MAX_TILE_CACHE = 128; std::unordered_map> tileTextureCache; + std::deque tileInsertionOrder; // hashes of successfully loaded tiles, oldest first std::unique_ptr noDataTexture; // Composite render target (3x3 tiles = 768x768) diff --git a/include/rendering/quest_marker_renderer.hpp b/include/rendering/quest_marker_renderer.hpp index 2d6a73d3..a0d18776 100644 --- a/include/rendering/quest_marker_renderer.hpp +++ b/include/rendering/quest_marker_renderer.hpp @@ -35,8 +35,10 @@ public: * @param position World position (NPC base position) * @param markerType 0=available(!), 1=turnin(?), 2=incomplete(?) * @param boundingHeight NPC bounding height (optional, default 2.0f) + * @param grayscale 0 = full colour, 1 = desaturated grey (trivial/low-level quests) */ - void setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight = 2.0f); + void setMarker(uint64_t guid, const glm::vec3& position, int markerType, + float boundingHeight = 2.0f, float grayscale = 0.0f); /** * Remove a quest marker @@ -61,6 +63,7 @@ private: glm::vec3 position; int type; // 0=available, 1=turnin, 2=incomplete float boundingHeight = 2.0f; + float grayscale = 0.0f; // 0 = colour, 1 = desaturated (trivial quests) }; std::unordered_map markers_; diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 93bbed03..b53e87d1 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -39,6 +41,7 @@ class StarField; class Clouds; class LensFlare; class Weather; +class Lightning; class LightingManager; class SwimEffects; class MountDust; @@ -127,6 +130,7 @@ public: Clouds* getClouds() const { return skySystem ? skySystem->getClouds() : nullptr; } LensFlare* getLensFlare() const { return skySystem ? skySystem->getLensFlare() : nullptr; } Weather* getWeather() const { return weather.get(); } + Lightning* getLightning() const { return lightning.get(); } CharacterRenderer* getCharacterRenderer() const { return characterRenderer.get(); } WMORenderer* getWMORenderer() const { return wmoRenderer.get(); } M2Renderer* getM2Renderer() const { return m2Renderer.get(); } @@ -135,6 +139,7 @@ public: QuestMarkerRenderer* getQuestMarkerRenderer() const { return questMarkerRenderer.get(); } SkySystem* getSkySystem() const { return skySystem.get(); } const std::string& getCurrentZoneName() const { return currentZoneName; } + bool isPlayerIndoors() const { return playerIndoors_; } VkContext* getVkContext() const { return vkCtx; } VkDescriptorSetLayout getPerFrameSetLayout() const { return perFrameSetLayout; } VkRenderPass getShadowRenderPass() const { return shadowRenderPass; } @@ -150,6 +155,14 @@ public: void playEmote(const std::string& emoteName); void triggerLevelUpEffect(const glm::vec3& position); void cancelEmote(); + + // Screenshot capture — copies swapchain image to PNG file + bool captureScreenshot(const std::string& outputPath); + + // Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT) + // useImpactKit=false → CastKit path; useImpactKit=true → ImpactKit path + void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, + bool useImpactKit = false); bool isEmoteActive() const { return emoteActive; } static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); static uint32_t getEmoteDbcId(const std::string& emoteName); @@ -159,6 +172,7 @@ public: // Targeting support void setTargetPosition(const glm::vec3* pos); void setInCombat(bool combat) { inCombat_ = combat; } + void resetCombatVisualState(); bool isMoving() const; void triggerMeleeSwing(); void setEquippedWeaponType(uint32_t inventoryType) { equippedWeaponInvType_ = inventoryType; meleeAnimId = 0; } @@ -216,6 +230,7 @@ private: std::unique_ptr clouds; std::unique_ptr lensFlare; std::unique_ptr weather; + std::unique_ptr lightning; std::unique_ptr lightingManager; std::unique_ptr skySystem; // Coordinator for sky rendering std::unique_ptr swimEffects; @@ -271,6 +286,10 @@ public: float getShadowDistance() const { return shadowDistance_; } void setMsaaSamples(VkSampleCountFlagBits samples); + // FXAA post-process anti-aliasing (combinable with MSAA) + void setFXAAEnabled(bool enabled); + bool isFXAAEnabled() const { return fxaa_.enabled; } + // FSR (FidelityFX Super Resolution) upscaling void setFSREnabled(bool enabled); bool isFSREnabled() const { return fsr_.enabled; } @@ -315,10 +334,30 @@ private: glm::mat4 computeLightSpaceMatrix(); pipeline::AssetManager* cachedAssetManager = nullptr; + + // Spell visual effects — transient M2 instances spawned by SMSG_PLAY_SPELL_VISUAL/IMPACT + struct SpellVisualInstance { + uint32_t instanceId; + float elapsed; + float duration; // per-instance lifetime in seconds (from M2 anim or default) + }; + std::vector activeSpellVisuals_; + std::unordered_map spellVisualCastPath_; // visualId → cast M2 path + std::unordered_map spellVisualImpactPath_; // visualId → impact M2 path + std::unordered_map spellVisualModelIds_; // M2 path → M2Renderer modelId + std::unordered_set spellVisualFailedModels_; // modelIds that failed to load (negative cache) + uint32_t nextSpellVisualModelId_ = 999000; // Reserved range 999000-999799 + bool spellVisualDbcLoaded_ = false; + void loadSpellVisualDbc(); + void updateSpellVisuals(float deltaTime); + static constexpr float SPELL_VISUAL_MAX_DURATION = 5.0f; + static constexpr float SPELL_VISUAL_DEFAULT_DURATION = 2.0f; + uint32_t currentZoneId = 0; std::string currentZoneName; bool inTavern_ = false; bool inBlacksmith_ = false; + bool playerIndoors_ = false; // Cached WMO inside state for macro conditionals float musicSwitchCooldown_ = 0.0f; bool deferredWorldInitEnabled_ = true; bool deferredWorldInitPending_ = false; @@ -333,6 +372,10 @@ private: // Character animation state enum class CharAnimState { IDLE, WALK, RUN, JUMP_START, JUMP_MID, JUMP_END, SIT_DOWN, SITTING, EMOTE, SWIM_IDLE, SWIM, MELEE_SWING, MOUNT, CHARGE, COMBAT_IDLE }; CharAnimState charAnimState = CharAnimState::IDLE; + float locomotionStopGraceTimer_ = 0.0f; + bool locomotionWasSprinting_ = false; + uint32_t lastPlayerAnimRequest_ = UINT32_MAX; + bool lastPlayerAnimLoopRequest_ = true; void updateCharacterAnimation(); bool isFootstepAnimationState() const; bool shouldTriggerFootstepEvent(uint32_t animationId, float animationTimeMs, float animationDurationMs); @@ -369,6 +412,13 @@ private: void initOverlayPipeline(); void renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); + // Brightness (1.0 = default, <1 darkens, >1 brightens) + float brightness_ = 1.0f; +public: + void setBrightness(float b) { brightness_ = b; } + float getBrightness() const { return brightness_; } +private: + // FSR 1.0 upscaling state struct FSRState { bool enabled = false; @@ -398,6 +448,31 @@ private: void destroyFSRResources(); void renderFSRUpscale(); + // FXAA post-process state + struct FXAAState { + bool enabled = false; + bool needsRecreate = false; + + // Off-screen scene target (same resolution as swapchain — no scaling) + AllocatedImage sceneColor{}; // 1x resolved color target + AllocatedImage sceneDepth{}; // Depth (matches MSAA sample count) + AllocatedImage sceneMsaaColor{}; // MSAA color target (when MSAA > 1x) + AllocatedImage sceneDepthResolve{}; // Depth resolve (MSAA + depth resolve) + VkFramebuffer sceneFramebuffer = VK_NULL_HANDLE; + VkSampler sceneSampler = VK_NULL_HANDLE; + + // FXAA fullscreen pipeline + VkPipeline pipeline = VK_NULL_HANDLE; + VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; + VkDescriptorSetLayout descSetLayout = VK_NULL_HANDLE; + VkDescriptorPool descPool = VK_NULL_HANDLE; + VkDescriptorSet descSet = VK_NULL_HANDLE; + }; + FXAAState fxaa_; + bool initFXAAResources(); + void destroyFXAAResources(); + void renderFXAAPass(); + // FSR 2.2 temporal upscaling state struct FSR2State { bool enabled = false; @@ -609,6 +684,8 @@ private: bool terrainEnabled = true; bool terrainLoaded = false; + bool ghostMode_ = false; // set each frame from gameHandler->isPlayerGhost() + // CPU timing stats (last frame/update). double lastUpdateMs = 0.0; double lastRenderMs = 0.0; diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 290c45eb..ab6e881f 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -279,6 +279,9 @@ public: /** Process one ready tile (for loading screens with per-tile progress updates) */ void processOneReadyTile(); + /** Process a bounded batch of ready tiles with async GPU upload (no sync wait) */ + void processReadyTiles(); + private: /** * Get tile coordinates from GL world position @@ -317,10 +320,6 @@ private: */ void workerLoop(); - /** - * Main thread: poll for completed tiles and upload to GPU - */ - void processReadyTiles(); void ensureGroundEffectTablesLoaded(); void generateGroundClutterPlacements(std::shared_ptr& pending, std::unordered_set& preparedModelIds); @@ -395,6 +394,11 @@ private: std::unordered_set uploadedM2Ids_; std::mutex uploadedM2IdsMutex_; + // Cross-tile dedup for WMO doodad preparation on background workers + // (prevents re-parsing thousands of doodads when same WMO spans multiple tiles) + std::unordered_set preparedWmoUniqueIds_; + std::mutex preparedWmoUniqueIdsMutex_; + // Dedup set for doodad placements across tile boundaries std::unordered_set placedDoodadIds; diff --git a/include/rendering/terrain_renderer.hpp b/include/rendering/terrain_renderer.hpp index f4994792..5bc13252 100644 --- a/include/rendering/terrain_renderer.hpp +++ b/include/rendering/terrain_renderer.hpp @@ -171,7 +171,7 @@ private: // Descriptor pool for material sets VkDescriptorPool materialDescPool = VK_NULL_HANDLE; - static constexpr uint32_t MAX_MATERIAL_SETS = 16384; + static constexpr uint32_t MAX_MATERIAL_SETS = 65536; // Loaded terrain chunks std::vector chunks; diff --git a/include/rendering/vk_context.hpp b/include/rendering/vk_context.hpp index 154a4f98..654729b3 100644 --- a/include/rendering/vk_context.hpp +++ b/include/rendering/vk_context.hpp @@ -57,6 +57,15 @@ public: void pollUploadBatches(); // Check completed async uploads, free staging buffers void waitAllUploads(); // Block until all in-flight uploads complete + // Defer resource destruction until it is safe with multiple frames in flight. + // + // This queues work to run after the fence for the *current frame slot* has + // signaled the next time we enter beginFrame() for that slot (i.e. after + // MAX_FRAMES_IN_FLIGHT submissions). Use this for resources that may still + // be referenced by command buffers submitted in the previous frame(s), + // such as descriptor sets and buffers freed during streaming/unload. + void deferAfterFrameFence(std::function&& fn); + // Accessors VkInstance getInstance() const { return instance; } VkPhysicalDevice getPhysicalDevice() const { return physicalDevice; } @@ -173,6 +182,9 @@ private: }; std::vector inFlightBatches_; + void runDeferredCleanup(uint32_t frameIndex); + std::vector> deferredCleanup_[MAX_FRAMES_IN_FLIGHT]; + // Depth buffer (shared across all framebuffers) VkImage depthImage = VK_NULL_HANDLE; VkImageView depthImageView = VK_NULL_HANDLE; diff --git a/include/rendering/vk_frame_data.hpp b/include/rendering/vk_frame_data.hpp index 595b76ac..482e76e7 100644 --- a/include/rendering/vk_frame_data.hpp +++ b/include/rendering/vk_frame_data.hpp @@ -2,6 +2,7 @@ #include #include +#include namespace wowee { namespace rendering { @@ -25,5 +26,38 @@ struct GPUPushConstants { glm::mat4 model; }; +// Push constants for shadow rendering passes +struct ShadowPush { + glm::mat4 lightSpaceMatrix; + glm::mat4 model; +}; + +// Uniform buffer for shadow rendering parameters (matches shader std140 layout) +struct ShadowParamsUBO { + int32_t useBones; + int32_t useTexture; + int32_t alphaTest; + int32_t foliageSway; + float windTime; + float foliageMotionDamp; +}; + +// Timer utility for performance profiling queries +struct QueryTimer { + double* totalMs = nullptr; + uint32_t* callCount = nullptr; + std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); + QueryTimer(double* total, uint32_t* calls) : totalMs(total), callCount(calls) {} + ~QueryTimer() { + if (callCount) { + (*callCount)++; + } + if (totalMs) { + auto end = std::chrono::steady_clock::now(); + *totalMs += std::chrono::duration(end - start).count(); + } + } +}; + } // namespace rendering } // namespace wowee diff --git a/include/rendering/vk_utils.hpp b/include/rendering/vk_utils.hpp index 40847ad1..22631cc0 100644 --- a/include/rendering/vk_utils.hpp +++ b/include/rendering/vk_utils.hpp @@ -3,6 +3,8 @@ #include #include #include +#include +#include namespace wowee { namespace rendering { @@ -56,5 +58,25 @@ inline bool vkCheck(VkResult result, [[maybe_unused]] const char* msg) { return true; } +// Environment variable utility functions +inline size_t envSizeMBOrDefault(const char* name, size_t defMb) { + const char* v = std::getenv(name); + if (!v || !*v) return defMb; + char* end = nullptr; + unsigned long long mb = std::strtoull(v, &end, 10); + if (end == v || mb == 0) return defMb; + if (mb > (std::numeric_limits::max() / (1024ull * 1024ull))) return defMb; + return static_cast(mb); +} + +inline size_t envSizeOrDefault(const char* name, size_t defValue) { + const char* v = std::getenv(name); + if (!v || !*v) return defValue; + char* end = nullptr; + unsigned long long n = std::strtoull(v, &end, 10); + if (end == v || n == 0) return defValue; + return static_cast(n); +} + } // namespace rendering } // namespace wowee diff --git a/include/rendering/weather.hpp b/include/rendering/weather.hpp index b92c963d..3349526f 100644 --- a/include/rendering/weather.hpp +++ b/include/rendering/weather.hpp @@ -28,7 +28,8 @@ public: enum class Type { NONE, RAIN, - SNOW + SNOW, + STORM }; Weather(); diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 50261865..2431628e 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -69,6 +69,12 @@ public: */ bool loadModel(const pipeline::WMOModel& model, uint32_t id); + /** + * Check if a WMO model is currently resident in the renderer + * @param id WMO model identifier + */ + bool isModelLoaded(uint32_t id) const; + /** * Unload WMO model and free GPU resources * @param id WMO model identifier @@ -150,7 +156,8 @@ public: */ /** Pre-update mutable state (frame ID, material UBOs) on main thread before parallel render. */ void prepareRender(); - void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera); + void render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, + const glm::vec3* viewerPos = nullptr); /** * Initialize shadow pipeline (Phase 7) @@ -649,7 +656,7 @@ private: // Descriptor pool for material sets VkDescriptorPool materialDescPool_ = VK_NULL_HANDLE; - static constexpr uint32_t MAX_MATERIAL_SETS = 8192; + static constexpr uint32_t MAX_MATERIAL_SETS = 32768; // Texture cache (path -> VkTexture) struct TextureCacheEntry { @@ -664,7 +671,9 @@ private: uint64_t textureCacheCounter_ = 0; size_t textureCacheBudgetBytes_ = 8192ull * 1024 * 1024; // 8 GB default, overridden at init std::unordered_set failedTextureCache_; + std::unordered_map failedTextureRetryAt_; std::unordered_set loggedTextureLoadFails_; + uint64_t textureLookupSerial_ = 0; uint32_t textureBudgetRejectWarnings_ = 0; // Default white texture @@ -696,7 +705,7 @@ private: // Rendering state bool wireframeMode = false; bool frustumCulling = true; - bool portalCulling = false; // Disabled by default - needs debugging + bool portalCulling = true; // AABB transform bug fixed; conservative frustum test (no plane-side check) is visually safe bool distanceCulling = false; // Disabled - causes ground to disappear float maxGroupDistance = 500.0f; float maxGroupDistanceSq = 250000.0f; // maxGroupDistance^2 diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 89568209..fee98b6d 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,22 @@ class VkContext; class VkTexture; class VkRenderTarget; +/// Party member dot passed in from the UI layer for world map overlay. +struct WorldMapPartyDot { + glm::vec3 renderPos; ///< Position in render-space coordinates + uint32_t color; ///< RGBA packed color (IM_COL32 format) + std::string name; ///< Member name (shown as tooltip on hover) +}; + +/// Taxi (flight master) node passed from the UI layer for world map overlay. +struct WorldMapTaxiNode { + uint32_t id = 0; ///< TaxiNodes.dbc ID + uint32_t mapId = 0; ///< WoW internal map ID (0=EK,1=Kal,530=Outland,571=Northrend) + float wowX = 0, wowY = 0, wowZ = 0; ///< Canonical WoW coordinates + std::string name; ///< Node name (shown as tooltip) + bool known = false; ///< Player has discovered this node +}; + struct WorldMapZone { uint32_t wmaID = 0; uint32_t areaID = 0; // 0 = continent level @@ -43,10 +60,27 @@ public: void compositePass(VkCommandBuffer cmd); /// ImGui overlay — call INSIDE the main render pass (during ImGui frame). - void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + void render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, + float playerYawDeg = 0.0f); void setMapName(const std::string& name); void setServerExplorationMask(const std::vector& masks, bool hasData); + void setPartyDots(std::vector dots) { partyDots_ = std::move(dots); } + void setTaxiNodes(std::vector nodes) { taxiNodes_ = std::move(nodes); } + + /// Quest POI marker for world map overlay (from SMSG_QUEST_POI_QUERY_RESPONSE). + struct QuestPoi { + float wowX = 0, wowY = 0; ///< Canonical WoW coordinates (centroid of POI area) + std::string name; ///< Quest title + }; + void setQuestPois(std::vector pois) { questPois_ = std::move(pois); } + /// Set the player's corpse position for overlay rendering. + /// @param hasCorpse True when the player is a ghost with an unclaimed corpse on this map. + /// @param renderPos Corpse position in render-space coordinates. + void setCorpsePos(bool hasCorpse, glm::vec3 renderPos) { + hasCorpse_ = hasCorpse; + corpseRenderPos_ = renderPos; + } bool isOpen() const { return open; } void close() { open = false; } @@ -62,7 +96,8 @@ private: float& top, float& bottom) const; void loadZoneTextures(int zoneIdx); void requestComposite(int zoneIdx); - void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight); + void renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, + float playerYawDeg); void updateExploration(const glm::vec3& playerRenderPos); void zoomIn(const glm::vec3& playerRenderPos); void zoomOut(); @@ -113,10 +148,26 @@ private: // Texture storage (owns all VkTexture objects for zone tiles) std::vector> zoneTextures; + // Party member dots (set each frame from the UI layer) + std::vector partyDots_; + + // Taxi node markers (set each frame from the UI layer) + std::vector taxiNodes_; + int currentMapId_ = -1; ///< WoW map ID currently loaded (set in loadZonesFromDBC) + + // Quest POI markers (set each frame from the UI layer) + std::vector questPois_; + + // Corpse marker (ghost state — set each frame from the UI layer) + bool hasCorpse_ = false; + glm::vec3 corpseRenderPos_ = {}; + // Exploration / fog of war std::vector serverExplorationMask; bool hasServerExplorationMask = false; std::unordered_set exploredZones; + // Locally accumulated exploration (used as fallback when server mask is unavailable) + std::unordered_set locallyExploredZones_; }; } // namespace rendering diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 15e098e7..bf8ac8b5 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -8,6 +8,7 @@ #include "ui/quest_log_screen.hpp" #include "ui/spellbook_screen.hpp" #include "ui/talent_screen.hpp" +#include "ui/keybinding_manager.hpp" #include #include #include @@ -45,27 +46,113 @@ private: char chatInputBuffer[512] = ""; char whisperTargetBuffer[256] = ""; bool chatInputActive = false; - int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER + int selectedChatType = 0; // 0=SAY, 1=YELL, 2=PARTY, 3=GUILD, 4=WHISPER, ..., 10=CHANNEL int lastChatType = 0; // Track chat type changes + int selectedChannelIdx = 0; // Index into joinedChannels_ when selectedChatType==10 bool chatInputMoveCursorToEnd = false; + // Chat sent-message history (Up/Down arrow recall) + std::vector chatSentHistory_; + int chatHistoryIdx_ = -1; // -1 = not browsing history + + // Set to true by /stopmacro; checked in executeMacroText to halt remaining commands. + bool macroStopped_ = false; + + // Action bar error-flash: spellId → wall-clock time (seconds) when the flash ends. + // Populated by the SpellCastFailedCallback; queried during action bar button rendering. + std::unordered_map actionFlashEndTimes_; + + // Cached game handler for input callbacks (set each frame in render) + game::GameHandler* cachedGameHandler_ = nullptr; + + // Tab-completion state for slash commands and player names + std::string chatTabPrefix_; // prefix captured on first Tab press + std::vector chatTabMatches_; // matching command list + int chatTabMatchIdx_ = -1; // active match index (-1 = inactive) + + // Mention notification: plays a sound when the player's name appears in chat + size_t chatMentionSeenCount_ = 0; // how many messages have been scanned for mentions + // Chat tabs int activeChatTab_ = 0; struct ChatTab { std::string name; - uint32_t typeMask; // bitmask of ChatType values to show + uint64_t typeMask; // bitmask of ChatType values to show (64-bit: types go up to 84) }; std::vector chatTabs_; + std::vector chatTabUnread_; // unread message count per tab (0 = none) + size_t chatTabSeenCount_ = 0; // how many history messages have been processed void initChatTabs(); bool shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const; // UI state bool showEntityWindow = false; bool showChatWindow = true; - bool showNameplates_ = true; // V key toggles nameplates + bool showMinimap_ = true; // M key toggles minimap + bool showNameplates_ = true; // V key toggles enemy/NPC nameplates + bool showFriendlyNameplates_ = true; // Shift+V toggles friendly player nameplates + float nameplateScale_ = 1.0f; // Scale multiplier for nameplate bar dimensions + uint64_t nameplateCtxGuid_ = 0; // GUID of nameplate right-clicked (0 = none) + ImVec2 nameplateCtxPos_{}; // Screen position of nameplate right-click + uint32_t lastPlayerHp_ = 0; // Previous frame HP for damage flash detection + float damageFlashAlpha_ = 0.0f; // Screen edge flash intensity (fades to 0) + bool damageFlashEnabled_ = true; + bool lowHealthVignetteEnabled_ = true; // Persistent pulsing red vignette below 20% HP + float levelUpFlashAlpha_ = 0.0f; // Golden level-up burst effect (fades to 0) + uint32_t levelUpDisplayLevel_ = 0; // Level shown in level-up text + + // Raid Warning / Boss Emote big-text overlay (center-screen, fades after 5s) + struct RaidWarnEntry { + std::string text; + float age = 0.0f; + bool isBossEmote = false; // true = amber, false (raid warning) = red+yellow + static constexpr float LIFETIME = 5.0f; + }; + std::vector raidWarnEntries_; + bool raidWarnCallbackSet_ = false; + size_t raidWarnChatSeenCount_ = 0; // index into chat history for unread scan + + // UIErrorsFrame: WoW-style center-bottom error messages (spell fails, out of range, etc.) + struct UIErrorEntry { std::string text; float age = 0.0f; }; + std::vector uiErrors_; + bool uiErrorCallbackSet_ = false; + static constexpr float kUIErrorLifetime = 2.5f; + bool castFailedCallbackSet_ = false; + static constexpr float kActionFlashDuration = 0.5f; // seconds for error-red overlay to fade + + // Reputation change toast: brief colored slide-in below minimap + struct RepToastEntry { std::string factionName; int32_t delta = 0; int32_t standing = 0; float age = 0.0f; }; + std::vector repToasts_; + bool repChangeCallbackSet_ = false; + static constexpr float kRepToastLifetime = 3.5f; + + // Quest completion toast: slide-in when a quest is turned in + struct QuestCompleteToastEntry { uint32_t questId = 0; std::string title; float age = 0.0f; }; + std::vector questCompleteToasts_; + bool questCompleteCallbackSet_ = false; + static constexpr float kQuestCompleteToastLifetime = 4.0f; + + // Zone entry toast: brief banner when entering a new zone + struct ZoneToastEntry { std::string zoneName; float age = 0.0f; }; + std::vector zoneToasts_; + + struct AreaTriggerToast { std::string text; float age = 0.0f; }; + std::vector areaTriggerToasts_; + void renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler); + std::string lastKnownZone_; + static constexpr float kZoneToastLifetime = 3.0f; + + // Death screen: elapsed time since the death dialog first appeared + float deathElapsed_ = 0.0f; + bool deathTimerRunning_ = false; + // WoW forces release after ~6 minutes; show countdown until then + static constexpr float kForcedReleaseSec = 360.0f; + void renderZoneToasts(float deltaTime); bool showPlayerInfo = false; bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; + bool showRaidFrames_ = true; // F key toggles raid/party frames + bool showWorldMap_ = false; // W key toggles world map std::string selectedGuildMember_; bool showGuildNoteEdit_ = false; bool editingOfficerNote_ = false; @@ -78,9 +165,15 @@ private: bool showAddRankModal_ = false; bool refocusChatInput = false; bool vendorBagsOpened_ = false; // Track if bags were auto-opened for current vendor session + bool chatScrolledUp_ = false; // true when user has scrolled above the latest messages + bool chatForceScrollToBottom_ = false; // set to true to jump to bottom next frame bool chatWindowLocked = true; ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; + ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default + ImVec2 questTrackerSize_ = ImVec2(220.0f, 200.0f); // saved size + float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default + bool questTrackerPosInit_ = false; bool showEscapeMenu = false; bool showEscapeSettingsNotice = false; bool showSettingsWindow = false; @@ -90,7 +183,8 @@ private: int pendingResIndex = 0; bool pendingShadows = true; float pendingShadowDistance = 300.0f; - bool pendingWaterRefraction = false; + bool pendingWaterRefraction = true; + int pendingBrightness = 50; // 0-100, maps to 0.0-2.0 (50 = 1.0 default) int pendingMasterVolume = 100; int pendingMusicVolume = 30; int pendingAmbientVolume = 100; @@ -105,15 +199,37 @@ private: float pendingMouseSensitivity = 0.2f; bool pendingInvertMouse = false; bool pendingExtendedZoom = false; + float pendingFov = 70.0f; // degrees, default matches WoW's ~70° horizontal FOV int pendingUiOpacity = 65; bool pendingMinimapRotate = false; bool pendingMinimapSquare = false; bool pendingMinimapNpcDots = false; + bool pendingShowLatencyMeter = true; bool pendingSeparateBags = true; + bool pendingShowKeyring = true; bool pendingAutoLoot = false; + bool pendingAutoSellGrey = false; + bool pendingAutoRepair = false; + + // Keybinding customization + int pendingRebindAction = -1; // -1 = not rebinding, otherwise action index + bool awaitingKeyPress = false; + // Macro editor popup state + uint32_t macroEditorId_ = 0; // macro index being edited + bool macroEditorOpen_ = false; // deferred OpenPopup flag + char macroEditorBuf_[256] = {}; // edit buffer bool pendingUseOriginalSoundtrack = true; + bool pendingShowActionBar2 = true; // Show second action bar above main bar + float pendingActionBarScale = 1.0f; // Multiplier for action bar slot size (0.5–1.5) + float pendingActionBar2OffsetX = 0.0f; // Horizontal offset from default center position + float pendingActionBar2OffsetY = 0.0f; // Vertical offset from default (above bar 1) + bool pendingShowRightBar = false; // Right-edge vertical action bar (bar 3, slots 24-35) + bool pendingShowLeftBar = false; // Left-edge vertical action bar (bar 4, slots 36-47) + float pendingRightBarOffsetY = 0.0f; // Vertical offset from screen center + float pendingLeftBarOffsetY = 0.0f; // Vertical offset from screen center int pendingGroundClutterDensity = 100; int pendingAntiAliasing = 0; // 0=Off, 1=2x, 2=4x, 3=8x + bool pendingFXAA = false; // FXAA post-process (combinable with MSAA) bool pendingNormalMapping = true; // on by default float pendingNormalMapStrength = 0.8f; // 0.0-2.0 bool pendingPOM = true; // on by default @@ -128,14 +244,27 @@ private: bool pendingAMDFramegen = false; bool fsrSettingsApplied_ = false; + // Graphics quality presets + enum class GraphicsPreset : int { + CUSTOM = 0, + LOW = 1, + MEDIUM = 2, + HIGH = 3, + ULTRA = 4 + }; + GraphicsPreset currentGraphicsPreset = GraphicsPreset::CUSTOM; + GraphicsPreset pendingGraphicsPreset = GraphicsPreset::CUSTOM; + // UI element transparency (0.0 = fully transparent, 1.0 = fully opaque) float uiOpacity_ = 0.65f; bool minimapRotate_ = false; bool minimapSquare_ = false; bool minimapNpcDots_ = false; + bool showLatencyMeter_ = true; // Show server latency indicator bool minimapSettingsApplied_ = false; bool volumeSettingsApplied_ = false; // True once saved volume settings applied to audio managers bool msaaSettingsApplied_ = false; // True once saved MSAA setting applied to renderer + bool fxaaSettingsApplied_ = false; // True once saved FXAA setting applied to renderer bool waterRefractionApplied_ = false; bool normalMapSettingsApplied_ = false; // True once saved normal map/POM settings applied @@ -162,6 +291,7 @@ private: * Send chat message */ void sendChatMessage(game::GameHandler& gameHandler); + void executeMacroText(game::GameHandler& gameHandler, const std::string& macroText); /** * Get chat type name @@ -182,11 +312,13 @@ private: * Render target frame */ void renderTargetFrame(game::GameHandler& gameHandler); + void renderFocusFrame(game::GameHandler& gameHandler); /** * Render pet frame (below player frame when player has an active pet) */ void renderPetFrame(game::GameHandler& gameHandler); + void renderTotemFrame(game::GameHandler& gameHandler); /** * Process targeting input (Tab, Escape, click) @@ -205,17 +337,26 @@ private: // ---- New UI renders ---- void renderActionBar(game::GameHandler& gameHandler); + void renderStanceBar(game::GameHandler& gameHandler); void renderBagBar(game::GameHandler& gameHandler); void renderXpBar(game::GameHandler& gameHandler); + void renderRepBar(game::GameHandler& gameHandler); void renderCastBar(game::GameHandler& gameHandler); void renderMirrorTimers(game::GameHandler& gameHandler); + void renderCooldownTracker(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); + void renderRaidWarningOverlay(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); void renderBossFrames(game::GameHandler& gameHandler); + void renderUIErrors(game::GameHandler& gameHandler, float deltaTime); + void renderRepToasts(float deltaTime); + void renderQuestCompleteToasts(float deltaTime); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); + void renderDuelCountdown(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); void renderTradeRequestPopup(game::GameHandler& gameHandler); + void renderTradeWindow(game::GameHandler& gameHandler); void renderSummonRequestPopup(game::GameHandler& gameHandler); void renderSharedQuestPopup(game::GameHandler& gameHandler); void renderItemTextWindow(game::GameHandler& gameHandler); @@ -228,18 +369,29 @@ private: void renderQuestOfferRewardWindow(game::GameHandler& gameHandler); void renderVendorWindow(game::GameHandler& gameHandler); void renderTrainerWindow(game::GameHandler& gameHandler); + void renderBarberShopWindow(game::GameHandler& gameHandler); + void renderStableWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); + void renderLogoutCountdown(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); + void renderTalentWipeConfirmDialog(game::GameHandler& gameHandler); + void renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); + void applyGraphicsPreset(GraphicsPreset preset); + void updateGraphicsPresetFromCurrentSettings(); void renderQuestMarkers(game::GameHandler& gameHandler); void renderMinimapMarkers(game::GameHandler& gameHandler); void renderQuestObjectiveTracker(game::GameHandler& gameHandler); void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler); + void renderBgInvitePopup(game::GameHandler& gameHandler); + void renderBfMgrInvitePopup(game::GameHandler& gameHandler); + void renderLfgProposalPopup(game::GameHandler& gameHandler); + void renderLfgRoleCheckPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); @@ -250,6 +402,9 @@ private: void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); void renderBattlegroundScore(game::GameHandler& gameHandler); + void renderDPSMeter(game::GameHandler& gameHandler); + void renderDurabilityWarning(game::GameHandler& gameHandler); + void takeScreenshot(game::GameHandler& gameHandler); /** * Inventory screen @@ -272,6 +427,23 @@ private: bool spellIconDbLoaded_ = false; VkDescriptorSet getSpellIcon(uint32_t spellId, pipeline::AssetManager* am); + // ItemExtendedCost.dbc cache: extendedCostId -> cost details + struct ExtendedCostEntry { + uint32_t honorPoints = 0; + uint32_t arenaPoints = 0; + uint32_t itemId[5] = {}; + uint32_t itemCount[5] = {}; + }; + std::unordered_map extendedCostCache_; + bool extendedCostDbLoaded_ = false; + void loadExtendedCostDBC(); + std::string formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler); + + // Macro cooldown cache: maps macro ID → resolved primary spell ID (0 = no spell found) + std::unordered_map macroPrimarySpellCache_; + size_t macroCacheSpellCount_ = 0; // invalidates cache when spell list changes + uint32_t resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler); + // Death Knight rune bar: client-predicted fill (0.0=depleted, 1.0=ready) for smooth animation float runeClientFill_[6] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f}; @@ -285,11 +457,63 @@ private: int bagBarPickedSlot_ = -1; // Visual drag in progress (-1 = none) int bagBarDragSource_ = -1; // Mouse pressed on this slot, waiting for drag or click (-1 = none) + // Who Results window + bool showWhoWindow_ = false; + void renderWhoWindow(game::GameHandler& gameHandler); + + // Combat Log window + bool showCombatLog_ = false; + void renderCombatLog(game::GameHandler& gameHandler); + // Instance Lockouts window bool showInstanceLockouts_ = false; // Dungeon Finder state bool showDungeonFinder_ = false; + + // Achievements window + bool showAchievementWindow_ = false; + char achievementSearchBuf_[128] = {}; + void renderAchievementWindow(game::GameHandler& gameHandler); + + // Skills / Professions window (K key) + bool showSkillsWindow_ = false; + void renderSkillsWindow(game::GameHandler& gameHandler); + + // Titles window + bool showTitlesWindow_ = false; + void renderTitlesWindow(game::GameHandler& gameHandler); + + // Equipment Set Manager window + bool showEquipSetWindow_ = false; + void renderEquipSetWindow(game::GameHandler& gameHandler); + + // GM Ticket window + bool showGmTicketWindow_ = false; + bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query + char gmTicketBuf_[2048] = {}; + void renderGmTicketWindow(game::GameHandler& gameHandler); + + // Pet rename modal (triggered from pet frame context menu) + bool petRenameOpen_ = false; + char petRenameBuf_[16] = {}; + + // Inspect window + bool showInspectWindow_ = false; + void renderInspectWindow(game::GameHandler& gameHandler); + + // Readable text window (books / scrolls / notes) + bool showBookWindow_ = false; + int bookCurrentPage_ = 0; + void renderBookWindow(game::GameHandler& gameHandler); + + // Threat window + bool showThreatWindow_ = false; + void renderThreatWindow(game::GameHandler& gameHandler); + + // BG scoreboard window + bool showBgScoreboard_ = false; + void renderBgScoreboard(game::GameHandler& gameHandler); uint8_t lfgRoles_ = 0x08; // default: DPS (0x02=tank, 0x04=healer, 0x08=dps) uint32_t lfgSelectedDungeon_ = 861; // default: random dungeon (entry 861 = Random Dungeon WotLK) @@ -320,6 +544,8 @@ private: }; std::vector chatBubbles_; bool chatBubbleCallbackSet_ = false; + bool levelUpCallbackSet_ = false; + bool achievementCallbackSet_ = false; // Mail compose state char mailRecipientBuffer_[256] = ""; @@ -327,6 +553,30 @@ private: char mailBodyBuffer_[2048] = ""; int mailComposeMoney_[3] = {0, 0, 0}; // gold, silver, copper + // Vendor search filter + char vendorSearchFilter_[128] = ""; + + // Vendor purchase confirmation for expensive items + bool vendorConfirmOpen_ = false; + uint64_t vendorConfirmGuid_ = 0; + uint32_t vendorConfirmItemId_ = 0; + uint32_t vendorConfirmSlot_ = 0; + uint32_t vendorConfirmQty_ = 1; + uint32_t vendorConfirmPrice_ = 0; + std::string vendorConfirmItemName_; + + // Barber shop UI state + int barberHairStyle_ = 0; + int barberHairColor_ = 0; + int barberFacialHair_ = 0; + int barberOrigHairStyle_ = 0; + int barberOrigHairColor_ = 0; + int barberOrigFacialHair_ = 0; + bool barberInitialized_ = false; + + // Trainer search filter + char trainerSearchFilter_[128] = ""; + // Auction house UI state char auctionSearchName_[256] = ""; int auctionLevelMin_ = 0; @@ -340,6 +590,7 @@ private: uint32_t auctionBrowseOffset_ = 0; // Pagination offset for browse results int auctionItemClass_ = -1; // Item class filter (-1 = All) int auctionItemSubClass_ = -1; // Item subclass filter (-1 = All) + bool auctionUsableOnly_ = false; // Filter to items usable by current class/level // Guild bank money input int guildBankMoneyInput_[3] = {0, 0, 0}; // gold, silver, copper @@ -349,27 +600,125 @@ private: bool leftClickWasPress_ = false; // Level-up ding animation - static constexpr float DING_DURATION = 3.0f; + static constexpr float DING_DURATION = 4.0f; float dingTimer_ = 0.0f; uint32_t dingLevel_ = 0; + uint32_t dingHpDelta_ = 0; + uint32_t dingManaDelta_ = 0; + uint32_t dingStats_[5] = {}; // str/agi/sta/int/spi deltas void renderDingEffect(); // Achievement toast banner static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; float achievementToastTimer_ = 0.0f; uint32_t achievementToastId_ = 0; + std::string achievementToastName_; void renderAchievementToast(); + // Area discovery toast ("Discovered! +XP XP") + static constexpr float DISCOVERY_TOAST_DURATION = 4.0f; + float discoveryToastTimer_ = 0.0f; + std::string discoveryToastName_; + uint32_t discoveryToastXP_ = 0; + bool areaDiscoveryCallbackSet_ = false; + void renderDiscoveryToast(); + + // Whisper toast — brief overlay at screen top when a whisper arrives while chat is not focused + struct WhisperToastEntry { + std::string sender; + std::string preview; // first ~60 chars of message + float age = 0.0f; + }; + static constexpr float WHISPER_TOAST_DURATION = 5.0f; + std::vector whisperToasts_; + size_t whisperSeenCount_ = 0; // how many chat entries have been scanned for whispers + void renderWhisperToasts(); + + // Quest objective progress toast ("Quest: X/Y") + struct QuestProgressToastEntry { + std::string questTitle; + std::string objectiveName; + uint32_t current = 0; + uint32_t required = 0; + float age = 0.0f; + }; + static constexpr float QUEST_TOAST_DURATION = 4.0f; + std::vector questToasts_; + bool questProgressCallbackSet_ = false; + void renderQuestProgressToasts(); + + // Nearby player level-up toast (" is now level X!") + struct PlayerLevelUpToastEntry { + uint64_t guid = 0; + std::string playerName; // resolved lazily at render time + uint32_t newLevel = 0; + float age = 0.0f; + }; + static constexpr float PLAYER_LEVELUP_TOAST_DURATION = 4.0f; + std::vector playerLevelUpToasts_; + bool otherPlayerLevelUpCallbackSet_ = false; + void renderPlayerLevelUpToasts(game::GameHandler& gameHandler); + + // PvP honor credit toast ("+N Honor" shown when an honorable kill is credited) + struct PvpHonorToastEntry { + uint32_t honor = 0; + uint32_t victimRank = 0; // 0 = unranked / not available + float age = 0.0f; + }; + static constexpr float PVP_HONOR_TOAST_DURATION = 3.5f; + std::vector pvpHonorToasts_; + bool pvpHonorCallbackSet_ = false; + void renderPvpHonorToasts(); + + // Item loot toast — quality-coloured popup when an item is received + struct ItemLootToastEntry { + uint32_t itemId = 0; + uint32_t count = 0; + uint32_t quality = 1; // 0=grey,1=white,2=green,3=blue,4=purple,5=orange + std::string name; + float age = 0.0f; + }; + static constexpr float ITEM_LOOT_TOAST_DURATION = 3.0f; + std::vector itemLootToasts_; + bool itemLootCallbackSet_ = false; + void renderItemLootToasts(); + + // Resurrection flash: brief "You have been resurrected!" overlay on ghost→alive transition + float resurrectFlashTimer_ = 0.0f; + static constexpr float kResurrectFlashDuration = 3.0f; + bool ghostStateCallbackSet_ = false; + bool appearanceCallbackSet_ = false; + bool ghostOpacityStateKnown_ = false; + bool ghostOpacityLastState_ = false; + uint32_t ghostOpacityLastInstanceId_ = 0; + void renderResurrectFlash(); + // Zone discovery text ("Entering: ") static constexpr float ZONE_TEXT_DURATION = 5.0f; float zoneTextTimer_ = 0.0f; std::string zoneTextName_; std::string lastKnownZoneName_; - void renderZoneText(); + uint32_t lastKnownWorldStateZoneId_ = 0; + void renderZoneText(game::GameHandler& gameHandler); + void renderWeatherOverlay(game::GameHandler& gameHandler); + + // Cooldown tracker + bool showCooldownTracker_ = false; + + // DPS / HPS meter + bool showDPSMeter_ = false; + float dpsCombatAge_ = 0.0f; // seconds in current combat (for accurate early-combat DPS) + bool dpsWasInCombat_ = false; + float dpsEncounterDamage_ = 0.0f; // total player damage this combat + float dpsEncounterHeal_ = 0.0f; // total player healing this combat + size_t dpsLogSeenCount_ = 0; // log entries already scanned public: - void triggerDing(uint32_t newLevel); - void triggerAchievementToast(uint32_t achievementId); + void triggerDing(uint32_t newLevel, uint32_t hpDelta = 0, uint32_t manaDelta = 0, + uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, + uint32_t intel = 0, uint32_t spi = 0); + void triggerAchievementToast(uint32_t achievementId, std::string name = {}); + void openDungeonFinder() { showDungeonFinder_ = true; } }; } // namespace ui diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index a0a19386..dca0e5a5 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -39,6 +39,8 @@ public: bool isSeparateBags() const { return separateBags_; } void toggleCompactBags() { compactBags_ = !compactBags_; } bool isCompactBags() const { return compactBags_; } + void setShowKeyring(bool show) { showKeyring_ = show; } + bool isShowKeyring() const { return showKeyring_; } bool isBackpackOpen() const { return backpackOpen_; } bool isBagOpen(int idx) const { return idx >= 0 && idx < 4 ? bagOpen_[idx] : false; } @@ -79,6 +81,7 @@ private: bool bKeyWasDown = false; bool separateBags_ = true; bool compactBags_ = false; + bool showKeyring_ = true; bool backpackOpen_ = false; std::array bagOpen_{}; bool cKeyWasDown = false; @@ -96,6 +99,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); + void renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0); private: // Character model preview @@ -147,7 +151,9 @@ private: int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper); void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); - void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0); + void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, + const int32_t* serverStats = nullptr, const int32_t* serverResists = nullptr, + const game::GameHandler* gh = nullptr); void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, @@ -155,7 +161,7 @@ private: SlotKind kind, int backpackIndex, game::EquipSlot equipSlot, int bagIndex = -1, int bagSlotIndex = -1); - void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr); + void renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory = nullptr, uint64_t itemGuid = 0); // Held item helpers void pickupFromBackpack(game::Inventory& inv, int index); @@ -169,11 +175,29 @@ private: void renderHeldItem(); bool bagHasAnyItems(const game::Inventory& inventory, int bagIndex) const; - // Drop confirmation + // Drop confirmation (drag-outside-window destroy) bool dropConfirmOpen_ = false; int dropBackpackIndex_ = -1; std::string dropItemName_; + // Destroy confirmation (Shift+right-click destroy) + bool destroyConfirmOpen_ = false; + uint8_t destroyBag_ = 0xFF; + uint8_t destroySlot_ = 0; + uint8_t destroyCount_ = 1; + std::string destroyItemName_; + + // Stack split popup state + bool splitConfirmOpen_ = false; + uint8_t splitBag_ = 0xFF; + uint8_t splitSlot_ = 0; + int splitMax_ = 1; + int splitCount_ = 1; + std::string splitItemName_; + + // Pending chat item link from shift-click + std::string pendingChatItemLink_; + public: static ImVec4 getQualityColor(game::ItemQuality quality); @@ -188,6 +212,13 @@ public: /// Drop the currently held item into a specific equipment slot. /// Returns true if the drop was accepted and consumed. bool dropHeldItemToEquipSlot(game::Inventory& inv, game::EquipSlot slot); + /// Returns a WoW item link string if the user shift-clicked a bag item, then clears it. + std::string getAndClearPendingChatLink() { + std::string out = std::move(pendingChatItemLink_); + pendingChatItemLink_.clear(); + return out; + } + /// Drop the currently held item into a bank slot via CMSG_SWAP_ITEM. void dropIntoBankSlot(game::GameHandler& gh, uint8_t dstBag, uint8_t dstSlot); /// Pick up an item from main bank slot (click-and-hold from bank window). diff --git a/include/ui/keybinding_manager.hpp b/include/ui/keybinding_manager.hpp new file mode 100644 index 00000000..3c67b125 --- /dev/null +++ b/include/ui/keybinding_manager.hpp @@ -0,0 +1,90 @@ +#ifndef WOWEE_KEYBINDING_MANAGER_HPP +#define WOWEE_KEYBINDING_MANAGER_HPP + +#include +#include +#include +#include + +namespace wowee::ui { + +/** + * Manages keybinding configuration for in-game actions. + * Supports loading/saving from config files and runtime rebinding. + */ +class KeybindingManager { +public: + enum class Action { + TOGGLE_CHARACTER_SCREEN, + TOGGLE_INVENTORY, + TOGGLE_BAGS, + TOGGLE_SPELLBOOK, + TOGGLE_TALENTS, + TOGGLE_QUESTS, + TOGGLE_MINIMAP, + TOGGLE_SETTINGS, + TOGGLE_CHAT, + TOGGLE_GUILD_ROSTER, + TOGGLE_DUNGEON_FINDER, + TOGGLE_WORLD_MAP, + TOGGLE_NAMEPLATES, + TOGGLE_RAID_FRAMES, + TOGGLE_ACHIEVEMENTS, + TOGGLE_SKILLS, + ACTION_COUNT + }; + + static KeybindingManager& getInstance(); + + /** + * Check if an action's keybinding was just pressed. + * Uses ImGui::IsKeyPressed() internally with the bound key. + */ + bool isActionPressed(Action action, bool repeat = false); + + /** + * Get the currently bound key for an action. + */ + ImGuiKey getKeyForAction(Action action) const; + + /** + * Rebind an action to a different key. + */ + void setKeyForAction(Action action, ImGuiKey key); + + /** + * Reset all keybindings to defaults. + */ + void resetToDefaults(); + + /** + * Load keybindings from config file. + */ + void loadFromConfigFile(const std::string& filePath); + + /** + * Save keybindings to config file. + */ + void saveToConfigFile(const std::string& filePath) const; + + /** + * Get human-readable name for an action. + */ + static const char* getActionName(Action action); + + /** + * Get all actions for iteration. + */ + static constexpr int getActionCount() { return static_cast(Action::ACTION_COUNT); } + +private: + KeybindingManager(); + + std::unordered_map bindings_; // action -> key + + void initializeDefaults(); +}; + +} // namespace wowee::ui + +#endif // WOWEE_KEYBINDING_MANAGER_HPP diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index d86abedc..0bb791e0 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -7,9 +7,11 @@ namespace wowee { namespace ui { +class InventoryScreen; + class QuestLogScreen { public: - void render(game::GameHandler& gameHandler); + void render(game::GameHandler& gameHandler, InventoryScreen& invScreen); bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } @@ -29,6 +31,10 @@ private: uint32_t lastDetailRequestQuestId_ = 0; double lastDetailRequestAt_ = 0.0; std::unordered_set questDetailQueryNoResponse_; + // Search / filter + char questSearchFilter_[64] = {}; + // 0=all, 1=active only, 2=complete only + int questFilterMode_ = 0; }; }} // namespace wowee::ui diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index a6944972..2bc0f866 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -25,6 +25,7 @@ struct SpellInfo { uint32_t manaCost = 0; // Mana cost uint32_t powerType = 0; // 0=mana, 1=rage, 2=focus, 3=energy uint32_t rangeIndex = 0; // Range index from SpellRange.dbc + uint32_t schoolMask = 0; // School bitmask (1=phys,2=holy,4=fire,8=nature,16=frost,32=shadow,64=arcane) bool isPassive() const { return (attributes & 0x40) != 0; } }; @@ -43,11 +44,33 @@ public: // Spell name lookup — triggers DBC load if needed, used by action bar tooltips std::string lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager); + // Rich tooltip — renders a full spell tooltip (inside an already-open BeginTooltip block). + // Triggers DBC load if needed. Returns true if spell data was found. + bool renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, + pipeline::AssetManager* assetManager); + // Drag-and-drop state for action bar assignment bool isDraggingSpell() const { return draggingSpell_; } uint32_t getDragSpellId() const { return dragSpellId_; } void consumeDragSpell() { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; } + /// Returns the max range in yards for a spell (0 if self-cast, unknown, or melee). + /// Triggers DBC load if needed. Used by the action bar for out-of-range tinting. + uint32_t getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager); + + /// Returns the power cost and type for a spell (cost=0 if unknown/free). + /// powerType: 0=mana, 1=rage, 2=focus, 3=energy, 6=runic power. + /// Triggers DBC load if needed. Used by the action bar for insufficient-power tinting. + void getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager, + uint32_t& outCost, uint32_t& outPowerType); + + /// Returns a WoW spell link string if the user shift-clicked a spell, then clears it. + std::string getAndClearPendingChatLink() { + std::string out = std::move(pendingChatSpellLink_); + pendingChatSpellLink_.clear(); + return out; + } + private: bool open = false; bool pKeyWasDown = false; @@ -81,6 +104,9 @@ private: uint32_t dragSpellId_ = 0; VkDescriptorSet dragSpellIconTex_ = VK_NULL_HANDLE; + // Pending chat spell link from shift-click + std::string pendingChatSpellLink_; + void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); void loadSkillLineDBCs(pipeline::AssetManager* assetManager); @@ -88,8 +114,8 @@ private: VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); const SpellInfo* getSpellInfo(uint32_t spellId) const; - // Tooltip rendering helper - void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler); + // Tooltip rendering helper (showUsageHints=false when called from action bar) + void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints = true); }; } // namespace ui diff --git a/include/ui/talent_screen.hpp b/include/ui/talent_screen.hpp index 18bbe152..82a674e4 100644 --- a/include/ui/talent_screen.hpp +++ b/include/ui/talent_screen.hpp @@ -28,6 +28,8 @@ private: void loadSpellDBC(pipeline::AssetManager* assetManager); void loadSpellIconDBC(pipeline::AssetManager* assetManager); + void loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager); + void renderGlyphs(game::GameHandler& gameHandler); VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); bool open = false; @@ -36,11 +38,22 @@ private: // DBC caches bool spellDbcLoaded = false; bool iconDbcLoaded = false; + bool glyphDbcLoaded = false; std::unordered_map spellIconIds; // spellId -> iconId std::unordered_map spellIconPaths; // iconId -> path std::unordered_map spellIconCache; // iconId -> texture std::unordered_map spellTooltips; // spellId -> description std::unordered_map bgTextureCache_; // tabId -> bg texture + + // Talent learn confirmation + bool talentConfirmOpen_ = false; + uint32_t pendingTalentId_ = 0; + uint32_t pendingTalentRank_ = 0; + std::string pendingTalentName_; + + // GlyphProperties.dbc cache: glyphId -> { spellId, isMajor } + struct GlyphInfo { uint32_t spellId = 0; bool isMajor = false; }; + std::unordered_map glyphProperties_; // glyphId -> info }; } // namespace ui diff --git a/src/addons/addon_manager.cpp b/src/addons/addon_manager.cpp new file mode 100644 index 00000000..ca91e92d --- /dev/null +++ b/src/addons/addon_manager.cpp @@ -0,0 +1,173 @@ +#include "addons/addon_manager.hpp" +#include "core/logger.hpp" +#include +#include + +namespace fs = std::filesystem; + +namespace wowee::addons { + +AddonManager::AddonManager() = default; +AddonManager::~AddonManager() { shutdown(); } + +bool AddonManager::initialize(game::GameHandler* gameHandler) { + gameHandler_ = gameHandler; + if (!luaEngine_.initialize()) return false; + luaEngine_.setGameHandler(gameHandler); + return true; +} + +void AddonManager::scanAddons(const std::string& addonsPath) { + addonsPath_ = addonsPath; + addons_.clear(); + + std::error_code ec; + if (!fs::is_directory(addonsPath, ec)) { + LOG_INFO("AddonManager: no AddOns directory at ", addonsPath); + return; + } + + std::vector dirs; + for (const auto& entry : fs::directory_iterator(addonsPath, ec)) { + if (entry.is_directory()) dirs.push_back(entry.path()); + } + // Sort alphabetically for deterministic load order + std::sort(dirs.begin(), dirs.end()); + + for (const auto& dir : dirs) { + std::string dirName = dir.filename().string(); + std::string tocPath = (dir / (dirName + ".toc")).string(); + auto toc = parseTocFile(tocPath); + if (!toc) continue; + + if (toc->isLoadOnDemand()) { + LOG_DEBUG("AddonManager: skipping LoadOnDemand addon: ", dirName); + continue; + } + + LOG_INFO("AddonManager: registered addon '", toc->getTitle(), + "' (", toc->files.size(), " files)"); + addons_.push_back(std::move(*toc)); + } + + LOG_INFO("AddonManager: scanned ", addons_.size(), " addons"); +} + +void AddonManager::loadAllAddons() { + luaEngine_.setAddonList(addons_); + int loaded = 0, failed = 0; + for (const auto& addon : addons_) { + if (loadAddon(addon)) loaded++; + else failed++; + } + LOG_INFO("AddonManager: loaded ", loaded, " addons", + (failed > 0 ? (", " + std::to_string(failed) + " failed") : "")); +} + +std::string AddonManager::getSavedVariablesPath(const TocFile& addon) const { + return addon.basePath + "/" + addon.addonName + ".lua.saved"; +} + +std::string AddonManager::getSavedVariablesPerCharacterPath(const TocFile& addon) const { + if (characterName_.empty()) return ""; + return addon.basePath + "/" + addon.addonName + "." + characterName_ + ".lua.saved"; +} + +bool AddonManager::loadAddon(const TocFile& addon) { + // Load SavedVariables before addon code (so globals are available at load time) + auto savedVars = addon.getSavedVariables(); + if (!savedVars.empty()) { + std::string svPath = getSavedVariablesPath(addon); + luaEngine_.loadSavedVariables(svPath); + LOG_DEBUG("AddonManager: loaded saved variables for '", addon.addonName, "'"); + } + // Load per-character SavedVariables + auto savedVarsPC = addon.getSavedVariablesPerCharacter(); + if (!savedVarsPC.empty()) { + std::string svpcPath = getSavedVariablesPerCharacterPath(addon); + if (!svpcPath.empty()) { + luaEngine_.loadSavedVariables(svpcPath); + LOG_DEBUG("AddonManager: loaded per-character saved variables for '", addon.addonName, "'"); + } + } + + bool success = true; + for (const auto& filename : addon.files) { + std::string lower = filename; + for (char& c : lower) c = static_cast(std::tolower(static_cast(c))); + + if (lower.size() >= 4 && lower.substr(lower.size() - 4) == ".lua") { + std::string fullPath = addon.basePath + "/" + filename; + if (!luaEngine_.executeFile(fullPath)) { + success = false; + } + } else if (lower.size() >= 4 && lower.substr(lower.size() - 4) == ".xml") { + LOG_DEBUG("AddonManager: skipping XML file '", filename, + "' in addon '", addon.addonName, "' (XML frames not yet implemented)"); + } + } + + // Fire ADDON_LOADED event after all addon files are executed + // This is the standard WoW pattern for addon initialization + if (success) { + luaEngine_.fireEvent("ADDON_LOADED", {addon.addonName}); + } + return success; +} + +bool AddonManager::runScript(const std::string& code) { + return luaEngine_.executeString(code); +} + +void AddonManager::fireEvent(const std::string& event, const std::vector& args) { + luaEngine_.fireEvent(event, args); +} + +void AddonManager::update(float deltaTime) { + luaEngine_.dispatchOnUpdate(deltaTime); +} + +void AddonManager::saveAllSavedVariables() { + for (const auto& addon : addons_) { + auto savedVars = addon.getSavedVariables(); + if (!savedVars.empty()) { + std::string svPath = getSavedVariablesPath(addon); + luaEngine_.saveSavedVariables(svPath, savedVars); + } + auto savedVarsPC = addon.getSavedVariablesPerCharacter(); + if (!savedVarsPC.empty()) { + std::string svpcPath = getSavedVariablesPerCharacterPath(addon); + if (!svpcPath.empty()) { + luaEngine_.saveSavedVariables(svpcPath, savedVarsPC); + } + } + } +} + +bool AddonManager::reload() { + LOG_INFO("AddonManager: reloading all addons..."); + saveAllSavedVariables(); + addons_.clear(); + luaEngine_.shutdown(); + + if (!luaEngine_.initialize()) { + LOG_ERROR("AddonManager: failed to reinitialize Lua VM during reload"); + return false; + } + luaEngine_.setGameHandler(gameHandler_); + + if (!addonsPath_.empty()) { + scanAddons(addonsPath_); + loadAllAddons(); + } + LOG_INFO("AddonManager: reload complete"); + return true; +} + +void AddonManager::shutdown() { + saveAllSavedVariables(); + addons_.clear(); + luaEngine_.shutdown(); +} + +} // namespace wowee::addons diff --git a/src/addons/lua_engine.cpp b/src/addons/lua_engine.cpp new file mode 100644 index 00000000..84146b84 --- /dev/null +++ b/src/addons/lua_engine.cpp @@ -0,0 +1,5421 @@ +#include "addons/lua_engine.hpp" +#include "addons/toc_parser.hpp" +#include "game/game_handler.hpp" +#include "game/entity.hpp" +#include "game/update_field_table.hpp" +#include "core/logger.hpp" +#include "core/application.hpp" +#include +#include +#include +#include + +extern "C" { +#include +#include +#include +} + +namespace wowee::addons { + +// Retrieve GameHandler pointer stored in Lua registry +static game::GameHandler* getGameHandler(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, "wowee_game_handler"); + auto* gh = static_cast(lua_touserdata(L, -1)); + lua_pop(L, 1); + return gh; +} + +// WoW-compatible print() — outputs to chat window instead of stdout +static int lua_wow_print(lua_State* L) { + int nargs = lua_gettop(L); + std::string result; + for (int i = 1; i <= nargs; i++) { + if (i > 1) result += '\t'; + // Lua 5.1: use lua_tostring (luaL_tolstring is 5.3+) + if (lua_isstring(L, i) || lua_isnumber(L, i)) { + const char* s = lua_tostring(L, i); + if (s) result += s; + } else if (lua_isboolean(L, i)) { + result += lua_toboolean(L, i) ? "true" : "false"; + } else if (lua_isnil(L, i)) { + result += "nil"; + } else { + result += lua_typename(L, lua_type(L, i)); + } + } + + auto* gh = getGameHandler(L); + if (gh) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = result; + gh->addLocalChatMessage(msg); + } + LOG_INFO("[Lua] ", result); + return 0; +} + +// WoW-compatible message() — same as print for now +static int lua_wow_message(lua_State* L) { + return lua_wow_print(L); +} + +// Helper: resolve WoW unit IDs to GUID +// Read UNIT_FIELD_TARGET_LO/HI from an entity's update fields to get what it's targeting +static uint64_t getEntityTargetGuid(game::GameHandler* gh, uint64_t guid) { + if (guid == 0) return 0; + // If asking for the player's target, use direct accessor + if (guid == gh->getPlayerGuid()) return gh->getTargetGuid(); + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity) return 0; + const auto& fields = entity->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt == fields.end()) return 0; + uint64_t targetGuid = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + targetGuid |= (static_cast(hiIt->second) << 32); + return targetGuid; +} + +static uint64_t resolveUnitGuid(game::GameHandler* gh, const std::string& uid) { + if (uid == "player") return gh->getPlayerGuid(); + if (uid == "target") return gh->getTargetGuid(); + if (uid == "focus") return gh->getFocusGuid(); + if (uid == "mouseover") return gh->getMouseoverGuid(); + if (uid == "pet") return gh->getPetGuid(); + // Compound unit IDs: targettarget, focustarget, pettarget, mouseovertarget + if (uid == "targettarget") return getEntityTargetGuid(gh, gh->getTargetGuid()); + if (uid == "focustarget") return getEntityTargetGuid(gh, gh->getFocusGuid()); + if (uid == "pettarget") return getEntityTargetGuid(gh, gh->getPetGuid()); + if (uid == "mouseovertarget") return getEntityTargetGuid(gh, gh->getMouseoverGuid()); + // party1-party4, raid1-raid40 + if (uid.rfind("party", 0) == 0 && uid.size() > 5) { + int idx = 0; + try { idx = std::stoi(uid.substr(5)); } catch (...) { return 0; } + if (idx < 1 || idx > 4) return 0; + const auto& pd = gh->getPartyData(); + // party members exclude self; index 1-based + int found = 0; + for (const auto& m : pd.members) { + if (m.guid == gh->getPlayerGuid()) continue; + if (++found == idx) return m.guid; + } + return 0; + } + if (uid.rfind("raid", 0) == 0 && uid.size() > 4 && uid[4] != 'p') { + int idx = 0; + try { idx = std::stoi(uid.substr(4)); } catch (...) { return 0; } + if (idx < 1 || idx > 40) return 0; + const auto& pd = gh->getPartyData(); + if (idx <= static_cast(pd.members.size())) + return pd.members[idx - 1].guid; + return 0; + } + return 0; +} + +// Helper: resolve unit IDs (player, target, focus, mouseover, pet, targettarget, focustarget, etc.) to entity +static game::Unit* resolveUnit(lua_State* L, const char* unitId) { + auto* gh = getGameHandler(L); + if (!gh || !unitId) return nullptr; + std::string uid(unitId); + for (char& c : uid) c = static_cast(std::tolower(static_cast(c))); + + uint64_t guid = resolveUnitGuid(gh, uid); + if (guid == 0) return nullptr; + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity) return nullptr; + return dynamic_cast(entity.get()); +} + +// --- WoW Unit API --- + +// Helper: find GroupMember data for a GUID (for party members out of entity range) +static const game::GroupMember* findPartyMember(game::GameHandler* gh, uint64_t guid) { + if (!gh || guid == 0) return nullptr; + for (const auto& m : gh->getPartyData().members) { + if (m.guid == guid && m.hasPartyStats) return &m; + } + return nullptr; +} + +static int lua_UnitName(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit && !unit->getName().empty()) { + lua_pushstring(L, unit->getName().c_str()); + } else { + // Fallback: party member name for out-of-range members + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + if (pm && !pm->name.empty()) { + lua_pushstring(L, pm->name.c_str()); + } else if (gh && guid != 0) { + // Try player name cache + const std::string& cached = gh->lookupName(guid); + lua_pushstring(L, cached.empty() ? "Unknown" : cached.c_str()); + } else { + lua_pushstring(L, "Unknown"); + } + } + return 1; +} + + +static int lua_UnitHealth(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushnumber(L, unit->getHealth()); + } else { + // Fallback: party member stats for out-of-range members + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->curHealth : 0); + } + return 1; +} + +static int lua_UnitHealthMax(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushnumber(L, unit->getMaxHealth()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->maxHealth : 0); + } + return 1; +} + +static int lua_UnitPower(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushnumber(L, unit->getPower()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->curPower : 0); + } + return 1; +} + +static int lua_UnitPowerMax(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushnumber(L, unit->getMaxPower()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->maxPower : 0); + } + return 1; +} + +static int lua_UnitLevel(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushnumber(L, unit->getLevel()); + } else { + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushnumber(L, pm ? pm->level : 0); + } + return 1; +} + +static int lua_UnitExists(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushboolean(L, 1); + } else { + // Party members in other zones don't have entities but still "exist" + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + lua_pushboolean(L, guid != 0 && findPartyMember(gh, guid) != nullptr); + } + return 1; +} + +static int lua_UnitIsDead(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + if (unit) { + lua_pushboolean(L, unit->getHealth() == 0); + } else { + // Fallback: party member stats for out-of-range members + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + lua_pushboolean(L, pm ? (pm->curHealth == 0 && pm->maxHealth > 0) : 0); + } + return 1; +} + +static int lua_UnitClass(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + auto* unit = resolveUnit(L, uid); + if (unit && gh) { + static const char* kClasses[] = {"", "Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + uint8_t classId = 0; + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") { + classId = gh->getPlayerClass(); + } else { + // Read class from UNIT_FIELD_BYTES_0 (class is byte 1) + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t bytes0 = entity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + classId = static_cast((bytes0 >> 8) & 0xFF); + } + } + // Fallback: check name query class/race cache + if (classId == 0 && guid != 0) { + classId = gh->lookupPlayerClass(guid); + } + } + const char* name = (classId > 0 && classId < 12) ? kClasses[classId] : "Unknown"; + lua_pushstring(L, name); + lua_pushstring(L, name); // WoW returns localized + English + lua_pushnumber(L, classId); + return 3; + } + lua_pushstring(L, "Unknown"); + lua_pushstring(L, "Unknown"); + lua_pushnumber(L, 0); + return 3; +} + +// UnitIsGhost(unit) — true if unit is in ghost form +static int lua_UnitIsGhost(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") { + lua_pushboolean(L, gh->isPlayerGhost()); + } else { + // Check UNIT_FIELD_FLAGS for UNIT_FLAG_GHOST (0x00000100) — best approximation + uint64_t guid = resolveUnitGuid(gh, uidStr); + bool ghost = false; + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t flags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + ghost = (flags & 0x00000100) != 0; // PLAYER_FLAGS_GHOST + } + } + lua_pushboolean(L, ghost); + } + return 1; +} + +// UnitIsDeadOrGhost(unit) +static int lua_UnitIsDeadOrGhost(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + auto* gh = getGameHandler(L); + bool dead = (unit && unit->getHealth() == 0); + if (!dead && gh) { + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") dead = gh->isPlayerGhost() || gh->isPlayerDead(); + } + lua_pushboolean(L, dead); + return 1; +} + +// UnitIsAFK(unit), UnitIsDND(unit) +static int lua_UnitIsAFK(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + // PLAYER_FLAGS at UNIT_FIELD_FLAGS: PLAYER_FLAGS_AFK = 0x01 + uint32_t playerFlags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + lua_pushboolean(L, (playerFlags & 0x01) != 0); + return 1; + } + } + lua_pushboolean(L, 0); + return 1; +} + +static int lua_UnitIsDND(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t playerFlags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + lua_pushboolean(L, (playerFlags & 0x02) != 0); // PLAYER_FLAGS_DND + return 1; + } + } + lua_pushboolean(L, 0); + return 1; +} + +// UnitPlayerControlled(unit) — true for players and player-controlled pets +static int lua_UnitPlayerControlled(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushboolean(L, 0); return 1; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity) { lua_pushboolean(L, 0); return 1; } + // Players are always player-controlled; pets check UNIT_FLAG_PLAYER_CONTROLLED (0x01000000) + if (entity->getType() == game::ObjectType::PLAYER) { + lua_pushboolean(L, 1); + } else { + uint32_t flags = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + lua_pushboolean(L, (flags & 0x01000000) != 0); + } + return 1; +} + +// UnitIsTapped(unit) — true if mob is tapped (tagged by any player) +static int lua_UnitIsTapped(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + if (!unit) { lua_pushboolean(L, 0); return 1; } + lua_pushboolean(L, (unit->getDynamicFlags() & 0x0004) != 0); // UNIT_DYNFLAG_TAPPED_BY_PLAYER + return 1; +} + +// UnitIsTappedByPlayer(unit) — true if tapped by the local player (can loot) +static int lua_UnitIsTappedByPlayer(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + if (!unit) { lua_pushboolean(L, 0); return 1; } + uint32_t df = unit->getDynamicFlags(); + // Tapped by player: has TAPPED flag but also LOOTABLE or TAPPED_BY_ALL + bool tapped = (df & 0x0004) != 0; + bool lootable = (df & 0x0001) != 0; + bool sharedTag = (df & 0x0008) != 0; + lua_pushboolean(L, tapped && (lootable || sharedTag)); + return 1; +} + +// UnitIsTappedByAllThreatList(unit) — true if shared-tag mob +static int lua_UnitIsTappedByAllThreatList(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + if (!unit) { lua_pushboolean(L, 0); return 1; } + lua_pushboolean(L, (unit->getDynamicFlags() & 0x0008) != 0); + return 1; +} + +// UnitThreatSituation(unit, mobUnit) → 0=not tanking, 1=not tanking but threat, 2=insecurely tanking, 3=securely tanking +static int lua_UnitThreatSituation(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + const char* uid = luaL_optstring(L, 1, "player"); + const char* mobUid = luaL_optstring(L, 2, nullptr); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t playerUnitGuid = resolveUnitGuid(gh, uidStr); + if (playerUnitGuid == 0) { lua_pushnumber(L, 0); return 1; } + // If no mob specified, check general combat threat against current target + uint64_t mobGuid = 0; + if (mobUid && *mobUid) { + std::string mStr(mobUid); + for (char& c : mStr) c = static_cast(std::tolower(static_cast(c))); + mobGuid = resolveUnitGuid(gh, mStr); + } + // Approximate threat: check if the mob is targeting this unit + if (mobGuid != 0) { + auto mobEntity = gh->getEntityManager().getEntity(mobGuid); + if (mobEntity) { + const auto& fields = mobEntity->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end()) { + uint64_t mobTarget = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + mobTarget |= (static_cast(hiIt->second) << 32); + if (mobTarget == playerUnitGuid) { + lua_pushnumber(L, 3); // securely tanking + return 1; + } + } + } + } + // Check if player is in combat (basic threat indicator) + if (playerUnitGuid == gh->getPlayerGuid() && gh->isInCombat()) { + lua_pushnumber(L, 1); // in combat but not tanking + return 1; + } + lua_pushnumber(L, 0); + return 1; +} + +// UnitDetailedThreatSituation(unit, mobUnit) → isTanking, status, threatPct, rawThreatPct, threatValue +static int lua_UnitDetailedThreatSituation(lua_State* L) { + // Use UnitThreatSituation logic for the basics + auto* gh = getGameHandler(L); + if (!gh) { + lua_pushboolean(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); + return 5; + } + const char* uid = luaL_optstring(L, 1, "player"); + const char* mobUid = luaL_optstring(L, 2, nullptr); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t unitGuid = resolveUnitGuid(gh, uidStr); + bool isTanking = false; + int status = 0; + if (unitGuid != 0 && mobUid && *mobUid) { + std::string mStr(mobUid); + for (char& c : mStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t mobGuid = resolveUnitGuid(gh, mStr); + if (mobGuid != 0) { + auto mobEnt = gh->getEntityManager().getEntity(mobGuid); + if (mobEnt) { + const auto& f = mobEnt->getFields(); + auto lo = f.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (lo != f.end()) { + uint64_t mt = lo->second; + auto hi = f.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hi != f.end()) mt |= (static_cast(hi->second) << 32); + if (mt == unitGuid) { isTanking = true; status = 3; } + } + } + } + } + lua_pushboolean(L, isTanking); + lua_pushnumber(L, status); + lua_pushnumber(L, isTanking ? 100.0 : 0.0); // threatPct + lua_pushnumber(L, isTanking ? 100.0 : 0.0); // rawThreatPct + lua_pushnumber(L, 0); // threatValue (not available without server threat data) + return 5; +} + +// UnitDistanceSquared(unit) → distSq, canCalculate +static int lua_UnitDistanceSquared(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushboolean(L, 0); return 2; } + const char* uid = luaL_checkstring(L, 1); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0 || guid == gh->getPlayerGuid()) { lua_pushnumber(L, 0); lua_pushboolean(L, 0); return 2; } + auto targetEnt = gh->getEntityManager().getEntity(guid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushnumber(L, 0); lua_pushboolean(L, 0); return 2; } + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + lua_pushnumber(L, dx*dx + dy*dy + dz*dz); + lua_pushboolean(L, 1); + return 2; +} + +// CheckInteractDistance(unit, distIndex) → boolean +// distIndex: 1=inspect(28yd), 2=trade(11yd), 3=duel(10yd), 4=follow(28yd) +static int lua_CheckInteractDistance(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid = luaL_checkstring(L, 1); + int distIdx = static_cast(luaL_optnumber(L, 2, 4)); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushboolean(L, 0); return 1; } + auto targetEnt = gh->getEntityManager().getEntity(guid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushboolean(L, 0); return 1; } + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + float maxDist = 28.0f; // default: follow/inspect range + switch (distIdx) { + case 1: maxDist = 28.0f; break; // inspect + case 2: maxDist = 11.11f; break; // trade + case 3: maxDist = 9.9f; break; // duel + case 4: maxDist = 28.0f; break; // follow + } + lua_pushboolean(L, dist <= maxDist); + return 1; +} + +// IsSpellInRange(spellName, unit) → 0 or 1 (nil if can't determine) +static int lua_IsSpellInRange(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* spellNameOrId = luaL_checkstring(L, 1); + const char* uid = luaL_optstring(L, 2, "target"); + + // Resolve spell ID + uint32_t spellId = 0; + if (spellNameOrId[0] >= '0' && spellNameOrId[0] <= '9') { + spellId = static_cast(strtoul(spellNameOrId, nullptr, 10)); + } else { + std::string nameLow(spellNameOrId); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + if (spellId == 0) { lua_pushnil(L); return 1; } + + // Get spell max range from DBC + auto data = gh->getSpellData(spellId); + if (data.maxRange <= 0.0f) { lua_pushnil(L); return 1; } + + // Resolve target position + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushnil(L); return 1; } + auto targetEnt = gh->getEntityManager().getEntity(guid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushnil(L); return 1; } + + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + lua_pushnumber(L, dist <= data.maxRange ? 1 : 0); + return 1; +} + +// UnitIsVisible(unit) → boolean (entity exists in the client's entity manager) +static int lua_UnitIsVisible(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "target"); + auto* unit = resolveUnit(L, uid); + lua_pushboolean(L, unit != nullptr); + return 1; +} + +// UnitGroupRolesAssigned(unit) → "TANK", "HEALER", "DAMAGER", or "NONE" +static int lua_UnitGroupRolesAssigned(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "NONE"); return 1; } + const char* uid = luaL_optstring(L, 1, "player"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushstring(L, "NONE"); return 1; } + const auto& pd = gh->getPartyData(); + for (const auto& m : pd.members) { + if (m.guid == guid) { + // WotLK roles bitmask: 0x02=Tank, 0x04=Healer, 0x08=DPS + if (m.roles & 0x02) { lua_pushstring(L, "TANK"); return 1; } + if (m.roles & 0x04) { lua_pushstring(L, "HEALER"); return 1; } + if (m.roles & 0x08) { lua_pushstring(L, "DAMAGER"); return 1; } + break; + } + } + lua_pushstring(L, "NONE"); + return 1; +} + +// UnitCanAttack(unit, otherUnit) → boolean +static int lua_UnitCanAttack(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid1 = luaL_checkstring(L, 1); + const char* uid2 = luaL_checkstring(L, 2); + std::string u1(uid1), u2(uid2); + for (char& c : u1) c = static_cast(std::tolower(static_cast(c))); + for (char& c : u2) c = static_cast(std::tolower(static_cast(c))); + uint64_t g1 = resolveUnitGuid(gh, u1); + uint64_t g2 = resolveUnitGuid(gh, u2); + if (g1 == 0 || g2 == 0 || g1 == g2) { lua_pushboolean(L, 0); return 1; } + // Check if unit2 is hostile to unit1 + auto* unit2 = resolveUnit(L, uid2); + if (unit2 && unit2->isHostile()) { + lua_pushboolean(L, 1); + } else { + lua_pushboolean(L, 0); + } + return 1; +} + +// UnitCanCooperate(unit, otherUnit) → boolean +static int lua_UnitCanCooperate(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + (void)luaL_checkstring(L, 1); // unit1 (unused — cooperation is based on unit2's hostility) + const char* uid2 = luaL_checkstring(L, 2); + auto* unit2 = resolveUnit(L, uid2); + if (!unit2) { lua_pushboolean(L, 0); return 1; } + lua_pushboolean(L, !unit2->isHostile()); + return 1; +} + +// UnitCreatureFamily(unit) → familyName or nil +static int lua_UnitCreatureFamily(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* uid = luaL_optstring(L, 1, "target"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushnil(L); return 1; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity || entity->getType() == game::ObjectType::PLAYER) { lua_pushnil(L); return 1; } + auto unit = std::dynamic_pointer_cast(entity); + if (!unit) { lua_pushnil(L); return 1; } + uint32_t family = gh->getCreatureFamily(unit->getEntry()); + if (family == 0) { lua_pushnil(L); return 1; } + static const char* kFamilies[] = { + "", "Wolf", "Cat", "Spider", "Bear", "Boar", "Crocolisk", "Carrion Bird", + "Crab", "Gorilla", "Raptor", "", "Tallstrider", "", "", "Felhunter", + "Voidwalker", "Succubus", "", "Doomguard", "Scorpid", "Turtle", "", + "Imp", "Bat", "Hyena", "Bird of Prey", "Wind Serpent", "", "Dragonhawk", + "Ravager", "Warp Stalker", "Sporebat", "Nether Ray", "Serpent", "Moth", + "Chimaera", "Devilsaur", "Ghoul", "Silithid", "Worm", "Rhino", "Wasp", + "Core Hound", "Spirit Beast" + }; + lua_pushstring(L, (family < sizeof(kFamilies)/sizeof(kFamilies[0]) && kFamilies[family][0]) + ? kFamilies[family] : "Beast"); + return 1; +} + +// UnitOnTaxi(unit) → boolean (true if on a flight path) +static int lua_UnitOnTaxi(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") { + lua_pushboolean(L, gh->isOnTaxiFlight()); + } else { + lua_pushboolean(L, 0); // Can't determine for other units + } + return 1; +} + +// UnitSex(unit) → 1=unknown, 2=male, 3=female +static int lua_UnitSex(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 1); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + // Gender is byte 2 of UNIT_FIELD_BYTES_0 (0=male, 1=female) + uint32_t bytes0 = entity->getField(game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + uint8_t gender = static_cast((bytes0 >> 16) & 0xFF); + lua_pushnumber(L, gender == 0 ? 2 : (gender == 1 ? 3 : 1)); // WoW: 2=male, 3=female + return 1; + } + } + lua_pushnumber(L, 1); // unknown + return 1; +} + +// --- Player/Game API --- + +static int lua_GetMoney(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? static_cast(gh->getMoneyCopper()) : 0.0); + return 1; +} + +// UnitStat(unit, statIndex) → base, effective, posBuff, negBuff +// statIndex: 1=STR, 2=AGI, 3=STA, 4=INT, 5=SPI (1-indexed per WoW API) +static int lua_UnitStat(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 4; } + int statIdx = static_cast(luaL_checknumber(L, 2)) - 1; // WoW API is 1-indexed + int32_t val = gh->getPlayerStat(statIdx); + if (val < 0) val = 0; + // We only have the effective value from the server; report base=effective, no buffs + lua_pushnumber(L, val); // base (approximate — server only sends effective) + lua_pushnumber(L, val); // effective + lua_pushnumber(L, 0); // positive buff + lua_pushnumber(L, 0); // negative buff + return 4; +} + +// GetDodgeChance() → percent +static int lua_GetDodgeChance(lua_State* L) { + auto* gh = getGameHandler(L); + float v = gh ? gh->getDodgePct() : 0.0f; + lua_pushnumber(L, v >= 0 ? v : 0.0); + return 1; +} + +// GetParryChance() → percent +static int lua_GetParryChance(lua_State* L) { + auto* gh = getGameHandler(L); + float v = gh ? gh->getParryPct() : 0.0f; + lua_pushnumber(L, v >= 0 ? v : 0.0); + return 1; +} + +// GetBlockChance() → percent +static int lua_GetBlockChance(lua_State* L) { + auto* gh = getGameHandler(L); + float v = gh ? gh->getBlockPct() : 0.0f; + lua_pushnumber(L, v >= 0 ? v : 0.0); + return 1; +} + +// GetCritChance() → percent (melee crit) +static int lua_GetCritChance(lua_State* L) { + auto* gh = getGameHandler(L); + float v = gh ? gh->getCritPct() : 0.0f; + lua_pushnumber(L, v >= 0 ? v : 0.0); + return 1; +} + +// GetRangedCritChance() → percent +static int lua_GetRangedCritChance(lua_State* L) { + auto* gh = getGameHandler(L); + float v = gh ? gh->getRangedCritPct() : 0.0f; + lua_pushnumber(L, v >= 0 ? v : 0.0); + return 1; +} + +// GetSpellCritChance(school) → percent (1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane) +static int lua_GetSpellCritChance(lua_State* L) { + auto* gh = getGameHandler(L); + int school = static_cast(luaL_checknumber(L, 1)); + float v = gh ? gh->getSpellCritPct(school) : 0.0f; + lua_pushnumber(L, v >= 0 ? v : 0.0); + return 1; +} + +// GetCombatRating(ratingIndex) → value +static int lua_GetCombatRating(lua_State* L) { + auto* gh = getGameHandler(L); + int cr = static_cast(luaL_checknumber(L, 1)); + int32_t v = gh ? gh->getCombatRating(cr) : 0; + lua_pushnumber(L, v >= 0 ? v : 0); + return 1; +} + +// GetSpellBonusDamage(school) → value (1-6 magic schools) +static int lua_GetSpellBonusDamage(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int32_t sp = gh->getSpellPower(); + lua_pushnumber(L, sp >= 0 ? sp : 0); + return 1; +} + +// GetSpellBonusHealing() → value +static int lua_GetSpellBonusHealing(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int32_t v = gh->getHealingPower(); + lua_pushnumber(L, v >= 0 ? v : 0); + return 1; +} + +// GetMeleeHaste / GetAttackPowerForStat stubs for addon compat +static int lua_GetAttackPower(lua_State* L) { + auto* gh = getGameHandler(L); + int32_t ap = gh ? gh->getMeleeAttackPower() : 0; + if (ap < 0) ap = 0; + lua_pushnumber(L, ap); // base + lua_pushnumber(L, 0); // posBuff + lua_pushnumber(L, 0); // negBuff + return 3; +} + +static int lua_GetRangedAttackPower(lua_State* L) { + auto* gh = getGameHandler(L); + int32_t ap = gh ? gh->getRangedAttackPower() : 0; + if (ap < 0) ap = 0; + lua_pushnumber(L, ap); + lua_pushnumber(L, 0); + lua_pushnumber(L, 0); + return 3; +} + +static int lua_IsInGroup(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isInGroup()); + return 1; +} + +static int lua_IsInRaid(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isInGroup() && gh->getPartyData().groupType == 1); + return 1; +} + +static int lua_GetPlayerMapPosition(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) { + const auto& mi = gh->getMovementInfo(); + lua_pushnumber(L, mi.x); + lua_pushnumber(L, mi.y); + return 2; + } + lua_pushnumber(L, 0); + lua_pushnumber(L, 0); + return 2; +} + +// GetPlayerFacing() → radians (0 = north, increasing counter-clockwise) +static int lua_GetPlayerFacing(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) { + float facing = gh->getMovementInfo().orientation; + // Normalize to [0, 2π) + while (facing < 0) facing += 6.2831853f; + while (facing >= 6.2831853f) facing -= 6.2831853f; + lua_pushnumber(L, facing); + } else { + lua_pushnumber(L, 0); + } + return 1; +} + +// GetCVar(name) → value string (stub for most, real for a few) +static int lua_GetCVar(lua_State* L) { + const char* name = luaL_checkstring(L, 1); + std::string n(name); + // Return sensible defaults for commonly queried CVars + if (n == "uiScale") lua_pushstring(L, "1"); + else if (n == "useUIScale") lua_pushstring(L, "1"); + else if (n == "screenWidth" || n == "gxResolution") { + auto* win = core::Application::getInstance().getWindow(); + lua_pushstring(L, std::to_string(win ? win->getWidth() : 1920).c_str()); + } else if (n == "screenHeight" || n == "gxFullscreenResolution") { + auto* win = core::Application::getInstance().getWindow(); + lua_pushstring(L, std::to_string(win ? win->getHeight() : 1080).c_str()); + } else if (n == "nameplateShowFriends") lua_pushstring(L, "1"); + else if (n == "nameplateShowEnemies") lua_pushstring(L, "1"); + else if (n == "Sound_EnableSFX") lua_pushstring(L, "1"); + else if (n == "Sound_EnableMusic") lua_pushstring(L, "1"); + else if (n == "chatBubbles") lua_pushstring(L, "1"); + else if (n == "autoLootDefault") lua_pushstring(L, "1"); + else lua_pushstring(L, "0"); + return 1; +} + +// SetCVar(name, value) — no-op stub (log for debugging) +static int lua_SetCVar(lua_State* L) { + (void)L; + return 0; +} + +static int lua_UnitRace(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "Unknown"); lua_pushstring(L, "Unknown"); lua_pushnumber(L, 0); return 3; } + std::string uid(luaL_optstring(L, 1, "player")); + for (char& c : uid) c = static_cast(std::tolower(static_cast(c))); + static const char* kRaces[] = {"","Human","Orc","Dwarf","Night Elf","Undead", + "Tauren","Gnome","Troll","","Blood Elf","Draenei"}; + uint8_t raceId = 0; + if (uid == "player") { + raceId = gh->getPlayerRace(); + } else { + // Read race from UNIT_FIELD_BYTES_0 (race is byte 0) + uint64_t guid = resolveUnitGuid(gh, uid); + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t bytes0 = entity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_BYTES_0)); + raceId = static_cast(bytes0 & 0xFF); + } + // Fallback: name query class/race cache + if (raceId == 0) raceId = gh->lookupPlayerRace(guid); + } + } + const char* name = (raceId > 0 && raceId < 12) ? kRaces[raceId] : "Unknown"; + lua_pushstring(L, name); // 1: localized race + lua_pushstring(L, name); // 2: English race + lua_pushnumber(L, raceId); // 3: raceId (WoW returns 3 values) + return 3; +} + +static int lua_UnitPowerType(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"}; + if (unit) { + uint8_t pt = unit->getPowerType(); + lua_pushnumber(L, pt); + lua_pushstring(L, (pt < 7) ? kPowerNames[pt] : "MANA"); + return 2; + } + // Fallback: party member stats for out-of-range members + auto* gh = getGameHandler(L); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = gh ? resolveUnitGuid(gh, uidStr) : 0; + const auto* pm = findPartyMember(gh, guid); + if (pm) { + uint8_t pt = pm->powerType; + lua_pushnumber(L, pt); + lua_pushstring(L, (pt < 7) ? kPowerNames[pt] : "MANA"); + return 2; + } + lua_pushnumber(L, 0); + lua_pushstring(L, "MANA"); + return 2; +} + +static int lua_GetNumGroupMembers(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? gh->getPartyData().memberCount : 0); + return 1; +} + +static int lua_UnitGUID(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushnil(L); return 1; } + char buf[32]; + snprintf(buf, sizeof(buf), "0x%016llX", (unsigned long long)guid); + lua_pushstring(L, buf); + return 1; +} + +static int lua_UnitIsPlayer(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + auto entity = guid ? gh->getEntityManager().getEntity(guid) : nullptr; + lua_pushboolean(L, entity && entity->getType() == game::ObjectType::PLAYER); + return 1; +} + +static int lua_InCombatLockdown(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isInCombat()); + return 1; +} + +// --- Addon Info API --- +// These need the AddonManager pointer stored in registry + +static int lua_GetNumAddOns(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, "wowee_addon_count"); + return 1; +} + +static int lua_GetAddOnInfo(lua_State* L) { + // Accept index (1-based) or addon name + lua_getfield(L, LUA_REGISTRYINDEX, "wowee_addon_info"); + if (!lua_istable(L, -1)) { + lua_pop(L, 1); + lua_pushnil(L); return 1; + } + + int idx = 0; + if (lua_isnumber(L, 1)) { + idx = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + // Search by name + const char* name = lua_tostring(L, 1); + int count = static_cast(lua_objlen(L, -1)); + for (int i = 1; i <= count; i++) { + lua_rawgeti(L, -1, i); + lua_getfield(L, -1, "name"); + const char* aName = lua_tostring(L, -1); + lua_pop(L, 1); + if (aName && strcmp(aName, name) == 0) { idx = i; lua_pop(L, 1); break; } + lua_pop(L, 1); + } + } + + if (idx < 1) { lua_pop(L, 1); lua_pushnil(L); return 1; } + + lua_rawgeti(L, -1, idx); + if (!lua_istable(L, -1)) { lua_pop(L, 2); lua_pushnil(L); return 1; } + + lua_getfield(L, -1, "name"); + lua_getfield(L, -2, "title"); + lua_getfield(L, -3, "notes"); + lua_pushboolean(L, 1); // loadable (always true for now) + lua_pushstring(L, "INSECURE"); // security + lua_pop(L, 1); // pop addon info entry (keep others) + // Return: name, title, notes, loadable, reason, security + return 5; +} + +// GetAddOnMetadata(addonNameOrIndex, key) → value +static int lua_GetAddOnMetadata(lua_State* L) { + lua_getfield(L, LUA_REGISTRYINDEX, "wowee_addon_info"); + if (!lua_istable(L, -1)) { lua_pop(L, 1); lua_pushnil(L); return 1; } + + int idx = 0; + if (lua_isnumber(L, 1)) { + idx = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + int count = static_cast(lua_objlen(L, -1)); + for (int i = 1; i <= count; i++) { + lua_rawgeti(L, -1, i); + lua_getfield(L, -1, "name"); + const char* aName = lua_tostring(L, -1); + lua_pop(L, 1); + if (aName && strcmp(aName, name) == 0) { idx = i; lua_pop(L, 1); break; } + lua_pop(L, 1); + } + } + if (idx < 1) { lua_pop(L, 1); lua_pushnil(L); return 1; } + + const char* key = luaL_checkstring(L, 2); + lua_rawgeti(L, -1, idx); + if (!lua_istable(L, -1)) { lua_pop(L, 2); lua_pushnil(L); return 1; } + lua_getfield(L, -1, "metadata"); + if (!lua_istable(L, -1)) { lua_pop(L, 3); lua_pushnil(L); return 1; } + lua_getfield(L, -1, key); + return 1; +} + +// UnitBuff(unitId, index) / UnitDebuff(unitId, index) +// Returns: name, rank, icon, count, debuffType, duration, expirationTime, caster, isStealable, shouldConsolidate, spellId +static int lua_UnitAura(lua_State* L, bool wantBuff) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* uid = luaL_optstring(L, 1, "player"); + int index = static_cast(luaL_optnumber(L, 2, 1)); + if (index < 1) { lua_pushnil(L); return 1; } + + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + + const std::vector* auras = nullptr; + if (uidStr == "player") auras = &gh->getPlayerAuras(); + else if (uidStr == "target") auras = &gh->getTargetAuras(); + else { + // Try party/raid/focus via GUID lookup in unitAurasCache + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) auras = gh->getUnitAuras(guid); + } + if (!auras) { lua_pushnil(L); return 1; } + + // Filter to buffs or debuffs and find the Nth one + int found = 0; + for (const auto& aura : *auras) { + if (aura.isEmpty() || aura.spellId == 0) continue; + bool isDebuff = (aura.flags & 0x80) != 0; + if (wantBuff ? isDebuff : !isDebuff) continue; + found++; + if (found == index) { + // Return: name, rank, icon, count, debuffType, duration, expirationTime, ...spellId + std::string name = gh->getSpellName(aura.spellId); + lua_pushstring(L, name.empty() ? "Unknown" : name.c_str()); // name + lua_pushstring(L, ""); // rank + std::string iconPath = gh->getSpellIconPath(aura.spellId); + if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); + else lua_pushnil(L); // icon texture path + lua_pushnumber(L, aura.charges); // count + // debuffType: resolve from Spell.dbc dispel type + { + uint8_t dt = gh->getSpellDispelType(aura.spellId); + switch (dt) { + case 1: lua_pushstring(L, "Magic"); break; + case 2: lua_pushstring(L, "Curse"); break; + case 3: lua_pushstring(L, "Disease"); break; + case 4: lua_pushstring(L, "Poison"); break; + default: lua_pushnil(L); break; + } + } + lua_pushnumber(L, aura.maxDurationMs > 0 ? aura.maxDurationMs / 1000.0 : 0); // duration + // expirationTime: GetTime() + remaining seconds (so addons can compute countdown) + if (aura.durationMs > 0) { + uint64_t auraNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remMs = aura.getRemainingMs(auraNowMs); + // GetTime epoch = steady_clock relative to engine start + static auto sStart = std::chrono::steady_clock::now(); + double nowSec = std::chrono::duration( + std::chrono::steady_clock::now() - sStart).count(); + lua_pushnumber(L, nowSec + remMs / 1000.0); + } else { + lua_pushnumber(L, 0); // permanent aura + } + // caster: return unit ID string if caster is known + if (aura.casterGuid != 0) { + if (aura.casterGuid == gh->getPlayerGuid()) + lua_pushstring(L, "player"); + else if (aura.casterGuid == gh->getTargetGuid()) + lua_pushstring(L, "target"); + else if (aura.casterGuid == gh->getFocusGuid()) + lua_pushstring(L, "focus"); + else if (aura.casterGuid == gh->getPetGuid()) + lua_pushstring(L, "pet"); + else { + char cBuf[32]; + snprintf(cBuf, sizeof(cBuf), "0x%016llX", (unsigned long long)aura.casterGuid); + lua_pushstring(L, cBuf); + } + } else { + lua_pushnil(L); + } + lua_pushboolean(L, 0); // isStealable + lua_pushboolean(L, 0); // shouldConsolidate + lua_pushnumber(L, aura.spellId); // spellId + return 11; + } + } + lua_pushnil(L); + return 1; +} + +static int lua_UnitBuff(lua_State* L) { return lua_UnitAura(L, true); } +static int lua_UnitDebuff(lua_State* L) { return lua_UnitAura(L, false); } + +// UnitAura(unit, index, filter) — generic aura query with filter string +// filter: "HELPFUL" = buffs, "HARMFUL" = debuffs, "PLAYER" = cast by player, +// "HELPFUL|PLAYER" = buffs cast by player, etc. +static int lua_UnitAuraGeneric(lua_State* L) { + const char* filter = luaL_optstring(L, 3, "HELPFUL"); + std::string f(filter ? filter : "HELPFUL"); + for (char& c : f) c = static_cast(std::toupper(static_cast(c))); + bool wantBuff = (f.find("HARMFUL") == std::string::npos); + return lua_UnitAura(L, wantBuff); +} + +// ---------- UnitCastingInfo / UnitChannelInfo ---------- +// Internal helper: pushes cast/channel info for a unit. +// Returns number of Lua return values (0 if not casting/channeling the requested type). +static int lua_UnitCastInfo(lua_State* L, bool wantChannel) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + + const char* uid = luaL_optstring(L, 1, "player"); + std::string uidStr(uid ? uid : "player"); + + // GetTime epoch for consistent time values + static auto sStart = std::chrono::steady_clock::now(); + double nowSec = std::chrono::duration( + std::chrono::steady_clock::now() - sStart).count(); + + // Resolve cast state for the unit + bool isCasting = false; + bool isChannel = false; + uint32_t spellId = 0; + float timeTotal = 0.0f; + float timeRemaining = 0.0f; + bool interruptible = true; + + if (uidStr == "player") { + isCasting = gh->isCasting(); + isChannel = gh->isChanneling(); + spellId = gh->getCurrentCastSpellId(); + timeTotal = gh->getCastTimeTotal(); + timeRemaining = gh->getCastTimeRemaining(); + // Player interruptibility: always true for own casts (server controls actual interrupt) + interruptible = true; + } else { + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushnil(L); return 1; } + const auto* state = gh->getUnitCastState(guid); + if (!state) { lua_pushnil(L); return 1; } + isCasting = state->casting; + isChannel = state->isChannel; + spellId = state->spellId; + timeTotal = state->timeTotal; + timeRemaining = state->timeRemaining; + interruptible = state->interruptible; + } + + if (!isCasting) { lua_pushnil(L); return 1; } + + // UnitCastingInfo: only returns for non-channel casts + // UnitChannelInfo: only returns for channels + if (wantChannel != isChannel) { lua_pushnil(L); return 1; } + + // Spell name + icon + const std::string& name = gh->getSpellName(spellId); + std::string iconPath = gh->getSpellIconPath(spellId); + + // Time values in milliseconds (WoW API convention) + double startTimeMs = (nowSec - (timeTotal - timeRemaining)) * 1000.0; + double endTimeMs = (nowSec + timeRemaining) * 1000.0; + + // Return values match WoW API: + // UnitCastingInfo: name, text, texture, startTime, endTime, isTradeSkill, castID, notInterruptible + // UnitChannelInfo: name, text, texture, startTime, endTime, isTradeSkill, notInterruptible + lua_pushstring(L, name.empty() ? "Unknown" : name.c_str()); // name + lua_pushstring(L, ""); // text (sub-text, usually empty) + if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); + else lua_pushstring(L, "Interface\\Icons\\INV_Misc_QuestionMark"); // texture + lua_pushnumber(L, startTimeMs); // startTime (ms) + lua_pushnumber(L, endTimeMs); // endTime (ms) + lua_pushboolean(L, gh->isProfessionSpell(spellId) ? 1 : 0); // isTradeSkill + if (!wantChannel) { + lua_pushnumber(L, spellId); // castID (UnitCastingInfo only) + } + lua_pushboolean(L, interruptible ? 0 : 1); // notInterruptible + return wantChannel ? 7 : 8; +} + +static int lua_UnitCastingInfo(lua_State* L) { return lua_UnitCastInfo(L, false); } +static int lua_UnitChannelInfo(lua_State* L) { return lua_UnitCastInfo(L, true); } + +// --- Action API --- + +static int lua_SendChatMessage(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* msg = luaL_checkstring(L, 1); + const char* chatType = luaL_optstring(L, 2, "SAY"); + // language arg (3) ignored — server determines language + const char* target = luaL_optstring(L, 4, ""); + + std::string typeStr(chatType); + for (char& c : typeStr) c = static_cast(std::toupper(static_cast(c))); + + game::ChatType ct = game::ChatType::SAY; + if (typeStr == "SAY") ct = game::ChatType::SAY; + else if (typeStr == "YELL") ct = game::ChatType::YELL; + else if (typeStr == "PARTY") ct = game::ChatType::PARTY; + else if (typeStr == "GUILD") ct = game::ChatType::GUILD; + else if (typeStr == "OFFICER") ct = game::ChatType::OFFICER; + else if (typeStr == "RAID") ct = game::ChatType::RAID; + else if (typeStr == "WHISPER") ct = game::ChatType::WHISPER; + else if (typeStr == "BATTLEGROUND") ct = game::ChatType::BATTLEGROUND; + + std::string targetStr(target && *target ? target : ""); + gh->sendChatMessage(ct, msg, targetStr); + return 0; +} + +static int lua_CastSpellByName(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* name = luaL_checkstring(L, 1); + if (!name || !*name) return 0; + + // Find highest rank of spell by name (same logic as /cast) + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + + uint32_t bestId = 0; + int bestRank = -1; + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn != nameLow) continue; + int rank = 0; + const std::string& rk = gh->getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { + try { rank = std::stoi(rkl.substr(5)); } catch (...) {} + } + } + if (rank > bestRank) { bestRank = rank; bestId = sid; } + } + if (bestId != 0) { + uint64_t target = gh->hasTarget() ? gh->getTargetGuid() : 0; + gh->castSpell(bestId, target); + } + return 0; +} + +// SendAddonMessage(prefix, text, chatType, target) — send addon message +static int lua_SendAddonMessage(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* prefix = luaL_checkstring(L, 1); + const char* text = luaL_checkstring(L, 2); + const char* chatType = luaL_optstring(L, 3, "PARTY"); + const char* target = luaL_optstring(L, 4, ""); + + // Build addon message: prefix + TAB + text, send via the appropriate channel + std::string typeStr(chatType); + for (char& c : typeStr) c = static_cast(std::toupper(static_cast(c))); + + game::ChatType ct = game::ChatType::PARTY; + if (typeStr == "PARTY") ct = game::ChatType::PARTY; + else if (typeStr == "RAID") ct = game::ChatType::RAID; + else if (typeStr == "GUILD") ct = game::ChatType::GUILD; + else if (typeStr == "OFFICER") ct = game::ChatType::OFFICER; + else if (typeStr == "BATTLEGROUND") ct = game::ChatType::BATTLEGROUND; + else if (typeStr == "WHISPER") ct = game::ChatType::WHISPER; + + // Encode as prefix\ttext (WoW addon message format) + std::string encoded = std::string(prefix) + "\t" + text; + std::string targetStr(target && *target ? target : ""); + gh->sendChatMessage(ct, encoded, targetStr); + return 0; +} + +// RegisterAddonMessagePrefix(prefix) — register prefix for receiving addon messages +static int lua_RegisterAddonMessagePrefix(lua_State* L) { + const char* prefix = luaL_checkstring(L, 1); + // Store in a global Lua table for filtering + lua_getglobal(L, "__WoweeAddonPrefixes"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setglobal(L, "__WoweeAddonPrefixes"); + } + lua_pushboolean(L, 1); + lua_setfield(L, -2, prefix); + lua_pop(L, 1); + lua_pushboolean(L, 1); // success + return 1; +} + +// IsAddonMessagePrefixRegistered(prefix) → boolean +static int lua_IsAddonMessagePrefixRegistered(lua_State* L) { + const char* prefix = luaL_checkstring(L, 1); + lua_getglobal(L, "__WoweeAddonPrefixes"); + if (lua_istable(L, -1)) { + lua_getfield(L, -1, prefix); + lua_pushboolean(L, lua_toboolean(L, -1)); + return 1; + } + lua_pushboolean(L, 0); + return 1; +} + +static int lua_IsSpellKnown(lua_State* L) { + auto* gh = getGameHandler(L); + uint32_t spellId = static_cast(luaL_checknumber(L, 1)); + lua_pushboolean(L, gh && gh->getKnownSpells().count(spellId)); + return 1; +} + +// --- Spell Book Tab API --- + +// GetNumSpellTabs() → count +static int lua_GetNumSpellTabs(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + lua_pushnumber(L, gh->getSpellBookTabs().size()); + return 1; +} + +// GetSpellTabInfo(tabIndex) → name, texture, offset, numSpells +// tabIndex is 1-based; offset is 1-based global spell book slot +static int lua_GetSpellTabInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIdx = static_cast(luaL_checknumber(L, 1)); + if (!gh || tabIdx < 1) { + lua_pushnil(L); return 1; + } + const auto& tabs = gh->getSpellBookTabs(); + if (tabIdx > static_cast(tabs.size())) { + lua_pushnil(L); return 1; + } + // Compute offset: sum of spells in all preceding tabs (1-based) + int offset = 0; + for (int i = 0; i < tabIdx - 1; ++i) + offset += static_cast(tabs[i].spellIds.size()); + const auto& tab = tabs[tabIdx - 1]; + lua_pushstring(L, tab.name.c_str()); // name + lua_pushstring(L, tab.texture.c_str()); // texture + lua_pushnumber(L, offset); // offset (0-based for WoW compat) + lua_pushnumber(L, tab.spellIds.size()); // numSpells + return 4; +} + +// GetSpellBookItemInfo(slot, bookType) → "SPELL", spellId +// slot is 1-based global spell book index +static int lua_GetSpellBookItemInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); + if (!gh || slot < 1) { + lua_pushstring(L, "SPELL"); + lua_pushnumber(L, 0); + return 2; + } + const auto& tabs = gh->getSpellBookTabs(); + int idx = slot; // 1-based + for (const auto& tab : tabs) { + if (idx <= static_cast(tab.spellIds.size())) { + lua_pushstring(L, "SPELL"); + lua_pushnumber(L, tab.spellIds[idx - 1]); + return 2; + } + idx -= static_cast(tab.spellIds.size()); + } + lua_pushstring(L, "SPELL"); + lua_pushnumber(L, 0); + return 2; +} + +// GetSpellBookItemName(slot, bookType) → name, subName +static int lua_GetSpellBookItemName(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); + if (!gh || slot < 1) { lua_pushnil(L); return 1; } + const auto& tabs = gh->getSpellBookTabs(); + int idx = slot; + for (const auto& tab : tabs) { + if (idx <= static_cast(tab.spellIds.size())) { + uint32_t spellId = tab.spellIds[idx - 1]; + const std::string& name = gh->getSpellName(spellId); + lua_pushstring(L, name.empty() ? "Unknown" : name.c_str()); + lua_pushstring(L, ""); // subName/rank + return 2; + } + idx -= static_cast(tab.spellIds.size()); + } + lua_pushnil(L); + return 1; +} + +static int lua_GetSpellCooldown(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + // Accept spell name or ID + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else { + const char* name = luaL_checkstring(L, 1); + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + float cd = gh->getSpellCooldown(spellId); + // WoW returns (start, duration, enabled) where remaining = start + duration - GetTime() + // Compute start = GetTime() - elapsed, duration = total cooldown + static auto sStart = std::chrono::steady_clock::now(); + double nowSec = std::chrono::duration( + std::chrono::steady_clock::now() - sStart).count(); + if (cd > 0.01f) { + lua_pushnumber(L, nowSec); // start (approximate — we don't track exact start) + lua_pushnumber(L, cd); // duration (remaining, used as total for simplicity) + } else { + lua_pushnumber(L, 0); // not on cooldown + lua_pushnumber(L, 0); + } + lua_pushnumber(L, 1); // enabled (always 1 — spell is usable) + return 3; +} + +static int lua_HasTarget(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->hasTarget()); + return 1; +} + +// TargetUnit(unitId) — set current target +static int lua_TargetUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_checkstring(L, 1); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) gh->setTarget(guid); + return 0; +} + +// ClearTarget() — clear current target +static int lua_ClearTarget(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->clearTarget(); + return 0; +} + +// FocusUnit(unitId) — set focus target +static int lua_FocusUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, nullptr); + if (!uid || !*uid) return 0; + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid != 0) gh->setFocus(guid); + return 0; +} + +// ClearFocus() — clear focus target +static int lua_ClearFocus(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->clearFocus(); + return 0; +} + +// AssistUnit(unitId) — target whatever the given unit is targeting +static int lua_AssistUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, "target"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) return 0; + uint64_t theirTarget = getEntityTargetGuid(gh, guid); + if (theirTarget != 0) gh->setTarget(theirTarget); + return 0; +} + +// TargetLastTarget() — re-target previous target +static int lua_TargetLastTarget(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->targetLastTarget(); + return 0; +} + +// TargetNearestEnemy() — tab-target nearest enemy +static int lua_TargetNearestEnemy(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->targetEnemy(false); + return 0; +} + +// TargetNearestFriend() — target nearest friendly unit +static int lua_TargetNearestFriend(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->targetFriend(false); + return 0; +} + +// GetRaidTargetIndex(unit) → icon index (1-8) or nil +static int lua_GetRaidTargetIndex(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* uid = luaL_optstring(L, 1, "target"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushnil(L); return 1; } + uint8_t mark = gh->getEntityRaidMark(guid); + if (mark == 0xFF) { lua_pushnil(L); return 1; } + lua_pushnumber(L, mark + 1); // WoW uses 1-indexed (1=Star, 2=Circle, ... 8=Skull) + return 1; +} + +// SetRaidTarget(unit, index) — set raid marker (1-8, or 0 to clear) +static int lua_SetRaidTarget(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, "target"); + int index = static_cast(luaL_checknumber(L, 2)); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) return 0; + if (index >= 1 && index <= 8) + gh->setRaidMark(guid, static_cast(index - 1)); + else if (index == 0) + gh->setRaidMark(guid, 0xFF); // clear + return 0; +} + +// GetSpellPowerCost(spellId) → {{ type=powerType, cost=manaCost, name=powerName }} +static int lua_GetSpellPowerCost(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_newtable(L); return 1; } + uint32_t spellId = static_cast(luaL_checknumber(L, 1)); + auto data = gh->getSpellData(spellId); + lua_newtable(L); // outer table (array of cost entries) + if (data.manaCost > 0) { + lua_newtable(L); // cost entry + lua_pushnumber(L, data.powerType); + lua_setfield(L, -2, "type"); + lua_pushnumber(L, data.manaCost); + lua_setfield(L, -2, "cost"); + static const char* kPowerNames[] = {"MANA","RAGE","FOCUS","ENERGY","HAPPINESS","","RUNIC_POWER"}; + lua_pushstring(L, data.powerType < 7 ? kPowerNames[data.powerType] : "MANA"); + lua_setfield(L, -2, "name"); + lua_rawseti(L, -2, 1); // outer[1] = entry + } + return 1; +} + +// --- GetSpellInfo / GetSpellTexture --- +// GetSpellInfo(spellIdOrName) -> name, rank, icon, castTime, minRange, maxRange, spellId +static int lua_GetSpellInfo(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + if (!name || !*name) { lua_pushnil(L); return 1; } + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + int bestRank = -1; + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn != nameLow) continue; + int rank = 0; + const std::string& rk = gh->getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { + try { rank = std::stoi(rkl.substr(5)); } catch (...) {} + } + } + if (rank > bestRank) { bestRank = rank; spellId = sid; } + } + } + + if (spellId == 0) { lua_pushnil(L); return 1; } + std::string name = gh->getSpellName(spellId); + if (name.empty()) { lua_pushnil(L); return 1; } + + lua_pushstring(L, name.c_str()); // 1: name + const std::string& rank = gh->getSpellRank(spellId); + lua_pushstring(L, rank.c_str()); // 2: rank + std::string iconPath = gh->getSpellIconPath(spellId); + if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); + else lua_pushnil(L); // 3: icon texture path + // Resolve cast time and range from Spell.dbc → SpellCastTimes.dbc / SpellRange.dbc + auto spellData = gh->getSpellData(spellId); + lua_pushnumber(L, spellData.castTimeMs); // 4: castTime (ms) + lua_pushnumber(L, spellData.minRange); // 5: minRange (yards) + lua_pushnumber(L, spellData.maxRange); // 6: maxRange (yards) + lua_pushnumber(L, spellId); // 7: spellId + return 7; +} + +// GetSpellTexture(spellIdOrName) -> icon texture path string +static int lua_GetSpellTexture(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + if (!name || !*name) { lua_pushnil(L); return 1; } + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + if (spellId == 0) { lua_pushnil(L); return 1; } + std::string iconPath = gh->getSpellIconPath(spellId); + if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); + else lua_pushnil(L); + return 1; +} + +// GetItemInfo(itemId) -> name, link, quality, iLevel, reqLevel, class, subclass, maxStack, equipSlot, texture, vendorPrice +static int lua_GetItemInfo(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + + uint32_t itemId = 0; + if (lua_isnumber(L, 1)) { + itemId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + // Try to parse "item:12345" link format + const char* s = lua_tostring(L, 1); + std::string str(s ? s : ""); + auto pos = str.find("item:"); + if (pos != std::string::npos) { + try { itemId = static_cast(std::stoul(str.substr(pos + 5))); } catch (...) {} + } + } + if (itemId == 0) { lua_pushnil(L); return 1; } + + const auto* info = gh->getItemInfo(itemId); + if (!info) { lua_pushnil(L); return 1; } + + lua_pushstring(L, info->name.c_str()); // 1: name + // Build item link string: |cFFFFFFFF|Hitem:ID:0:0:0:0:0:0:0|h[Name]|h|r + char link[256]; + snprintf(link, sizeof(link), "|cFFFFFFFF|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + itemId, info->name.c_str()); + lua_pushstring(L, link); // 2: link + lua_pushnumber(L, info->quality); // 3: quality + lua_pushnumber(L, info->itemLevel); // 4: iLevel + lua_pushnumber(L, info->requiredLevel); // 5: requiredLevel + lua_pushstring(L, ""); // 6: class (type string) + lua_pushstring(L, ""); // 7: subclass + lua_pushnumber(L, info->maxStack > 0 ? info->maxStack : 1); // 8: maxStack + lua_pushstring(L, ""); // 9: equipSlot + // 10: texture (icon path from ItemDisplayInfo.dbc) + if (info->displayInfoId != 0) { + std::string iconPath = gh->getItemIconPath(info->displayInfoId); + if (!iconPath.empty()) lua_pushstring(L, iconPath.c_str()); + else lua_pushnil(L); + } else { + lua_pushnil(L); + } + lua_pushnumber(L, info->sellPrice); // 11: vendorPrice + return 11; +} + +// --- Locale/Build/Realm info --- + +static int lua_GetLocale(lua_State* L) { + lua_pushstring(L, "enUS"); + return 1; +} + +static int lua_GetBuildInfo(lua_State* L) { + // Return WotLK defaults; expansion-specific version detection would need + // access to the expansion registry which isn't available here. + lua_pushstring(L, "3.3.5a"); // 1: version + lua_pushnumber(L, 12340); // 2: buildNumber + lua_pushstring(L, "Jan 1 2025");// 3: date + lua_pushnumber(L, 30300); // 4: tocVersion + return 4; +} + +static int lua_GetCurrentMapAreaID(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? gh->getCurrentMapId() : 0); + return 1; +} + +// GetZoneText() / GetRealZoneText() → current zone name +static int lua_GetZoneText(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, ""); return 1; } + uint32_t zoneId = gh->getWorldStateZoneId(); + if (zoneId != 0) { + std::string name = gh->getWhoAreaName(zoneId); + if (!name.empty()) { lua_pushstring(L, name.c_str()); return 1; } + } + lua_pushstring(L, ""); + return 1; +} + +// GetSubZoneText() → subzone name (same as zone for now — server doesn't always send subzone) +static int lua_GetSubZoneText(lua_State* L) { + return lua_GetZoneText(L); // Best-effort: zone and subzone often overlap +} + +// GetMinimapZoneText() → zone name displayed near minimap +static int lua_GetMinimapZoneText(lua_State* L) { + return lua_GetZoneText(L); +} + +// --- World Map Navigation API --- + +// Map ID → continent mapping +static int mapIdToContinent(uint32_t mapId) { + switch (mapId) { + case 0: return 2; // Eastern Kingdoms + case 1: return 1; // Kalimdor + case 530: return 3; // Outland + case 571: return 4; // Northrend + default: return 0; // Instance or unknown + } +} + +// Internal tracked map state (which continent/zone the map UI is viewing) +static int s_mapContinent = 0; +static int s_mapZone = 0; + +// SetMapToCurrentZone() — sets map view to the player's current zone +static int lua_SetMapToCurrentZone(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) { + s_mapContinent = mapIdToContinent(gh->getCurrentMapId()); + s_mapZone = static_cast(gh->getWorldStateZoneId()); + } + return 0; +} + +// GetCurrentMapContinent() → continentId (1=Kalimdor, 2=EK, 3=Outland, 4=Northrend) +static int lua_GetCurrentMapContinent(lua_State* L) { + if (s_mapContinent == 0) { + auto* gh = getGameHandler(L); + if (gh) s_mapContinent = mapIdToContinent(gh->getCurrentMapId()); + } + lua_pushnumber(L, s_mapContinent); + return 1; +} + +// GetCurrentMapZone() → zoneId +static int lua_GetCurrentMapZone(lua_State* L) { + if (s_mapZone == 0) { + auto* gh = getGameHandler(L); + if (gh) s_mapZone = static_cast(gh->getWorldStateZoneId()); + } + lua_pushnumber(L, s_mapZone); + return 1; +} + +// SetMapZoom(continent [, zone]) — sets map view to continent/zone +static int lua_SetMapZoom(lua_State* L) { + s_mapContinent = static_cast(luaL_checknumber(L, 1)); + s_mapZone = static_cast(luaL_optnumber(L, 2, 0)); + return 0; +} + +// GetMapContinents() → "Kalimdor", "Eastern Kingdoms", ... +static int lua_GetMapContinents(lua_State* L) { + lua_pushstring(L, "Kalimdor"); + lua_pushstring(L, "Eastern Kingdoms"); + lua_pushstring(L, "Outland"); + lua_pushstring(L, "Northrend"); + return 4; +} + +// GetMapZones(continent) → zone names for that continent +// Returns a basic list; addons mainly need this to not error +static int lua_GetMapZones(lua_State* L) { + int cont = static_cast(luaL_checknumber(L, 1)); + // Return a minimal representative set per continent + switch (cont) { + case 1: // Kalimdor + lua_pushstring(L, "Durotar"); lua_pushstring(L, "Mulgore"); + lua_pushstring(L, "The Barrens"); lua_pushstring(L, "Teldrassil"); + return 4; + case 2: // Eastern Kingdoms + lua_pushstring(L, "Elwynn Forest"); lua_pushstring(L, "Westfall"); + lua_pushstring(L, "Dun Morogh"); lua_pushstring(L, "Tirisfal Glades"); + return 4; + case 3: // Outland + lua_pushstring(L, "Hellfire Peninsula"); lua_pushstring(L, "Zangarmarsh"); + return 2; + case 4: // Northrend + lua_pushstring(L, "Borean Tundra"); lua_pushstring(L, "Howling Fjord"); + return 2; + default: + return 0; + } +} + +// GetNumMapLandmarks() → 0 (no landmark data exposed yet) +static int lua_GetNumMapLandmarks(lua_State* L) { + lua_pushnumber(L, 0); + return 1; +} + +// --- Player State API --- +// These replace the hardcoded "return false" Lua stubs with real game state. + +static int lua_IsMounted(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isMounted()); + return 1; +} + +static int lua_IsFlying(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isPlayerFlying()); + return 1; +} + +static int lua_IsSwimming(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isSwimming()); + return 1; +} + +static int lua_IsResting(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isPlayerResting()); + return 1; +} + +static int lua_IsFalling(lua_State* L) { + auto* gh = getGameHandler(L); + // Check FALLING movement flag + if (!gh) { lua_pushboolean(L, 0); return 1; } + const auto& mi = gh->getMovementInfo(); + lua_pushboolean(L, (mi.flags & 0x2000) != 0); // MOVEFLAG_FALLING = 0x2000 + return 1; +} + +static int lua_IsStealthed(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + // Check for stealth auras (aura flags bit 0x40 = is harmful, stealth is a buff) + // WoW detects stealth via unit flags: UNIT_FLAG_IMMUNE (0x02) or specific aura IDs + // Simplified: check player auras for known stealth spell IDs + bool stealthed = false; + for (const auto& a : gh->getPlayerAuras()) { + if (a.isEmpty() || a.spellId == 0) continue; + // Common stealth IDs: 1784 (Stealth), 5215 (Prowl), 66 (Invisibility) + if (a.spellId == 1784 || a.spellId == 5215 || a.spellId == 66 || + a.spellId == 1785 || a.spellId == 1786 || a.spellId == 1787 || + a.spellId == 11305 || a.spellId == 11306) { + stealthed = true; + break; + } + } + lua_pushboolean(L, stealthed); + return 1; +} + +static int lua_GetUnitSpeed(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + if (!gh || std::string(uid) != "player") { + lua_pushnumber(L, 0); + return 1; + } + lua_pushnumber(L, gh->getServerRunSpeed()); + return 1; +} + +// --- Container/Bag API --- +// WoW bags: container 0 = backpack (16 slots), containers 1-4 = equipped bags + +static int lua_GetContainerNumSlots(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + if (!gh) { lua_pushnumber(L, 0); return 1; } + const auto& inv = gh->getInventory(); + if (container == 0) { + lua_pushnumber(L, inv.getBackpackSize()); + } else if (container >= 1 && container <= 4) { + lua_pushnumber(L, inv.getBagSize(container - 1)); + } else { + lua_pushnumber(L, 0); + } + return 1; +} + +// GetContainerItemInfo(container, slot) → texture, count, locked, quality, readable, lootable, link +static int lua_GetContainerItemInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + int slot = static_cast(luaL_checknumber(L, 2)); + if (!gh) { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const game::ItemSlot* itemSlot = nullptr; + + if (container == 0 && slot >= 1 && slot <= inv.getBackpackSize()) { + itemSlot = &inv.getBackpackSlot(slot - 1); // WoW uses 1-based + } else if (container >= 1 && container <= 4) { + int bagIdx = container - 1; + int bagSize = inv.getBagSize(bagIdx); + if (slot >= 1 && slot <= bagSize) + itemSlot = &inv.getBagSlot(bagIdx, slot - 1); + } + + if (!itemSlot || itemSlot->empty()) { lua_pushnil(L); return 1; } + + // Get item info for quality/icon + const auto* info = gh->getItemInfo(itemSlot->item.itemId); + + lua_pushnil(L); // texture (icon path — would need ItemDisplayInfo icon resolver) + lua_pushnumber(L, itemSlot->item.stackCount); // count + lua_pushboolean(L, 0); // locked + lua_pushnumber(L, info ? info->quality : 0); // quality + lua_pushboolean(L, 0); // readable + lua_pushboolean(L, 0); // lootable + // Build item link with quality color + std::string name = info ? info->name : ("Item #" + std::to_string(itemSlot->item.itemId)); + uint32_t q = info ? info->quality : 0; + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = q < 8 ? q : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], itemSlot->item.itemId, name.c_str()); + lua_pushstring(L, link); // link + return 7; +} + +// GetContainerItemLink(container, slot) → item link string +static int lua_GetContainerItemLink(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + int slot = static_cast(luaL_checknumber(L, 2)); + if (!gh) { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const game::ItemSlot* itemSlot = nullptr; + + if (container == 0 && slot >= 1 && slot <= inv.getBackpackSize()) { + itemSlot = &inv.getBackpackSlot(slot - 1); + } else if (container >= 1 && container <= 4) { + int bagIdx = container - 1; + int bagSize = inv.getBagSize(bagIdx); + if (slot >= 1 && slot <= bagSize) + itemSlot = &inv.getBagSlot(bagIdx, slot - 1); + } + + if (!itemSlot || itemSlot->empty()) { lua_pushnil(L); return 1; } + const auto* info = gh->getItemInfo(itemSlot->item.itemId); + std::string name = info ? info->name : ("Item #" + std::to_string(itemSlot->item.itemId)); + uint32_t q = info ? info->quality : 0; + char link[256]; + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = q < 8 ? q : 1u; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], itemSlot->item.itemId, name.c_str()); + lua_pushstring(L, link); + return 1; +} + +// GetContainerNumFreeSlots(container) → numFreeSlots, bagType +static int lua_GetContainerNumFreeSlots(lua_State* L) { + auto* gh = getGameHandler(L); + int container = static_cast(luaL_checknumber(L, 1)); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + + const auto& inv = gh->getInventory(); + int freeSlots = 0; + int totalSlots = 0; + + if (container == 0) { + totalSlots = inv.getBackpackSize(); + for (int i = 0; i < totalSlots; ++i) + if (inv.getBackpackSlot(i).empty()) ++freeSlots; + } else if (container >= 1 && container <= 4) { + totalSlots = inv.getBagSize(container - 1); + for (int i = 0; i < totalSlots; ++i) + if (inv.getBagSlot(container - 1, i).empty()) ++freeSlots; + } + + lua_pushnumber(L, freeSlots); + lua_pushnumber(L, 0); // bagType (0 = normal) + return 2; +} + +// --- Equipment Slot API --- +// WoW inventory slot IDs: 1=Head,2=Neck,3=Shoulders,4=Shirt,5=Chest, +// 6=Waist,7=Legs,8=Feet,9=Wrists,10=Hands,11=Ring1,12=Ring2, +// 13=Trinket1,14=Trinket2,15=Back,16=MainHand,17=OffHand,18=Ranged,19=Tabard + +// GetInventorySlotInfo("slotName") → slotId, textureName, checkRelic +// Maps WoW slot names (e.g. "HeadSlot", "HEADSLOT") to inventory slot IDs +static int lua_GetInventorySlotInfo(lua_State* L) { + const char* name = luaL_checkstring(L, 1); + std::string slot(name); + // Normalize: uppercase, strip trailing "SLOT" if present + for (char& c : slot) c = static_cast(std::toupper(static_cast(c))); + if (slot.size() > 4 && slot.substr(slot.size() - 4) == "SLOT") + slot = slot.substr(0, slot.size() - 4); + + // WoW inventory slots are 1-indexed + struct SlotMap { const char* name; int id; const char* texture; }; + static const SlotMap mapping[] = { + {"HEAD", 1, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Head"}, + {"NECK", 2, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Neck"}, + {"SHOULDER", 3, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Shoulder"}, + {"SHIRT", 4, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Shirt"}, + {"CHEST", 5, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Chest"}, + {"WAIST", 6, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Waist"}, + {"LEGS", 7, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Legs"}, + {"FEET", 8, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Feet"}, + {"WRIST", 9, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Wrists"}, + {"HANDS", 10, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Hands"}, + {"FINGER0", 11, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Finger"}, + {"FINGER1", 12, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Finger"}, + {"TRINKET0", 13, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Trinket"}, + {"TRINKET1", 14, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Trinket"}, + {"BACK", 15, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Chest"}, + {"MAINHAND", 16, "Interface\\PaperDoll\\UI-PaperDoll-Slot-MainHand"}, + {"SECONDARYHAND",17, "Interface\\PaperDoll\\UI-PaperDoll-Slot-SecondaryHand"}, + {"RANGED", 18, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Ranged"}, + {"TABARD", 19, "Interface\\PaperDoll\\UI-PaperDoll-Slot-Tabard"}, + }; + for (const auto& m : mapping) { + if (slot == m.name) { + lua_pushnumber(L, m.id); + lua_pushstring(L, m.texture); + lua_pushboolean(L, m.id == 18 ? 1 : 0); // checkRelic: only ranged slot + return 3; + } + } + luaL_error(L, "Unknown inventory slot: %s", name); + return 0; +} + +static int lua_GetInventoryItemLink(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + int slotId = static_cast(luaL_checknumber(L, 2)); + if (!gh || slotId < 1 || slotId > 19) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const auto& slot = inv.getEquipSlot(static_cast(slotId - 1)); + if (slot.empty()) { lua_pushnil(L); return 1; } + + const auto* info = gh->getItemInfo(slot.item.itemId); + std::string name = info ? info->name : slot.item.name; + uint32_t q = info ? info->quality : static_cast(slot.item.quality); + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = q < 8 ? q : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], slot.item.itemId, name.c_str()); + lua_pushstring(L, link); + return 1; +} + +static int lua_GetInventoryItemID(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + int slotId = static_cast(luaL_checknumber(L, 2)); + if (!gh || slotId < 1 || slotId > 19) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const auto& slot = inv.getEquipSlot(static_cast(slotId - 1)); + if (slot.empty()) { lua_pushnil(L); return 1; } + lua_pushnumber(L, slot.item.itemId); + return 1; +} + +static int lua_GetInventoryItemTexture(lua_State* L) { + auto* gh = getGameHandler(L); + const char* uid = luaL_optstring(L, 1, "player"); + int slotId = static_cast(luaL_checknumber(L, 2)); + if (!gh || slotId < 1 || slotId > 19) { lua_pushnil(L); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") { lua_pushnil(L); return 1; } + + const auto& inv = gh->getInventory(); + const auto& slot = inv.getEquipSlot(static_cast(slotId - 1)); + if (slot.empty()) { lua_pushnil(L); return 1; } + // Return spell icon path for the item's on-use spell, or nil + lua_pushnil(L); + return 1; +} + +// --- Time & XP API --- + +static int lua_GetGameTime(lua_State* L) { + // Returns server game time as hours, minutes + auto* gh = getGameHandler(L); + if (gh) { + float gt = gh->getGameTime(); + int hours = static_cast(gt) % 24; + int mins = static_cast((gt - static_cast(gt)) * 60.0f); + lua_pushnumber(L, hours); + lua_pushnumber(L, mins); + } else { + lua_pushnumber(L, 12); + lua_pushnumber(L, 0); + } + return 2; +} + +static int lua_GetServerTime(lua_State* L) { + lua_pushnumber(L, static_cast(std::time(nullptr))); + return 1; +} + +static int lua_UnitXP(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + std::string u(uid); + for (char& c : u) c = static_cast(std::tolower(static_cast(c))); + if (u == "player") lua_pushnumber(L, gh->getPlayerXp()); + else lua_pushnumber(L, 0); + return 1; +} + +static int lua_UnitXPMax(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 1); return 1; } + std::string u(uid); + for (char& c : u) c = static_cast(std::tolower(static_cast(c))); + if (u == "player") { + uint32_t nlxp = gh->getPlayerNextLevelXp(); + lua_pushnumber(L, nlxp > 0 ? nlxp : 1); + } else { + lua_pushnumber(L, 1); + } + return 1; +} + +// GetXPExhaustion() → rested XP pool remaining (nil if none) +static int lua_GetXPExhaustion(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + uint32_t rested = gh->getPlayerRestedXp(); + if (rested > 0) lua_pushnumber(L, rested); + else lua_pushnil(L); + return 1; +} + +// GetRestState() → 1 = normal, 2 = rested +static int lua_GetRestState(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, (gh && gh->isPlayerResting()) ? 2 : 1); + return 1; +} + +// --- Quest Log API --- + +static int lua_GetNumQuestLogEntries(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + const auto& ql = gh->getQuestLog(); + lua_pushnumber(L, ql.size()); // numEntries + lua_pushnumber(L, 0); // numQuests (headers not tracked) + return 2; +} + +// GetQuestLogTitle(index) → title, level, suggestedGroup, isHeader, isCollapsed, isComplete, frequency, questID +static int lua_GetQuestLogTitle(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + if (index > static_cast(ql.size())) { lua_pushnil(L); return 1; } + const auto& q = ql[index - 1]; // 1-based + lua_pushstring(L, q.title.c_str()); // title + lua_pushnumber(L, 0); // level (not tracked) + lua_pushnumber(L, 0); // suggestedGroup + lua_pushboolean(L, 0); // isHeader + lua_pushboolean(L, 0); // isCollapsed + lua_pushboolean(L, q.complete); // isComplete + lua_pushnumber(L, 0); // frequency + lua_pushnumber(L, q.questId); // questID + return 8; +} + +// GetQuestLogQuestText(index) → description, objectives +static int lua_GetQuestLogQuestText(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + if (index > static_cast(ql.size())) { lua_pushnil(L); return 1; } + const auto& q = ql[index - 1]; + lua_pushstring(L, ""); // description (not stored) + lua_pushstring(L, q.objectives.c_str()); // objectives + return 2; +} + +// IsQuestComplete(questID) → boolean +static int lua_IsQuestComplete(lua_State* L) { + auto* gh = getGameHandler(L); + uint32_t questId = static_cast(luaL_checknumber(L, 1)); + if (!gh) { lua_pushboolean(L, 0); return 1; } + for (const auto& q : gh->getQuestLog()) { + if (q.questId == questId) { + lua_pushboolean(L, q.complete); + return 1; + } + } + lua_pushboolean(L, 0); + return 1; +} + +// SelectQuestLogEntry(index) — select a quest in the quest log +static int lua_SelectQuestLogEntry(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (gh) gh->setSelectedQuestLogIndex(index); + return 0; +} + +// GetQuestLogSelection() → index +static int lua_GetQuestLogSelection(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? gh->getSelectedQuestLogIndex() : 0); + return 1; +} + +// GetNumQuestWatches() → count +static int lua_GetNumQuestWatches(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? gh->getTrackedQuestIds().size() : 0); + return 1; +} + +// GetQuestIndexForWatch(watchIndex) → questLogIndex +// Maps the Nth watched quest to its quest log index (1-based) +static int lua_GetQuestIndexForWatch(lua_State* L) { + auto* gh = getGameHandler(L); + int watchIdx = static_cast(luaL_checknumber(L, 1)); + if (!gh || watchIdx < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + const auto& tracked = gh->getTrackedQuestIds(); + int found = 0; + for (size_t i = 0; i < ql.size(); ++i) { + if (tracked.count(ql[i].questId)) { + found++; + if (found == watchIdx) { + lua_pushnumber(L, static_cast(i) + 1); // 1-based + return 1; + } + } + } + lua_pushnil(L); + return 1; +} + +// AddQuestWatch(questLogIndex) — add a quest to the watch list +static int lua_AddQuestWatch(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) return 0; + const auto& ql = gh->getQuestLog(); + if (index <= static_cast(ql.size())) { + gh->setQuestTracked(ql[index - 1].questId, true); + } + return 0; +} + +// RemoveQuestWatch(questLogIndex) — remove a quest from the watch list +static int lua_RemoveQuestWatch(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) return 0; + const auto& ql = gh->getQuestLog(); + if (index <= static_cast(ql.size())) { + gh->setQuestTracked(ql[index - 1].questId, false); + } + return 0; +} + +// IsQuestWatched(questLogIndex) → boolean +static int lua_IsQuestWatched(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushboolean(L, 0); return 1; } + const auto& ql = gh->getQuestLog(); + if (index <= static_cast(ql.size())) { + lua_pushboolean(L, gh->isQuestTracked(ql[index - 1].questId) ? 1 : 0); + } else { + lua_pushboolean(L, 0); + } + return 1; +} + +// GetQuestLink(questLogIndex) → "|cff...|Hquest:id:level|h[title]|h|r" +static int lua_GetQuestLink(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + if (index > static_cast(ql.size())) { lua_pushnil(L); return 1; } + const auto& q = ql[index - 1]; + // Yellow quest link format matching WoW + std::string link = "|cff808000|Hquest:" + std::to_string(q.questId) + + ":0|h[" + q.title + "]|h|r"; + lua_pushstring(L, link.c_str()); + return 1; +} + +// GetNumQuestLeaderBoards(questLogIndex) → count of objectives +static int lua_GetNumQuestLeaderBoards(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnumber(L, 0); return 1; } + const auto& ql = gh->getQuestLog(); + if (index > static_cast(ql.size())) { lua_pushnumber(L, 0); return 1; } + const auto& q = ql[index - 1]; + int count = 0; + for (const auto& ko : q.killObjectives) { + if (ko.npcOrGoId != 0 || ko.required > 0) ++count; + } + for (const auto& io : q.itemObjectives) { + if (io.itemId != 0 || io.required > 0) ++count; + } + lua_pushnumber(L, count); + return 1; +} + +// GetQuestLogLeaderBoard(objIndex, questLogIndex) → text, type, finished +// objIndex is 1-based within the quest's objectives +static int lua_GetQuestLogLeaderBoard(lua_State* L) { + auto* gh = getGameHandler(L); + int objIdx = static_cast(luaL_checknumber(L, 1)); + int questIdx = static_cast(luaL_optnumber(L, 2, + gh ? gh->getSelectedQuestLogIndex() : 0)); + if (!gh || questIdx < 1 || objIdx < 1) { lua_pushnil(L); return 1; } + const auto& ql = gh->getQuestLog(); + if (questIdx > static_cast(ql.size())) { lua_pushnil(L); return 1; } + const auto& q = ql[questIdx - 1]; + + // Build ordered list: kill objectives first, then item objectives + int cur = 0; + for (int i = 0; i < 4; ++i) { + if (q.killObjectives[i].npcOrGoId == 0 && q.killObjectives[i].required == 0) continue; + ++cur; + if (cur == objIdx) { + // Get current count from killCounts map (keyed by abs(npcOrGoId)) + uint32_t key = static_cast(std::abs(q.killObjectives[i].npcOrGoId)); + uint32_t current = 0; + auto it = q.killCounts.find(key); + if (it != q.killCounts.end()) current = it->second.first; + uint32_t required = q.killObjectives[i].required; + bool finished = (current >= required); + // Build display text like "Kobold Vermin slain: 3/8" + std::string text = (q.killObjectives[i].npcOrGoId < 0 ? "Object" : "Creature") + + std::string(" slain: ") + std::to_string(current) + "/" + std::to_string(required); + lua_pushstring(L, text.c_str()); + lua_pushstring(L, q.killObjectives[i].npcOrGoId < 0 ? "object" : "monster"); + lua_pushboolean(L, finished ? 1 : 0); + return 3; + } + } + for (int i = 0; i < 6; ++i) { + if (q.itemObjectives[i].itemId == 0 && q.itemObjectives[i].required == 0) continue; + ++cur; + if (cur == objIdx) { + uint32_t current = 0; + auto it = q.itemCounts.find(q.itemObjectives[i].itemId); + if (it != q.itemCounts.end()) current = it->second; + uint32_t required = q.itemObjectives[i].required; + bool finished = (current >= required); + // Get item name if available + std::string itemName; + const auto* info = gh->getItemInfo(q.itemObjectives[i].itemId); + if (info && !info->name.empty()) itemName = info->name; + else itemName = "Item #" + std::to_string(q.itemObjectives[i].itemId); + std::string text = itemName + ": " + std::to_string(current) + "/" + std::to_string(required); + lua_pushstring(L, text.c_str()); + lua_pushstring(L, "item"); + lua_pushboolean(L, finished ? 1 : 0); + return 3; + } + } + lua_pushnil(L); + return 1; +} + +// ExpandQuestHeader / CollapseQuestHeader — no-ops (flat quest list, no headers) +static int lua_ExpandQuestHeader(lua_State* L) { (void)L; return 0; } +static int lua_CollapseQuestHeader(lua_State* L) { (void)L; return 0; } + +// GetQuestLogSpecialItemInfo(questLogIndex) — returns nil (no special items) +static int lua_GetQuestLogSpecialItemInfo(lua_State* L) { (void)L; lua_pushnil(L); return 1; } + +// --- Skill Line API --- + +// GetNumSkillLines() → count +static int lua_GetNumSkillLines(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + lua_pushnumber(L, gh->getPlayerSkills().size()); + return 1; +} + +// GetSkillLineInfo(index) → skillName, isHeader, isExpanded, skillRank, numTempPoints, skillModifier, skillMaxRank, isAbandonable, stepCost, rankCost, minLevel, skillCostType +static int lua_GetSkillLineInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { + lua_pushnil(L); + return 1; + } + const auto& skills = gh->getPlayerSkills(); + if (index > static_cast(skills.size())) { + lua_pushnil(L); + return 1; + } + // Skills are in a map — iterate to the Nth entry + auto it = skills.begin(); + std::advance(it, index - 1); + const auto& skill = it->second; + std::string name = gh->getSkillName(skill.skillId); + if (name.empty()) name = "Skill " + std::to_string(skill.skillId); + + lua_pushstring(L, name.c_str()); // 1: skillName + lua_pushboolean(L, 0); // 2: isHeader (false — flat list) + lua_pushboolean(L, 1); // 3: isExpanded + lua_pushnumber(L, skill.effectiveValue()); // 4: skillRank + lua_pushnumber(L, skill.bonusTemp); // 5: numTempPoints + lua_pushnumber(L, skill.bonusPerm); // 6: skillModifier + lua_pushnumber(L, skill.maxValue); // 7: skillMaxRank + lua_pushboolean(L, 0); // 8: isAbandonable + lua_pushnumber(L, 0); // 9: stepCost + lua_pushnumber(L, 0); // 10: rankCost + lua_pushnumber(L, 0); // 11: minLevel + lua_pushnumber(L, 0); // 12: skillCostType + return 12; +} + +// --- Friends/Ignore API --- + +// GetNumFriends() → count +static int lua_GetNumFriends(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int count = 0; + for (const auto& c : gh->getContacts()) + if (c.isFriend()) count++; + lua_pushnumber(L, count); + return 1; +} + +// GetFriendInfo(index) → name, level, class, area, connected, status, note +static int lua_GetFriendInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { + lua_pushnil(L); return 1; + } + int found = 0; + for (const auto& c : gh->getContacts()) { + if (!c.isFriend()) continue; + if (++found == index) { + lua_pushstring(L, c.name.c_str()); // 1: name + lua_pushnumber(L, c.level); // 2: level + static const char* kClasses[] = {"","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + lua_pushstring(L, c.classId < 12 ? kClasses[c.classId] : "Unknown"); // 3: class + std::string area; + if (c.areaId != 0) area = gh->getWhoAreaName(c.areaId); + lua_pushstring(L, area.c_str()); // 4: area + lua_pushboolean(L, c.isOnline()); // 5: connected + lua_pushstring(L, c.status == 2 ? "" : (c.status == 3 ? "" : "")); // 6: status + lua_pushstring(L, c.note.c_str()); // 7: note + return 7; + } + } + lua_pushnil(L); + return 1; +} + +// --- Guild API --- + +// IsInGuild() → boolean +static int lua_IsInGuild(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushboolean(L, gh && gh->isInGuild()); + return 1; +} + +// GetGuildInfo("player") → guildName, guildRankName, guildRankIndex +static int lua_GetGuildInfoFunc(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh || !gh->isInGuild()) { lua_pushnil(L); return 1; } + lua_pushstring(L, gh->getGuildName().c_str()); + // Get rank name for the player + const auto& roster = gh->getGuildRoster(); + std::string rankName; + uint32_t rankIndex = 0; + for (const auto& m : roster.members) { + if (m.guid == gh->getPlayerGuid()) { + rankIndex = m.rankIndex; + const auto& rankNames = gh->getGuildRankNames(); + if (rankIndex < rankNames.size()) rankName = rankNames[rankIndex]; + break; + } + } + lua_pushstring(L, rankName.c_str()); + lua_pushnumber(L, rankIndex); + return 3; +} + +// GetNumGuildMembers() → totalMembers, onlineMembers +static int lua_GetNumGuildMembers(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 2; } + const auto& roster = gh->getGuildRoster(); + int online = 0; + for (const auto& m : roster.members) + if (m.online) online++; + lua_pushnumber(L, roster.members.size()); + lua_pushnumber(L, online); + return 2; +} + +// GetGuildRosterInfo(index) → name, rank, rankIndex, level, class, zone, note, officerNote, online, status, classId +static int lua_GetGuildRosterInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + const auto& roster = gh->getGuildRoster(); + if (index > static_cast(roster.members.size())) { lua_pushnil(L); return 1; } + const auto& m = roster.members[index - 1]; + + lua_pushstring(L, m.name.c_str()); // 1: name + const auto& rankNames = gh->getGuildRankNames(); + lua_pushstring(L, m.rankIndex < rankNames.size() + ? rankNames[m.rankIndex].c_str() : ""); // 2: rank name + lua_pushnumber(L, m.rankIndex); // 3: rankIndex + lua_pushnumber(L, m.level); // 4: level + static const char* kCls[] = {"","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + lua_pushstring(L, m.classId < 12 ? kCls[m.classId] : "Unknown"); // 5: class + std::string zone; + if (m.zoneId != 0 && m.online) zone = gh->getWhoAreaName(m.zoneId); + lua_pushstring(L, zone.c_str()); // 6: zone + lua_pushstring(L, m.publicNote.c_str()); // 7: note + lua_pushstring(L, m.officerNote.c_str()); // 8: officerNote + lua_pushboolean(L, m.online); // 9: online + lua_pushnumber(L, 0); // 10: status (0=online, 1=AFK, 2=DND) + lua_pushnumber(L, m.classId); // 11: classId (numeric) + return 11; +} + +// GetGuildRosterMOTD() → motd +static int lua_GetGuildRosterMOTD(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, ""); return 1; } + lua_pushstring(L, gh->getGuildRoster().motd.c_str()); + return 1; +} + +// GetNumIgnores() → count +static int lua_GetNumIgnores(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int count = 0; + for (const auto& c : gh->getContacts()) + if (c.isIgnored()) count++; + lua_pushnumber(L, count); + return 1; +} + +// GetIgnoreName(index) → name +static int lua_GetIgnoreName(lua_State* L) { + auto* gh = getGameHandler(L); + int index = static_cast(luaL_checknumber(L, 1)); + if (!gh || index < 1) { lua_pushnil(L); return 1; } + int found = 0; + for (const auto& c : gh->getContacts()) { + if (!c.isIgnored()) continue; + if (++found == index) { + lua_pushstring(L, c.name.c_str()); + return 1; + } + } + lua_pushnil(L); + return 1; +} + +// --- Talent API --- + +// GetNumTalentTabs() → count (usually 3) +static int lua_GetNumTalentTabs(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + // Count tabs matching the player's class + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + int count = 0; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) count++; + } + lua_pushnumber(L, count); + return 1; +} + +// GetTalentTabInfo(tabIndex) → name, iconTexture, pointsSpent, background +static int lua_GetTalentTabInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIndex = static_cast(luaL_checknumber(L, 1)); // 1-indexed + if (!gh || tabIndex < 1) { + lua_pushnil(L); return 1; + } + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + // Find the Nth tab for this class (sorted by orderIndex) + std::vector classTabs; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) classTabs.push_back(&tab); + } + std::sort(classTabs.begin(), classTabs.end(), + [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); + if (tabIndex > static_cast(classTabs.size())) { + lua_pushnil(L); return 1; + } + const auto* tab = classTabs[tabIndex - 1]; + // Count points spent in this tab + int pointsSpent = 0; + const auto& learned = gh->getLearnedTalents(); + for (const auto& [talentId, rank] : learned) { + const auto* entry = gh->getTalentEntry(talentId); + if (entry && entry->tabId == tab->tabId) pointsSpent += rank; + } + lua_pushstring(L, tab->name.c_str()); // 1: name + lua_pushnil(L); // 2: iconTexture (not resolved) + lua_pushnumber(L, pointsSpent); // 3: pointsSpent + lua_pushstring(L, tab->backgroundFile.c_str()); // 4: background + return 4; +} + +// GetNumTalents(tabIndex) → count +static int lua_GetNumTalents(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIndex = static_cast(luaL_checknumber(L, 1)); + if (!gh || tabIndex < 1) { lua_pushnumber(L, 0); return 1; } + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + std::vector classTabs; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) classTabs.push_back(&tab); + } + std::sort(classTabs.begin(), classTabs.end(), + [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); + if (tabIndex > static_cast(classTabs.size())) { + lua_pushnumber(L, 0); return 1; + } + uint32_t targetTabId = classTabs[tabIndex - 1]->tabId; + int count = 0; + for (const auto& [talentId, entry] : gh->getAllTalents()) { + if (entry.tabId == targetTabId) count++; + } + lua_pushnumber(L, count); + return 1; +} + +// GetTalentInfo(tabIndex, talentIndex) → name, iconTexture, tier, column, rank, maxRank, isExceptional, available +static int lua_GetTalentInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int tabIndex = static_cast(luaL_checknumber(L, 1)); + int talentIndex = static_cast(luaL_checknumber(L, 2)); + if (!gh || tabIndex < 1 || talentIndex < 1) { + for (int i = 0; i < 8; i++) lua_pushnil(L); + return 8; + } + uint8_t classId = gh->getPlayerClass(); + uint32_t classMask = (classId > 0) ? (1u << (classId - 1)) : 0; + std::vector classTabs; + for (const auto& [tabId, tab] : gh->getAllTalentTabs()) { + if (tab.classMask & classMask) classTabs.push_back(&tab); + } + std::sort(classTabs.begin(), classTabs.end(), + [](const auto* a, const auto* b) { return a->orderIndex < b->orderIndex; }); + if (tabIndex > static_cast(classTabs.size())) { + for (int i = 0; i < 8; i++) lua_pushnil(L); + return 8; + } + uint32_t targetTabId = classTabs[tabIndex - 1]->tabId; + // Collect talents for this tab, sorted by row then column + std::vector tabTalents; + for (const auto& [talentId, entry] : gh->getAllTalents()) { + if (entry.tabId == targetTabId) tabTalents.push_back(&entry); + } + std::sort(tabTalents.begin(), tabTalents.end(), + [](const auto* a, const auto* b) { + return (a->row != b->row) ? a->row < b->row : a->column < b->column; + }); + if (talentIndex > static_cast(tabTalents.size())) { + for (int i = 0; i < 8; i++) lua_pushnil(L); + return 8; + } + const auto* talent = tabTalents[talentIndex - 1]; + uint8_t rank = gh->getTalentRank(talent->talentId); + // Get spell name for rank 1 spell + std::string name = gh->getSpellName(talent->rankSpells[0]); + if (name.empty()) name = "Talent " + std::to_string(talent->talentId); + + lua_pushstring(L, name.c_str()); // 1: name + lua_pushnil(L); // 2: iconTexture + lua_pushnumber(L, talent->row + 1); // 3: tier (1-indexed) + lua_pushnumber(L, talent->column + 1); // 4: column (1-indexed) + lua_pushnumber(L, rank); // 5: rank + lua_pushnumber(L, talent->maxRank); // 6: maxRank + lua_pushboolean(L, 0); // 7: isExceptional + lua_pushboolean(L, 1); // 8: available + return 8; +} + +// GetActiveTalentGroup() → 1 or 2 +static int lua_GetActiveTalentGroup(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? (gh->getActiveTalentSpec() + 1) : 1); + return 1; +} + +// --- Loot API --- + +// GetNumLootItems() → count +static int lua_GetNumLootItems(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh || !gh->isLootWindowOpen()) { lua_pushnumber(L, 0); return 1; } + lua_pushnumber(L, gh->getCurrentLoot().items.size()); + return 1; +} + +// GetLootSlotInfo(slot) → texture, name, quantity, quality, locked +static int lua_GetLootSlotInfo(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); // 1-indexed + if (!gh || !gh->isLootWindowOpen()) { + lua_pushnil(L); return 1; + } + const auto& loot = gh->getCurrentLoot(); + if (slot < 1 || slot > static_cast(loot.items.size())) { + lua_pushnil(L); return 1; + } + const auto& item = loot.items[slot - 1]; + const auto* info = gh->getItemInfo(item.itemId); + + // texture (icon path from ItemDisplayInfo.dbc) + std::string icon; + if (info && info->displayInfoId != 0) { + icon = gh->getItemIconPath(info->displayInfoId); + } + if (!icon.empty()) lua_pushstring(L, icon.c_str()); + else lua_pushnil(L); + + // name + if (info && !info->name.empty()) lua_pushstring(L, info->name.c_str()); + else lua_pushstring(L, ("Item #" + std::to_string(item.itemId)).c_str()); + + lua_pushnumber(L, item.count); // quantity + lua_pushnumber(L, info ? info->quality : 1); // quality + lua_pushboolean(L, 0); // locked (not tracked) + return 5; +} + +// GetLootSlotLink(slot) → itemLink +static int lua_GetLootSlotLink(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); + if (!gh || !gh->isLootWindowOpen()) { lua_pushnil(L); return 1; } + const auto& loot = gh->getCurrentLoot(); + if (slot < 1 || slot > static_cast(loot.items.size())) { + lua_pushnil(L); return 1; + } + const auto& item = loot.items[slot - 1]; + const auto* info = gh->getItemInfo(item.itemId); + if (!info || info->name.empty()) { lua_pushnil(L); return 1; } + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = info->quality < 8 ? info->quality : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], item.itemId, info->name.c_str()); + lua_pushstring(L, link); + return 1; +} + +// LootSlot(slot) — take item from loot +static int lua_LootSlot(lua_State* L) { + auto* gh = getGameHandler(L); + int slot = static_cast(luaL_checknumber(L, 1)); + if (!gh || !gh->isLootWindowOpen()) return 0; + const auto& loot = gh->getCurrentLoot(); + if (slot < 1 || slot > static_cast(loot.items.size())) return 0; + gh->lootItem(loot.items[slot - 1].slotIndex); + return 0; +} + +// CloseLoot() — close loot window +static int lua_CloseLoot(lua_State* L) { + auto* gh = getGameHandler(L); + if (gh) gh->closeLoot(); + return 0; +} + +// GetLootMethod() → "freeforall"|"roundrobin"|"master"|"group"|"needbeforegreed", partyLoot, raidLoot +static int lua_GetLootMethod(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "freeforall"); lua_pushnumber(L, 0); lua_pushnumber(L, 0); return 3; } + const auto& pd = gh->getPartyData(); + const char* method = "freeforall"; + switch (pd.lootMethod) { + case 0: method = "freeforall"; break; + case 1: method = "roundrobin"; break; + case 2: method = "master"; break; + case 3: method = "group"; break; + case 4: method = "needbeforegreed"; break; + } + lua_pushstring(L, method); + lua_pushnumber(L, 0); // partyLootMaster (index) + lua_pushnumber(L, 0); // raidLootMaster (index) + return 3; +} + +// --- Additional WoW API --- + +static int lua_UnitAffectingCombat(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") { + lua_pushboolean(L, gh->isInCombat()); + } else { + // Check UNIT_FLAG_IN_COMBAT (0x00080000) in UNIT_FIELD_FLAGS + uint64_t guid = resolveUnitGuid(gh, uidStr); + bool inCombat = false; + if (guid != 0) { + auto entity = gh->getEntityManager().getEntity(guid); + if (entity) { + uint32_t flags = entity->getField( + game::fieldIndex(game::UF::UNIT_FIELD_FLAGS)); + inCombat = (flags & 0x00080000) != 0; // UNIT_FLAG_IN_COMBAT + } + } + lua_pushboolean(L, inCombat); + } + return 1; +} + +static int lua_GetNumRaidMembers(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh || !gh->isInGroup()) { lua_pushnumber(L, 0); return 1; } + const auto& pd = gh->getPartyData(); + lua_pushnumber(L, (pd.groupType == 1) ? pd.memberCount : 0); + return 1; +} + +static int lua_GetNumPartyMembers(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh || !gh->isInGroup()) { lua_pushnumber(L, 0); return 1; } + const auto& pd = gh->getPartyData(); + // In party (not raid), count excludes self + int count = (pd.groupType == 0) ? static_cast(pd.memberCount) : 0; + // memberCount includes self on some servers, subtract 1 if needed + if (count > 0) count = std::max(0, count - 1); + lua_pushnumber(L, count); + return 1; +} + +static int lua_UnitInParty(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr == "player") { + lua_pushboolean(L, gh->isInGroup()); + } else { + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushboolean(L, 0); return 1; } + const auto& pd = gh->getPartyData(); + bool found = false; + for (const auto& m : pd.members) { + if (m.guid == guid) { found = true; break; } + } + lua_pushboolean(L, found); + } + return 1; +} + +static int lua_UnitInRaid(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + const auto& pd = gh->getPartyData(); + if (pd.groupType != 1) { lua_pushboolean(L, 0); return 1; } + if (uidStr == "player") { + lua_pushboolean(L, 1); + return 1; + } + uint64_t guid = resolveUnitGuid(gh, uidStr); + bool found = false; + for (const auto& m : pd.members) { + if (m.guid == guid) { found = true; break; } + } + lua_pushboolean(L, found); + return 1; +} + +static int lua_UnitIsUnit(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid1 = luaL_checkstring(L, 1); + const char* uid2 = luaL_checkstring(L, 2); + std::string u1(uid1), u2(uid2); + for (char& c : u1) c = static_cast(std::tolower(static_cast(c))); + for (char& c : u2) c = static_cast(std::tolower(static_cast(c))); + uint64_t g1 = resolveUnitGuid(gh, u1); + uint64_t g2 = resolveUnitGuid(gh, u2); + lua_pushboolean(L, g1 != 0 && g1 == g2); + return 1; +} + +static int lua_UnitIsFriend(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + lua_pushboolean(L, unit && !unit->isHostile()); + return 1; +} + +static int lua_UnitIsEnemy(lua_State* L) { + const char* uid = luaL_optstring(L, 1, "player"); + auto* unit = resolveUnit(L, uid); + lua_pushboolean(L, unit && unit->isHostile()); + return 1; +} + +static int lua_UnitCreatureType(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "Unknown"); return 1; } + const char* uid = luaL_optstring(L, 1, "target"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushstring(L, "Unknown"); return 1; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity) { lua_pushstring(L, "Unknown"); return 1; } + // Player units are always "Humanoid" + if (entity->getType() == game::ObjectType::PLAYER) { + lua_pushstring(L, "Humanoid"); + return 1; + } + auto unit = std::dynamic_pointer_cast(entity); + if (!unit) { lua_pushstring(L, "Unknown"); return 1; } + uint32_t ct = gh->getCreatureType(unit->getEntry()); + static const char* kTypes[] = { + "Unknown", "Beast", "Dragonkin", "Demon", "Elemental", + "Giant", "Undead", "Humanoid", "Critter", "Mechanical", + "Not specified", "Totem", "Non-combat Pet", "Gas Cloud" + }; + lua_pushstring(L, (ct < 14) ? kTypes[ct] : "Unknown"); + return 1; +} + +// GetPlayerInfoByGUID(guid) → localizedClass, englishClass, localizedRace, englishRace, sex, name, realm +static int lua_GetPlayerInfoByGUID(lua_State* L) { + auto* gh = getGameHandler(L); + const char* guidStr = luaL_checkstring(L, 1); + if (!gh || !guidStr) { + for (int i = 0; i < 7; i++) lua_pushnil(L); + return 7; + } + // Parse hex GUID string "0x0000000000000001" + uint64_t guid = 0; + if (guidStr[0] == '0' && (guidStr[1] == 'x' || guidStr[1] == 'X')) + guid = strtoull(guidStr + 2, nullptr, 16); + else + guid = strtoull(guidStr, nullptr, 16); + + if (guid == 0) { for (int i = 0; i < 7; i++) lua_pushnil(L); return 7; } + + // Look up entity name + std::string name = gh->lookupName(guid); + if (name.empty() && guid == gh->getPlayerGuid()) { + const auto& chars = gh->getCharacters(); + for (const auto& c : chars) + if (c.guid == guid) { name = c.name; break; } + } + + // For player GUID, return class/race if it's the local player + const char* className = "Unknown"; + const char* raceName = "Unknown"; + if (guid == gh->getPlayerGuid()) { + static const char* kClasses[] = {"","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid"}; + static const char* kRaces[] = {"","Human","Orc","Dwarf","Night Elf","Undead", + "Tauren","Gnome","Troll","","Blood Elf","Draenei"}; + uint8_t cid = gh->getPlayerClass(); + uint8_t rid = gh->getPlayerRace(); + if (cid < 12) className = kClasses[cid]; + if (rid < 12) raceName = kRaces[rid]; + } + + lua_pushstring(L, className); // 1: localizedClass + lua_pushstring(L, className); // 2: englishClass + lua_pushstring(L, raceName); // 3: localizedRace + lua_pushstring(L, raceName); // 4: englishRace + lua_pushnumber(L, 0); // 5: sex (0=unknown) + lua_pushstring(L, name.c_str()); // 6: name + lua_pushstring(L, ""); // 7: realm + return 7; +} + +// GetItemLink(itemId) → "|cFFxxxxxx|Hitem:ID:...|h[Name]|h|r" +static int lua_GetItemLink(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + uint32_t itemId = static_cast(luaL_checknumber(L, 1)); + if (itemId == 0) { lua_pushnil(L); return 1; } + const auto* info = gh->getItemInfo(itemId); + if (!info || info->name.empty()) { lua_pushnil(L); return 1; } + static const char* kQH[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint32_t qi = info->quality < 8 ? info->quality : 1u; + char link[256]; + snprintf(link, sizeof(link), "|cff%s|Hitem:%u:0:0:0:0:0:0:0|h[%s]|h|r", + kQH[qi], itemId, info->name.c_str()); + lua_pushstring(L, link); + return 1; +} + +// GetSpellLink(spellIdOrName) → "|cFFxxxxxx|Hspell:ID|h[Name]|h|r" +static int lua_GetSpellLink(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + if (!name || !*name) { lua_pushnil(L); return 1; } + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + if (spellId == 0) { lua_pushnil(L); return 1; } + std::string name = gh->getSpellName(spellId); + if (name.empty()) { lua_pushnil(L); return 1; } + char link[256]; + snprintf(link, sizeof(link), "|cff71d5ff|Hspell:%u|h[%s]|h|r", spellId, name.c_str()); + lua_pushstring(L, link); + return 1; +} + +// IsUsableSpell(spellIdOrName) → usable, noMana +static int lua_IsUsableSpell(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); lua_pushboolean(L, 0); return 2; } + + uint32_t spellId = 0; + if (lua_isnumber(L, 1)) { + spellId = static_cast(lua_tonumber(L, 1)); + } else if (lua_isstring(L, 1)) { + const char* name = lua_tostring(L, 1); + if (!name || !*name) { lua_pushboolean(L, 0); lua_pushboolean(L, 0); return 2; } + std::string nameLow(name); + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + for (uint32_t sid : gh->getKnownSpells()) { + std::string sn = gh->getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == nameLow) { spellId = sid; break; } + } + } + + if (spellId == 0 || !gh->getKnownSpells().count(spellId)) { + lua_pushboolean(L, 0); + lua_pushboolean(L, 0); + return 2; + } + + // Check if on cooldown + float cd = gh->getSpellCooldown(spellId); + bool onCooldown = (cd > 0.1f); + + // Check mana/power cost + bool noMana = false; + if (!onCooldown) { + auto spellData = gh->getSpellData(spellId); + if (spellData.manaCost > 0) { + auto playerEntity = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (playerEntity) { + auto* unit = dynamic_cast(playerEntity.get()); + if (unit && unit->getPower() < spellData.manaCost) { + noMana = true; + } + } + } + } + lua_pushboolean(L, (onCooldown || noMana) ? 0 : 1); // usable + lua_pushboolean(L, noMana ? 1 : 0); // notEnoughMana + return 2; +} + +// IsInInstance() → isInstance, instanceType +static int lua_IsInInstance(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); lua_pushstring(L, "none"); return 2; } + bool inInstance = gh->isInInstance(); + lua_pushboolean(L, inInstance); + lua_pushstring(L, inInstance ? "party" : "none"); // simplified: "none", "party", "raid", "pvp", "arena" + return 2; +} + +// GetInstanceInfo() → name, type, difficultyIndex, difficultyName, maxPlayers, ... +static int lua_GetInstanceInfo(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { + lua_pushstring(L, ""); lua_pushstring(L, "none"); lua_pushnumber(L, 0); + lua_pushstring(L, "Normal"); lua_pushnumber(L, 0); + return 5; + } + std::string mapName = gh->getMapName(gh->getCurrentMapId()); + lua_pushstring(L, mapName.c_str()); // 1: name + lua_pushstring(L, gh->isInInstance() ? "party" : "none"); // 2: instanceType + lua_pushnumber(L, gh->getInstanceDifficulty()); // 3: difficultyIndex + static const char* kDiff[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; + uint32_t diff = gh->getInstanceDifficulty(); + lua_pushstring(L, (diff < 4) ? kDiff[diff] : "Normal"); // 4: difficultyName + lua_pushnumber(L, 5); // 5: maxPlayers (default 5-man) + return 5; +} + +// GetInstanceDifficulty() → difficulty (1=normal, 2=heroic, 3=25normal, 4=25heroic) +static int lua_GetInstanceDifficulty(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? (gh->getInstanceDifficulty() + 1) : 1); // WoW returns 1-based + return 1; +} + +// UnitClassification(unit) → "normal", "elite", "rareelite", "worldboss", "rare" +static int lua_UnitClassification(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushstring(L, "normal"); return 1; } + const char* uid = luaL_optstring(L, 1, "target"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushstring(L, "normal"); return 1; } + auto entity = gh->getEntityManager().getEntity(guid); + if (!entity || entity->getType() == game::ObjectType::PLAYER) { + lua_pushstring(L, "normal"); + return 1; + } + auto unit = std::dynamic_pointer_cast(entity); + if (!unit) { lua_pushstring(L, "normal"); return 1; } + int rank = gh->getCreatureRank(unit->getEntry()); + switch (rank) { + case 1: lua_pushstring(L, "elite"); break; + case 2: lua_pushstring(L, "rareelite"); break; + case 3: lua_pushstring(L, "worldboss"); break; + case 4: lua_pushstring(L, "rare"); break; + default: lua_pushstring(L, "normal"); break; + } + return 1; +} + +// GetComboPoints("player"|"vehicle", "target") → number +static int lua_GetComboPoints(lua_State* L) { + auto* gh = getGameHandler(L); + lua_pushnumber(L, gh ? gh->getComboPoints() : 0); + return 1; +} + +// UnitReaction(unit, otherUnit) → 1-8 (hostile to exalted) +// Simplified: hostile=2, neutral=4, friendly=5 +static int lua_UnitReaction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + const char* uid1 = luaL_checkstring(L, 1); + const char* uid2 = luaL_checkstring(L, 2); + auto* unit2 = resolveUnit(L, uid2); + if (!unit2) { lua_pushnil(L); return 1; } + // If unit2 is the player, always friendly to self + std::string u1(uid1); + for (char& c : u1) c = static_cast(std::tolower(static_cast(c))); + std::string u2(uid2); + for (char& c : u2) c = static_cast(std::tolower(static_cast(c))); + uint64_t g1 = resolveUnitGuid(gh, u1); + uint64_t g2 = resolveUnitGuid(gh, u2); + if (g1 == g2) { lua_pushnumber(L, 5); return 1; } // same unit = friendly + if (unit2->isHostile()) { + lua_pushnumber(L, 2); // hostile + } else { + lua_pushnumber(L, 5); // friendly + } + return 1; +} + +// UnitIsConnected(unit) → boolean +static int lua_UnitIsConnected(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + const char* uid = luaL_optstring(L, 1, "player"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + uint64_t guid = resolveUnitGuid(gh, uidStr); + if (guid == 0) { lua_pushboolean(L, 0); return 1; } + // Player is always connected + if (guid == gh->getPlayerGuid()) { lua_pushboolean(L, 1); return 1; } + // Check party/raid member online status + const auto& pd = gh->getPartyData(); + for (const auto& m : pd.members) { + if (m.guid == guid) { + lua_pushboolean(L, m.isOnline ? 1 : 0); + return 1; + } + } + // Non-party entities that exist are considered connected + auto entity = gh->getEntityManager().getEntity(guid); + lua_pushboolean(L, entity ? 1 : 0); + return 1; +} + +// HasAction(slot) → boolean (1-indexed slot) +static int lua_HasAction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); return 1; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; // WoW uses 1-indexed slots + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size())) { + lua_pushboolean(L, 0); + return 1; + } + lua_pushboolean(L, !bar[slot].isEmpty()); + return 1; +} + +// GetActionTexture(slot) → texturePath or nil +static int lua_GetActionTexture(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushnil(L); + return 1; + } + const auto& action = bar[slot]; + if (action.type == game::ActionBarSlot::SPELL) { + std::string icon = gh->getSpellIconPath(action.id); + if (!icon.empty()) { + lua_pushstring(L, icon.c_str()); + return 1; + } + } else if (action.type == game::ActionBarSlot::ITEM && action.id != 0) { + const auto* info = gh->getItemInfo(action.id); + if (info && info->displayInfoId != 0) { + std::string icon = gh->getItemIconPath(info->displayInfoId); + if (!icon.empty()) { + lua_pushstring(L, icon.c_str()); + return 1; + } + } + } + lua_pushnil(L); + return 1; +} + +// IsCurrentAction(slot) → boolean +static int lua_IsCurrentAction(lua_State* L) { + // Currently no "active action" tracking; return false + (void)L; + lua_pushboolean(L, 0); + return 1; +} + +// IsUsableAction(slot) → usable, notEnoughMana +static int lua_IsUsableAction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushboolean(L, 0); lua_pushboolean(L, 0); return 2; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushboolean(L, 0); + lua_pushboolean(L, 0); + return 2; + } + const auto& action = bar[slot]; + bool usable = action.isReady(); + bool noMana = false; + if (action.type == game::ActionBarSlot::SPELL) { + usable = usable && gh->getKnownSpells().count(action.id); + // Check power cost + if (usable && action.id != 0) { + auto spellData = gh->getSpellData(action.id); + if (spellData.manaCost > 0) { + auto pe = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (pe) { + auto* unit = dynamic_cast(pe.get()); + if (unit && unit->getPower() < spellData.manaCost) { + noMana = true; + usable = false; + } + } + } + } + } + lua_pushboolean(L, usable ? 1 : 0); + lua_pushboolean(L, noMana ? 1 : 0); + return 2; +} + +// IsActionInRange(slot) → 1 if in range, 0 if out, nil if no range check applicable +static int lua_IsActionInRange(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnil(L); return 1; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushnil(L); + return 1; + } + const auto& action = bar[slot]; + uint32_t spellId = 0; + if (action.type == game::ActionBarSlot::SPELL) { + spellId = action.id; + } else { + // Items/macros: no range check for now + lua_pushnil(L); + return 1; + } + if (spellId == 0) { lua_pushnil(L); return 1; } + + auto data = gh->getSpellData(spellId); + if (data.maxRange <= 0.0f) { + // Melee or self-cast spells: no range indicator + lua_pushnil(L); + return 1; + } + + // Need a target to check range against + uint64_t targetGuid = gh->getTargetGuid(); + if (targetGuid == 0) { lua_pushnil(L); return 1; } + auto targetEnt = gh->getEntityManager().getEntity(targetGuid); + auto playerEnt = gh->getEntityManager().getEntity(gh->getPlayerGuid()); + if (!targetEnt || !playerEnt) { lua_pushnil(L); return 1; } + + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + float dist = std::sqrt(dx*dx + dy*dy + dz*dz); + lua_pushnumber(L, dist <= data.maxRange ? 1 : 0); + return 1; +} + +// GetActionInfo(slot) → actionType, id, subType +static int lua_GetActionInfo(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { return 0; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + return 0; + } + const auto& action = bar[slot]; + switch (action.type) { + case game::ActionBarSlot::SPELL: + lua_pushstring(L, "spell"); + lua_pushnumber(L, action.id); + lua_pushstring(L, "spell"); + return 3; + case game::ActionBarSlot::ITEM: + lua_pushstring(L, "item"); + lua_pushnumber(L, action.id); + lua_pushstring(L, "item"); + return 3; + case game::ActionBarSlot::MACRO: + lua_pushstring(L, "macro"); + lua_pushnumber(L, action.id); + lua_pushstring(L, "macro"); + return 3; + default: + return 0; + } +} + +// GetActionCount(slot) → count (item stack count or 0) +static int lua_GetActionCount(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); return 1; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushnumber(L, 0); + return 1; + } + const auto& action = bar[slot]; + if (action.type == game::ActionBarSlot::ITEM && action.id != 0) { + // Count items across backpack + bags + uint32_t count = 0; + const auto& inv = gh->getInventory(); + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& s = inv.getBackpackSlot(i); + if (!s.empty() && s.item.itemId == action.id) + count += (s.item.stackCount > 0 ? s.item.stackCount : 1); + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + int bagSize = inv.getBagSize(b); + for (int i = 0; i < bagSize; ++i) { + const auto& s = inv.getBagSlot(b, i); + if (!s.empty() && s.item.itemId == action.id) + count += (s.item.stackCount > 0 ? s.item.stackCount : 1); + } + } + lua_pushnumber(L, count); + } else { + lua_pushnumber(L, 0); + } + return 1; +} + +// GetActionCooldown(slot) → start, duration, enable +static int lua_GetActionCooldown(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) { lua_pushnumber(L, 0); lua_pushnumber(L, 0); lua_pushnumber(L, 1); return 3; } + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) { + lua_pushnumber(L, 0); + lua_pushnumber(L, 0); + lua_pushnumber(L, 1); + return 3; + } + const auto& action = bar[slot]; + if (action.cooldownRemaining > 0.0f) { + // WoW returns GetTime()-based start time; approximate + double now = 0; + lua_getglobal(L, "GetTime"); + if (lua_isfunction(L, -1)) { + lua_call(L, 0, 1); + now = lua_tonumber(L, -1); + lua_pop(L, 1); + } else { + lua_pop(L, 1); + } + double start = now - (action.cooldownTotal - action.cooldownRemaining); + lua_pushnumber(L, start); + lua_pushnumber(L, action.cooldownTotal); + lua_pushnumber(L, 1); + } else { + lua_pushnumber(L, 0); + lua_pushnumber(L, 0); + lua_pushnumber(L, 1); + } + return 3; +} + +// UseAction(slot, checkCursor, onSelf) — activate action bar slot (1-indexed) +static int lua_UseAction(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + int slot = static_cast(luaL_checknumber(L, 1)) - 1; + const auto& bar = gh->getActionBar(); + if (slot < 0 || slot >= static_cast(bar.size()) || bar[slot].isEmpty()) return 0; + const auto& action = bar[slot]; + if (action.type == game::ActionBarSlot::SPELL && action.isReady()) { + uint64_t target = gh->hasTarget() ? gh->getTargetGuid() : 0; + gh->castSpell(action.id, target); + } else if (action.type == game::ActionBarSlot::ITEM && action.id != 0) { + gh->useItemById(action.id); + } + // Macro execution requires GameScreen context; not available from pure Lua API + return 0; +} + +// CancelUnitBuff(unit, index) — cancel a buff by index (1-indexed) +static int lua_CancelUnitBuff(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + const char* uid = luaL_optstring(L, 1, "player"); + std::string uidStr(uid); + for (char& c : uidStr) c = static_cast(std::tolower(static_cast(c))); + if (uidStr != "player") return 0; // Can only cancel own buffs + int index = static_cast(luaL_checknumber(L, 2)); + const auto& auras = gh->getPlayerAuras(); + // Find the Nth buff (non-debuff) + int buffCount = 0; + for (const auto& a : auras) { + if (a.isEmpty()) continue; + if ((a.flags & 0x80) != 0) continue; // skip debuffs + if (++buffCount == index) { + gh->cancelAura(a.spellId); + break; + } + } + return 0; +} + +// CastSpellByID(spellId) — cast spell by numeric ID +static int lua_CastSpellByID(lua_State* L) { + auto* gh = getGameHandler(L); + if (!gh) return 0; + uint32_t spellId = static_cast(luaL_checknumber(L, 1)); + if (spellId == 0) return 0; + uint64_t target = gh->hasTarget() ? gh->getTargetGuid() : 0; + gh->castSpell(spellId, target); + return 0; +} + +// --- Frame System --- +// Minimal WoW-compatible frame objects with RegisterEvent/SetScript/GetScript. +// Frames are Lua tables with a metatable that provides methods. + +// Frame method: frame:RegisterEvent("EVENT") +static int lua_Frame_RegisterEvent(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); // self + const char* eventName = luaL_checkstring(L, 2); + + // Get frame's registered events table (create if needed) + lua_getfield(L, 1, "__events"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setfield(L, 1, "__events"); + } + lua_pushboolean(L, 1); + lua_setfield(L, -2, eventName); + lua_pop(L, 1); + + // Also register in global __WoweeFrameEvents for dispatch + lua_getglobal(L, "__WoweeFrameEvents"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setglobal(L, "__WoweeFrameEvents"); + } + lua_getfield(L, -1, eventName); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setfield(L, -3, eventName); + } + // Append frame reference + int len = static_cast(lua_objlen(L, -1)); + lua_pushvalue(L, 1); // push frame + lua_rawseti(L, -2, len + 1); + lua_pop(L, 2); // pop list + __WoweeFrameEvents + return 0; +} + +// Frame method: frame:UnregisterEvent("EVENT") +static int lua_Frame_UnregisterEvent(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + const char* eventName = luaL_checkstring(L, 2); + + // Remove from frame's own events + lua_getfield(L, 1, "__events"); + if (lua_istable(L, -1)) { + lua_pushnil(L); + lua_setfield(L, -2, eventName); + } + lua_pop(L, 1); + return 0; +} + +// Frame method: frame:SetScript("handler", func) +static int lua_Frame_SetScript(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + const char* scriptType = luaL_checkstring(L, 2); + // arg 3 can be function or nil + lua_getfield(L, 1, "__scripts"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setfield(L, 1, "__scripts"); + } + lua_pushvalue(L, 3); + lua_setfield(L, -2, scriptType); + lua_pop(L, 1); + + // Track frames with OnUpdate in __WoweeOnUpdateFrames + if (strcmp(scriptType, "OnUpdate") == 0) { + lua_getglobal(L, "__WoweeOnUpdateFrames"); + if (!lua_istable(L, -1)) { lua_pop(L, 1); return 0; } + if (lua_isfunction(L, 3)) { + // Add frame to the list + int len = static_cast(lua_objlen(L, -1)); + lua_pushvalue(L, 1); + lua_rawseti(L, -2, len + 1); + } + lua_pop(L, 1); + } + return 0; +} + +// Frame method: frame:GetScript("handler") +static int lua_Frame_GetScript(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + const char* scriptType = luaL_checkstring(L, 2); + lua_getfield(L, 1, "__scripts"); + if (lua_istable(L, -1)) { + lua_getfield(L, -1, scriptType); + } else { + lua_pushnil(L); + } + return 1; +} + +// Frame method: frame:GetName() +static int lua_Frame_GetName(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__name"); + return 1; +} + +// Frame method: frame:Show() / frame:Hide() / frame:IsShown() / frame:IsVisible() +static int lua_Frame_Show(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushboolean(L, 1); + lua_setfield(L, 1, "__visible"); + return 0; +} +static int lua_Frame_Hide(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushboolean(L, 0); + lua_setfield(L, 1, "__visible"); + return 0; +} +static int lua_Frame_IsShown(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__visible"); + lua_pushboolean(L, lua_toboolean(L, -1)); + return 1; +} + +// Frame method: frame:CreateTexture(name, layer) → texture stub +static int lua_Frame_CreateTexture(lua_State* L) { + lua_newtable(L); + // Add noop methods for common texture operations + luaL_dostring(L, + "return function(t) " + "function t:SetTexture() end " + "function t:SetTexCoord() end " + "function t:SetVertexColor() end " + "function t:SetAllPoints() end " + "function t:SetPoint() end " + "function t:SetSize() end " + "function t:SetWidth() end " + "function t:SetHeight() end " + "function t:Show() end " + "function t:Hide() end " + "function t:SetAlpha() end " + "function t:GetTexture() return '' end " + "function t:SetDesaturated() end " + "function t:SetBlendMode() end " + "function t:SetDrawLayer() end " + "end"); + lua_pushvalue(L, -2); // push the table + lua_call(L, 1, 0); // call the function with the table + return 1; +} + +// Frame method: frame:CreateFontString(name, layer, template) → fontstring stub +static int lua_Frame_CreateFontString(lua_State* L) { + lua_newtable(L); + luaL_dostring(L, + "return function(fs) " + "fs._text = '' " + "function fs:SetText(t) self._text = t or '' end " + "function fs:GetText() return self._text end " + "function fs:SetFont() end " + "function fs:SetFontObject() end " + "function fs:SetTextColor() end " + "function fs:SetJustifyH() end " + "function fs:SetJustifyV() end " + "function fs:SetPoint() end " + "function fs:SetAllPoints() end " + "function fs:Show() end " + "function fs:Hide() end " + "function fs:SetAlpha() end " + "function fs:GetStringWidth() return 0 end " + "function fs:GetStringHeight() return 0 end " + "function fs:SetWordWrap() end " + "function fs:SetNonSpaceWrap() end " + "function fs:SetMaxLines() end " + "function fs:SetShadowOffset() end " + "function fs:SetShadowColor() end " + "function fs:SetWidth() end " + "function fs:SetHeight() end " + "end"); + lua_pushvalue(L, -2); + lua_call(L, 1, 0); + return 1; +} + +// GetFramerate() → fps +static int lua_GetFramerate(lua_State* L) { + lua_pushnumber(L, static_cast(ImGui::GetIO().Framerate)); + return 1; +} + +// GetCursorPosition() → x, y (screen coordinates, origin top-left) +static int lua_GetCursorPosition(lua_State* L) { + const auto& io = ImGui::GetIO(); + lua_pushnumber(L, io.MousePos.x); + lua_pushnumber(L, io.MousePos.y); + return 2; +} + +// GetScreenWidth() → width +static int lua_GetScreenWidth(lua_State* L) { + auto* window = core::Application::getInstance().getWindow(); + lua_pushnumber(L, window ? window->getWidth() : 1920); + return 1; +} + +// GetScreenHeight() → height +static int lua_GetScreenHeight(lua_State* L) { + auto* window = core::Application::getInstance().getWindow(); + lua_pushnumber(L, window ? window->getHeight() : 1080); + return 1; +} + +// Frame methods: SetPoint, SetSize, SetWidth, SetHeight, GetWidth, GetHeight, GetCenter, SetAlpha, GetAlpha +static int lua_Frame_SetPoint(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + const char* point = luaL_optstring(L, 2, "CENTER"); + // Store point info in frame table + lua_pushstring(L, point); + lua_setfield(L, 1, "__point"); + // Optional x/y offsets (args 4,5 if relativeTo is given, or 3,4 if not) + double xOfs = 0, yOfs = 0; + if (lua_isnumber(L, 4)) { xOfs = lua_tonumber(L, 4); yOfs = lua_tonumber(L, 5); } + else if (lua_isnumber(L, 3)) { xOfs = lua_tonumber(L, 3); yOfs = lua_tonumber(L, 4); } + lua_pushnumber(L, xOfs); + lua_setfield(L, 1, "__xOfs"); + lua_pushnumber(L, yOfs); + lua_setfield(L, 1, "__yOfs"); + return 0; +} + +static int lua_Frame_SetSize(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + double w = luaL_optnumber(L, 2, 0); + double h = luaL_optnumber(L, 3, 0); + lua_pushnumber(L, w); + lua_setfield(L, 1, "__width"); + lua_pushnumber(L, h); + lua_setfield(L, 1, "__height"); + return 0; +} + +static int lua_Frame_SetWidth(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnumber(L, luaL_checknumber(L, 2)); + lua_setfield(L, 1, "__width"); + return 0; +} + +static int lua_Frame_SetHeight(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnumber(L, luaL_checknumber(L, 2)); + lua_setfield(L, 1, "__height"); + return 0; +} + +static int lua_Frame_GetWidth(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__width"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); lua_pushnumber(L, 0); } + return 1; +} + +static int lua_Frame_GetHeight(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__height"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); lua_pushnumber(L, 0); } + return 1; +} + +static int lua_Frame_GetCenter(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__xOfs"); + double x = lua_isnumber(L, -1) ? lua_tonumber(L, -1) : 0; + lua_pop(L, 1); + lua_getfield(L, 1, "__yOfs"); + double y = lua_isnumber(L, -1) ? lua_tonumber(L, -1) : 0; + lua_pop(L, 1); + lua_pushnumber(L, x); + lua_pushnumber(L, y); + return 2; +} + +static int lua_Frame_SetAlpha(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_pushnumber(L, luaL_checknumber(L, 2)); + lua_setfield(L, 1, "__alpha"); + return 0; +} + +static int lua_Frame_GetAlpha(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__alpha"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); lua_pushnumber(L, 1.0); } + return 1; +} + +static int lua_Frame_SetParent(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + if (lua_istable(L, 2) || lua_isnil(L, 2)) { + lua_pushvalue(L, 2); + lua_setfield(L, 1, "__parent"); + } + return 0; +} + +static int lua_Frame_GetParent(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + lua_getfield(L, 1, "__parent"); + return 1; +} + +// CreateFrame(frameType, name, parent, template) +static int lua_CreateFrame(lua_State* L) { + const char* frameType = luaL_optstring(L, 1, "Frame"); + const char* name = luaL_optstring(L, 2, nullptr); + (void)frameType; // All frame types use the same table structure for now + + // Create the frame table + lua_newtable(L); + + // Set frame name + if (name && *name) { + lua_pushstring(L, name); + lua_setfield(L, -2, "__name"); + // Also set as a global so other addons can find it by name + lua_pushvalue(L, -1); + lua_setglobal(L, name); + } + + // Set initial visibility + lua_pushboolean(L, 1); + lua_setfield(L, -2, "__visible"); + + // Apply frame metatable with methods + lua_getglobal(L, "__WoweeFrameMT"); + lua_setmetatable(L, -2); + + return 1; +} + +// --- WoW Utility Functions --- + +// strsplit(delimiter, str) — WoW's string split +static int lua_strsplit(lua_State* L) { + const char* delim = luaL_checkstring(L, 1); + const char* str = luaL_checkstring(L, 2); + if (!delim[0]) { lua_pushstring(L, str); return 1; } + int count = 0; + std::string s(str); + size_t pos = 0; + while (pos <= s.size()) { + size_t found = s.find(delim[0], pos); + if (found == std::string::npos) { + lua_pushstring(L, s.substr(pos).c_str()); + count++; + break; + } + lua_pushstring(L, s.substr(pos, found - pos).c_str()); + count++; + pos = found + 1; + } + return count; +} + +// strtrim(str) — remove leading/trailing whitespace +static int lua_strtrim(lua_State* L) { + const char* str = luaL_checkstring(L, 1); + std::string s(str); + size_t start = s.find_first_not_of(" \t\r\n"); + size_t end = s.find_last_not_of(" \t\r\n"); + lua_pushstring(L, (start == std::string::npos) ? "" : s.substr(start, end - start + 1).c_str()); + return 1; +} + +// wipe(table) — clear all entries from a table +static int lua_wipe(lua_State* L) { + luaL_checktype(L, 1, LUA_TTABLE); + // Remove all integer keys + int len = static_cast(lua_objlen(L, 1)); + for (int i = len; i >= 1; i--) { + lua_pushnil(L); + lua_rawseti(L, 1, i); + } + // Remove all string keys + lua_pushnil(L); + while (lua_next(L, 1) != 0) { + lua_pop(L, 1); // pop value + lua_pushvalue(L, -1); // copy key + lua_pushnil(L); + lua_rawset(L, 1); // table[key] = nil + } + lua_pushvalue(L, 1); + return 1; +} + +// date(format) — safe date function (os.date was removed) +static int lua_wow_date(lua_State* L) { + const char* fmt = luaL_optstring(L, 1, "%c"); + time_t now = time(nullptr); + struct tm* tm = localtime(&now); + char buf[256]; + strftime(buf, sizeof(buf), fmt, tm); + lua_pushstring(L, buf); + return 1; +} + +// time() — current unix timestamp +static int lua_wow_time(lua_State* L) { + lua_pushnumber(L, static_cast(time(nullptr))); + return 1; +} + +// Stub for GetTime() — returns elapsed seconds +static int lua_wow_gettime(lua_State* L) { + static auto start = std::chrono::steady_clock::now(); + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - start).count(); + lua_pushnumber(L, elapsed); + return 1; +} + +LuaEngine::LuaEngine() = default; + +LuaEngine::~LuaEngine() { + shutdown(); +} + +bool LuaEngine::initialize() { + if (L_) return true; + + L_ = luaL_newstate(); + if (!L_) { + LOG_ERROR("LuaEngine: failed to create Lua state"); + return false; + } + + // Open safe standard libraries (no io, os, debug, package) + luaopen_base(L_); + luaopen_table(L_); + luaopen_string(L_); + luaopen_math(L_); + + // Remove unsafe globals from base library + const char* unsafeGlobals[] = { + "dofile", "loadfile", "load", "collectgarbage", "newproxy", nullptr + }; + for (const char** g = unsafeGlobals; *g; ++g) { + lua_pushnil(L_); + lua_setglobal(L_, *g); + } + + registerCoreAPI(); + registerEventAPI(); + + LOG_INFO("LuaEngine: initialized (Lua 5.1)"); + return true; +} + +void LuaEngine::shutdown() { + if (L_) { + lua_close(L_); + L_ = nullptr; + LOG_INFO("LuaEngine: shut down"); + } +} + +void LuaEngine::setGameHandler(game::GameHandler* handler) { + gameHandler_ = handler; + if (L_) { + lua_pushlightuserdata(L_, handler); + lua_setfield(L_, LUA_REGISTRYINDEX, "wowee_game_handler"); + } +} + +void LuaEngine::registerCoreAPI() { + // Override print() to go to chat + lua_pushcfunction(L_, lua_wow_print); + lua_setglobal(L_, "print"); + + // WoW API stubs + lua_pushcfunction(L_, lua_wow_message); + lua_setglobal(L_, "message"); + + lua_pushcfunction(L_, lua_wow_gettime); + lua_setglobal(L_, "GetTime"); + + // Unit API + static const struct { const char* name; lua_CFunction func; } unitAPI[] = { + {"UnitName", lua_UnitName}, + {"UnitHealth", lua_UnitHealth}, + {"UnitHealthMax", lua_UnitHealthMax}, + {"UnitPower", lua_UnitPower}, + {"UnitPowerMax", lua_UnitPowerMax}, + {"UnitMana", lua_UnitPower}, + {"UnitManaMax", lua_UnitPowerMax}, + {"UnitLevel", lua_UnitLevel}, + {"UnitExists", lua_UnitExists}, + {"UnitIsDead", lua_UnitIsDead}, + {"UnitIsGhost", lua_UnitIsGhost}, + {"UnitIsDeadOrGhost", lua_UnitIsDeadOrGhost}, + {"UnitIsAFK", lua_UnitIsAFK}, + {"UnitIsDND", lua_UnitIsDND}, + {"UnitPlayerControlled", lua_UnitPlayerControlled}, + {"UnitIsTapped", lua_UnitIsTapped}, + {"UnitIsTappedByPlayer", lua_UnitIsTappedByPlayer}, + {"UnitIsTappedByAllThreatList", lua_UnitIsTappedByAllThreatList}, + {"UnitIsVisible", lua_UnitIsVisible}, + {"UnitGroupRolesAssigned", lua_UnitGroupRolesAssigned}, + {"UnitCanAttack", lua_UnitCanAttack}, + {"UnitCanCooperate", lua_UnitCanCooperate}, + {"UnitCreatureFamily", lua_UnitCreatureFamily}, + {"UnitOnTaxi", lua_UnitOnTaxi}, + {"UnitThreatSituation", lua_UnitThreatSituation}, + {"UnitDetailedThreatSituation", lua_UnitDetailedThreatSituation}, + {"UnitSex", lua_UnitSex}, + {"UnitClass", lua_UnitClass}, + {"GetMoney", lua_GetMoney}, + {"UnitStat", lua_UnitStat}, + {"GetDodgeChance", lua_GetDodgeChance}, + {"GetParryChance", lua_GetParryChance}, + {"GetBlockChance", lua_GetBlockChance}, + {"GetCritChance", lua_GetCritChance}, + {"GetRangedCritChance", lua_GetRangedCritChance}, + {"GetSpellCritChance", lua_GetSpellCritChance}, + {"GetCombatRating", lua_GetCombatRating}, + {"GetSpellBonusDamage", lua_GetSpellBonusDamage}, + {"GetSpellBonusHealing", lua_GetSpellBonusHealing}, + {"GetAttackPowerForStat", lua_GetAttackPower}, + {"GetRangedAttackPower", lua_GetRangedAttackPower}, + {"IsInGroup", lua_IsInGroup}, + {"IsInRaid", lua_IsInRaid}, + {"GetPlayerMapPosition", lua_GetPlayerMapPosition}, + {"GetPlayerFacing", lua_GetPlayerFacing}, + {"GetCVar", lua_GetCVar}, + {"SetCVar", lua_SetCVar}, + {"SendChatMessage", lua_SendChatMessage}, + {"SendAddonMessage", lua_SendAddonMessage}, + {"RegisterAddonMessagePrefix", lua_RegisterAddonMessagePrefix}, + {"IsAddonMessagePrefixRegistered", lua_IsAddonMessagePrefixRegistered}, + {"CastSpellByName", lua_CastSpellByName}, + {"IsSpellKnown", lua_IsSpellKnown}, + {"GetNumSpellTabs", lua_GetNumSpellTabs}, + {"GetSpellTabInfo", lua_GetSpellTabInfo}, + {"GetSpellBookItemInfo", lua_GetSpellBookItemInfo}, + {"GetSpellBookItemName", lua_GetSpellBookItemName}, + {"GetSpellCooldown", lua_GetSpellCooldown}, + {"GetSpellPowerCost", lua_GetSpellPowerCost}, + {"IsSpellInRange", lua_IsSpellInRange}, + {"UnitDistanceSquared", lua_UnitDistanceSquared}, + {"CheckInteractDistance", lua_CheckInteractDistance}, + {"HasTarget", lua_HasTarget}, + {"TargetUnit", lua_TargetUnit}, + {"ClearTarget", lua_ClearTarget}, + {"FocusUnit", lua_FocusUnit}, + {"ClearFocus", lua_ClearFocus}, + {"AssistUnit", lua_AssistUnit}, + {"TargetLastTarget", lua_TargetLastTarget}, + {"TargetNearestEnemy", lua_TargetNearestEnemy}, + {"TargetNearestFriend", lua_TargetNearestFriend}, + {"GetRaidTargetIndex", lua_GetRaidTargetIndex}, + {"SetRaidTarget", lua_SetRaidTarget}, + {"UnitRace", lua_UnitRace}, + {"UnitPowerType", lua_UnitPowerType}, + {"GetNumGroupMembers", lua_GetNumGroupMembers}, + {"UnitGUID", lua_UnitGUID}, + {"UnitIsPlayer", lua_UnitIsPlayer}, + {"InCombatLockdown", lua_InCombatLockdown}, + {"UnitBuff", lua_UnitBuff}, + {"UnitDebuff", lua_UnitDebuff}, + {"UnitAura", lua_UnitAuraGeneric}, + {"UnitCastingInfo", lua_UnitCastingInfo}, + {"UnitChannelInfo", lua_UnitChannelInfo}, + {"GetNumAddOns", lua_GetNumAddOns}, + {"GetAddOnInfo", lua_GetAddOnInfo}, + {"GetAddOnMetadata", lua_GetAddOnMetadata}, + {"GetSpellInfo", lua_GetSpellInfo}, + {"GetSpellTexture", lua_GetSpellTexture}, + {"GetItemInfo", lua_GetItemInfo}, + {"GetLocale", lua_GetLocale}, + {"GetBuildInfo", lua_GetBuildInfo}, + {"GetCurrentMapAreaID", lua_GetCurrentMapAreaID}, + {"SetMapToCurrentZone", lua_SetMapToCurrentZone}, + {"GetCurrentMapContinent", lua_GetCurrentMapContinent}, + {"GetCurrentMapZone", lua_GetCurrentMapZone}, + {"SetMapZoom", lua_SetMapZoom}, + {"GetMapContinents", lua_GetMapContinents}, + {"GetMapZones", lua_GetMapZones}, + {"GetNumMapLandmarks", lua_GetNumMapLandmarks}, + {"GetZoneText", lua_GetZoneText}, + {"GetRealZoneText", lua_GetZoneText}, + {"GetSubZoneText", lua_GetSubZoneText}, + {"GetMinimapZoneText", lua_GetMinimapZoneText}, + // Player state (replaces hardcoded stubs) + {"IsMounted", lua_IsMounted}, + {"IsFlying", lua_IsFlying}, + {"IsSwimming", lua_IsSwimming}, + {"IsResting", lua_IsResting}, + {"IsFalling", lua_IsFalling}, + {"IsStealthed", lua_IsStealthed}, + {"GetUnitSpeed", lua_GetUnitSpeed}, + // Combat/group queries + {"UnitAffectingCombat", lua_UnitAffectingCombat}, + {"GetNumRaidMembers", lua_GetNumRaidMembers}, + {"GetNumPartyMembers", lua_GetNumPartyMembers}, + {"UnitInParty", lua_UnitInParty}, + {"UnitInRaid", lua_UnitInRaid}, + {"UnitIsUnit", lua_UnitIsUnit}, + {"UnitIsFriend", lua_UnitIsFriend}, + {"UnitIsEnemy", lua_UnitIsEnemy}, + {"UnitCreatureType", lua_UnitCreatureType}, + {"UnitClassification", lua_UnitClassification}, + {"GetPlayerInfoByGUID", lua_GetPlayerInfoByGUID}, + {"GetItemLink", lua_GetItemLink}, + {"GetSpellLink", lua_GetSpellLink}, + {"IsUsableSpell", lua_IsUsableSpell}, + {"IsInInstance", lua_IsInInstance}, + {"GetInstanceInfo", lua_GetInstanceInfo}, + {"GetInstanceDifficulty", lua_GetInstanceDifficulty}, + // Container/bag API + {"GetContainerNumSlots", lua_GetContainerNumSlots}, + {"GetContainerItemInfo", lua_GetContainerItemInfo}, + {"GetContainerItemLink", lua_GetContainerItemLink}, + {"GetContainerNumFreeSlots", lua_GetContainerNumFreeSlots}, + // Equipment slot API + {"GetInventorySlotInfo", lua_GetInventorySlotInfo}, + {"GetInventoryItemLink", lua_GetInventoryItemLink}, + {"GetInventoryItemID", lua_GetInventoryItemID}, + {"GetInventoryItemTexture", lua_GetInventoryItemTexture}, + // Time/XP API + {"GetGameTime", lua_GetGameTime}, + {"GetServerTime", lua_GetServerTime}, + {"UnitXP", lua_UnitXP}, + {"UnitXPMax", lua_UnitXPMax}, + {"GetXPExhaustion", lua_GetXPExhaustion}, + {"GetRestState", lua_GetRestState}, + // Quest log API + {"GetNumQuestLogEntries", lua_GetNumQuestLogEntries}, + {"GetQuestLogTitle", lua_GetQuestLogTitle}, + {"GetQuestLogQuestText", lua_GetQuestLogQuestText}, + {"IsQuestComplete", lua_IsQuestComplete}, + {"SelectQuestLogEntry", lua_SelectQuestLogEntry}, + {"GetQuestLogSelection", lua_GetQuestLogSelection}, + {"GetNumQuestWatches", lua_GetNumQuestWatches}, + {"GetQuestIndexForWatch", lua_GetQuestIndexForWatch}, + {"AddQuestWatch", lua_AddQuestWatch}, + {"RemoveQuestWatch", lua_RemoveQuestWatch}, + {"IsQuestWatched", lua_IsQuestWatched}, + {"GetQuestLink", lua_GetQuestLink}, + {"GetNumQuestLeaderBoards", lua_GetNumQuestLeaderBoards}, + {"GetQuestLogLeaderBoard", lua_GetQuestLogLeaderBoard}, + {"ExpandQuestHeader", lua_ExpandQuestHeader}, + {"CollapseQuestHeader", lua_CollapseQuestHeader}, + {"GetQuestLogSpecialItemInfo", lua_GetQuestLogSpecialItemInfo}, + // Skill line API + {"GetNumSkillLines", lua_GetNumSkillLines}, + {"GetSkillLineInfo", lua_GetSkillLineInfo}, + // Talent API + {"GetNumTalentTabs", lua_GetNumTalentTabs}, + {"GetTalentTabInfo", lua_GetTalentTabInfo}, + {"GetNumTalents", lua_GetNumTalents}, + {"GetTalentInfo", lua_GetTalentInfo}, + {"GetActiveTalentGroup", lua_GetActiveTalentGroup}, + // Friends/ignore API + // Guild API + {"IsInGuild", lua_IsInGuild}, + {"GetGuildInfo", lua_GetGuildInfoFunc}, + {"GetNumGuildMembers", lua_GetNumGuildMembers}, + {"GetGuildRosterInfo", lua_GetGuildRosterInfo}, + {"GetGuildRosterMOTD", lua_GetGuildRosterMOTD}, + {"GetNumFriends", lua_GetNumFriends}, + {"GetFriendInfo", lua_GetFriendInfo}, + {"GetNumIgnores", lua_GetNumIgnores}, + {"GetIgnoreName", lua_GetIgnoreName}, + // Reaction/connection queries + {"UnitReaction", lua_UnitReaction}, + {"UnitIsConnected", lua_UnitIsConnected}, + {"GetComboPoints", lua_GetComboPoints}, + // Action bar API + {"HasAction", lua_HasAction}, + {"GetActionTexture", lua_GetActionTexture}, + {"IsCurrentAction", lua_IsCurrentAction}, + {"IsUsableAction", lua_IsUsableAction}, + {"IsActionInRange", lua_IsActionInRange}, + {"GetActionInfo", lua_GetActionInfo}, + {"GetActionCount", lua_GetActionCount}, + {"GetActionCooldown", lua_GetActionCooldown}, + {"UseAction", lua_UseAction}, + {"CancelUnitBuff", lua_CancelUnitBuff}, + {"CastSpellByID", lua_CastSpellByID}, + // Loot API + {"GetNumLootItems", lua_GetNumLootItems}, + {"GetLootSlotInfo", lua_GetLootSlotInfo}, + {"GetLootSlotLink", lua_GetLootSlotLink}, + {"LootSlot", lua_LootSlot}, + {"CloseLoot", lua_CloseLoot}, + {"GetLootMethod", lua_GetLootMethod}, + // Utilities + {"strsplit", lua_strsplit}, + {"strtrim", lua_strtrim}, + {"wipe", lua_wipe}, + {"date", lua_wow_date}, + {"time", lua_wow_time}, + }; + for (const auto& [name, func] : unitAPI) { + lua_pushcfunction(L_, func); + lua_setglobal(L_, name); + } + + // WoW aliases + lua_getglobal(L_, "string"); + lua_getfield(L_, -1, "format"); + lua_setglobal(L_, "format"); + lua_pop(L_, 1); // pop string table + + // tinsert/tremove aliases + lua_getglobal(L_, "table"); + lua_getfield(L_, -1, "insert"); + lua_setglobal(L_, "tinsert"); + lua_getfield(L_, -1, "remove"); + lua_setglobal(L_, "tremove"); + lua_pop(L_, 1); // pop table + + // SlashCmdList table — addons register slash commands here + lua_newtable(L_); + lua_setglobal(L_, "SlashCmdList"); + + // Frame metatable with methods + lua_newtable(L_); // metatable + lua_pushvalue(L_, -1); + lua_setfield(L_, -2, "__index"); // metatable.__index = metatable + + static const struct luaL_Reg frameMethods[] = { + {"RegisterEvent", lua_Frame_RegisterEvent}, + {"UnregisterEvent", lua_Frame_UnregisterEvent}, + {"SetScript", lua_Frame_SetScript}, + {"GetScript", lua_Frame_GetScript}, + {"GetName", lua_Frame_GetName}, + {"Show", lua_Frame_Show}, + {"Hide", lua_Frame_Hide}, + {"IsShown", lua_Frame_IsShown}, + {"IsVisible", lua_Frame_IsShown}, // alias + {"SetPoint", lua_Frame_SetPoint}, + {"SetSize", lua_Frame_SetSize}, + {"SetWidth", lua_Frame_SetWidth}, + {"SetHeight", lua_Frame_SetHeight}, + {"GetWidth", lua_Frame_GetWidth}, + {"GetHeight", lua_Frame_GetHeight}, + {"GetCenter", lua_Frame_GetCenter}, + {"SetAlpha", lua_Frame_SetAlpha}, + {"GetAlpha", lua_Frame_GetAlpha}, + {"SetParent", lua_Frame_SetParent}, + {"GetParent", lua_Frame_GetParent}, + {"CreateTexture", lua_Frame_CreateTexture}, + {"CreateFontString", lua_Frame_CreateFontString}, + {nullptr, nullptr} + }; + for (const luaL_Reg* r = frameMethods; r->name; r++) { + lua_pushcfunction(L_, r->func); + lua_setfield(L_, -2, r->name); + } + lua_setglobal(L_, "__WoweeFrameMT"); + + // Add commonly called no-op frame methods to prevent addon errors + luaL_dostring(L_, + "local mt = __WoweeFrameMT\n" + "function mt:SetFrameLevel(level) self.__frameLevel = level end\n" + "function mt:GetFrameLevel() return self.__frameLevel or 1 end\n" + "function mt:SetFrameStrata(strata) self.__strata = strata end\n" + "function mt:GetFrameStrata() return self.__strata or 'MEDIUM' end\n" + "function mt:EnableMouse(enable) end\n" + "function mt:EnableMouseWheel(enable) end\n" + "function mt:SetMovable(movable) end\n" + "function mt:SetResizable(resizable) end\n" + "function mt:RegisterForDrag(...) end\n" + "function mt:SetClampedToScreen(clamped) end\n" + "function mt:SetBackdrop(backdrop) end\n" + "function mt:SetBackdropColor(...) end\n" + "function mt:SetBackdropBorderColor(...) end\n" + "function mt:ClearAllPoints() end\n" + "function mt:SetID(id) self.__id = id end\n" + "function mt:GetID() return self.__id or 0 end\n" + "function mt:SetScale(scale) self.__scale = scale end\n" + "function mt:GetScale() return self.__scale or 1.0 end\n" + "function mt:GetEffectiveScale() return self.__scale or 1.0 end\n" + "function mt:SetToplevel(top) end\n" + "function mt:Raise() end\n" + "function mt:Lower() end\n" + "function mt:GetLeft() return 0 end\n" + "function mt:GetRight() return 0 end\n" + "function mt:GetTop() return 0 end\n" + "function mt:GetBottom() return 0 end\n" + "function mt:GetNumPoints() return 0 end\n" + "function mt:GetPoint(n) return 'CENTER', nil, 'CENTER', 0, 0 end\n" + "function mt:SetHitRectInsets(...) end\n" + "function mt:RegisterForClicks(...) end\n" + "function mt:SetAttribute(name, value) self['attr_'..name] = value end\n" + "function mt:GetAttribute(name) return self['attr_'..name] end\n" + "function mt:HookScript(scriptType, fn)\n" + " local orig = self.__scripts and self.__scripts[scriptType]\n" + " if orig then\n" + " self:SetScript(scriptType, function(...) orig(...); fn(...) end)\n" + " else\n" + " self:SetScript(scriptType, fn)\n" + " end\n" + "end\n" + "function mt:SetMinResize(...) end\n" + "function mt:SetMaxResize(...) end\n" + "function mt:StartMoving() end\n" + "function mt:StopMovingOrSizing() end\n" + "function mt:IsMouseOver() return false end\n" + "function mt:GetObjectType() return 'Frame' end\n" + ); + + // CreateFrame function + lua_pushcfunction(L_, lua_CreateFrame); + lua_setglobal(L_, "CreateFrame"); + + // Cursor/screen/FPS functions + lua_pushcfunction(L_, lua_GetCursorPosition); + lua_setglobal(L_, "GetCursorPosition"); + lua_pushcfunction(L_, lua_GetScreenWidth); + lua_setglobal(L_, "GetScreenWidth"); + lua_pushcfunction(L_, lua_GetScreenHeight); + lua_setglobal(L_, "GetScreenHeight"); + lua_pushcfunction(L_, lua_GetFramerate); + lua_setglobal(L_, "GetFramerate"); + + // Frame event dispatch table + lua_newtable(L_); + lua_setglobal(L_, "__WoweeFrameEvents"); + + // OnUpdate frame tracking table + lua_newtable(L_); + lua_setglobal(L_, "__WoweeOnUpdateFrames"); + + // C_Timer implementation via Lua (uses OnUpdate internally) + luaL_dostring(L_, + "C_Timer = {}\n" + "local timers = {}\n" + "local timerFrame = CreateFrame('Frame', '__WoweeTimerFrame')\n" + "timerFrame:SetScript('OnUpdate', function(self, elapsed)\n" + " local i = 1\n" + " while i <= #timers do\n" + " timers[i].remaining = timers[i].remaining - elapsed\n" + " if timers[i].remaining <= 0 then\n" + " local cb = timers[i].callback\n" + " table.remove(timers, i)\n" + " cb()\n" + " else\n" + " i = i + 1\n" + " end\n" + " end\n" + " if #timers == 0 then self:Hide() end\n" + "end)\n" + "timerFrame:Hide()\n" + "function C_Timer.After(seconds, callback)\n" + " tinsert(timers, {remaining = seconds, callback = callback})\n" + " timerFrame:Show()\n" + "end\n" + "function C_Timer.NewTicker(seconds, callback, iterations)\n" + " local count = 0\n" + " local maxIter = iterations or -1\n" + " local ticker = {cancelled = false}\n" + " local function tick()\n" + " if ticker.cancelled then return end\n" + " count = count + 1\n" + " callback(ticker)\n" + " if maxIter > 0 and count >= maxIter then return end\n" + " C_Timer.After(seconds, tick)\n" + " end\n" + " C_Timer.After(seconds, tick)\n" + " function ticker:Cancel() self.cancelled = true end\n" + " return ticker\n" + "end\n" + ); + + // DEFAULT_CHAT_FRAME with AddMessage method (used by many addons) + luaL_dostring(L_, + "DEFAULT_CHAT_FRAME = {}\n" + "function DEFAULT_CHAT_FRAME:AddMessage(text, r, g, b)\n" + " if r and g and b then\n" + " local hex = format('|cff%02x%02x%02x', " + " math.floor(r*255), math.floor(g*255), math.floor(b*255))\n" + " print(hex .. tostring(text) .. '|r')\n" + " else\n" + " print(tostring(text))\n" + " end\n" + "end\n" + "ChatFrame1 = DEFAULT_CHAT_FRAME\n" + ); + + // hooksecurefunc — hook a function to run additional code after it + luaL_dostring(L_, + "function hooksecurefunc(tblOrName, nameOrFunc, funcOrNil)\n" + " local tbl, name, hook\n" + " if type(tblOrName) == 'table' then\n" + " tbl, name, hook = tblOrName, nameOrFunc, funcOrNil\n" + " else\n" + " tbl, name, hook = _G, tblOrName, nameOrFunc\n" + " end\n" + " local orig = tbl[name]\n" + " if type(orig) ~= 'function' then return end\n" + " tbl[name] = function(...)\n" + " local r = {orig(...)}\n" + " hook(...)\n" + " return unpack(r)\n" + " end\n" + "end\n" + ); + + // LibStub — universal library version management used by Ace3 and virtually all addon libs. + // This is the standard WoW LibStub implementation that addons embed/expect globally. + luaL_dostring(L_, + "local LibStub = LibStub or {}\n" + "LibStub.libs = LibStub.libs or {}\n" + "LibStub.minors = LibStub.minors or {}\n" + "function LibStub:NewLibrary(major, minor)\n" + " assert(type(major) == 'string', 'LibStub:NewLibrary: bad argument #1 (string expected)')\n" + " minor = assert(tonumber(minor or (type(minor) == 'string' and minor:match('(%d+)'))), 'LibStub:NewLibrary: bad argument #2 (number expected)')\n" + " local oldMinor = self.minors[major]\n" + " if oldMinor and oldMinor >= minor then return nil end\n" + " local lib = self.libs[major] or {}\n" + " self.libs[major] = lib\n" + " self.minors[major] = minor\n" + " return lib, oldMinor\n" + "end\n" + "function LibStub:GetLibrary(major, silent)\n" + " if not self.libs[major] and not silent then\n" + " error('Cannot find a library instance of \"' .. tostring(major) .. '\".')\n" + " end\n" + " return self.libs[major], self.minors[major]\n" + "end\n" + "function LibStub:IterateLibraries() return pairs(self.libs) end\n" + "setmetatable(LibStub, { __call = LibStub.GetLibrary })\n" + "_G['LibStub'] = LibStub\n" + ); + + // CallbackHandler-1.0 — minimal implementation for Ace3-based addons + luaL_dostring(L_, + "if LibStub then\n" + " local CBH = LibStub:NewLibrary('CallbackHandler-1.0', 7)\n" + " if CBH then\n" + " CBH.mixins = { 'RegisterCallback', 'UnregisterCallback', 'UnregisterAllCallbacks', 'Fire' }\n" + " function CBH:New(target, regName, unregName, unregAllName, onUsed)\n" + " local registry = setmetatable({}, { __index = CBH })\n" + " registry.callbacks = {}\n" + " target = target or {}\n" + " target[regName or 'RegisterCallback'] = function(self, event, method, ...)\n" + " if not registry.callbacks[event] then registry.callbacks[event] = {} end\n" + " local handler = type(method) == 'function' and method or self[method]\n" + " registry.callbacks[event][self] = handler\n" + " end\n" + " target[unregName or 'UnregisterCallback'] = function(self, event)\n" + " if registry.callbacks[event] then registry.callbacks[event][self] = nil end\n" + " end\n" + " target[unregAllName or 'UnregisterAllCallbacks'] = function(self)\n" + " for event, handlers in pairs(registry.callbacks) do handlers[self] = nil end\n" + " end\n" + " registry.Fire = function(self, event, ...)\n" + " if not self.callbacks[event] then return end\n" + " for obj, handler in pairs(self.callbacks[event]) do\n" + " handler(obj, event, ...)\n" + " end\n" + " end\n" + " return registry\n" + " end\n" + " end\n" + "end\n" + ); + + // Noop stubs for commonly called functions that don't need implementation + luaL_dostring(L_, + "function SetDesaturation() end\n" + "function SetPortraitTexture() end\n" + "function PlaySound() end\n" + "function PlaySoundFile() end\n" + "function StopSound() end\n" + "function UIParent_OnEvent() end\n" + "UIParent = CreateFrame('Frame', 'UIParent')\n" + "UIPanelWindows = {}\n" + "WorldFrame = CreateFrame('Frame', 'WorldFrame')\n" + // GameTooltip: global tooltip frame used by virtually all addons + "GameTooltip = CreateFrame('Frame', 'GameTooltip')\n" + "GameTooltip.__lines = {}\n" + "function GameTooltip:SetOwner(owner, anchor) self.__owner = owner; self.__anchor = anchor end\n" + "function GameTooltip:ClearLines() self.__lines = {} end\n" + "function GameTooltip:AddLine(text, r, g, b, wrap) table.insert(self.__lines, {text=text or '',r=r,g=g,b=b}) end\n" + "function GameTooltip:AddDoubleLine(l, r, lr, lg, lb, rr, rg, rb) table.insert(self.__lines, {text=(l or '')..' '..(r or '')}) end\n" + "function GameTooltip:SetText(text, r, g, b) self.__lines = {{text=text or '',r=r,g=g,b=b}} end\n" + "function GameTooltip:GetItem()\n" + " if self.__itemId and self.__itemId > 0 then\n" + " local name = GetItemInfo(self.__itemId)\n" + " return name, '|cffffffff|Hitem:'..self.__itemId..':0|h['..tostring(name)..']|h|r'\n" + " end\n" + " return nil\n" + "end\n" + "function GameTooltip:GetSpell()\n" + " if self.__spellId and self.__spellId > 0 then\n" + " local name = GetSpellInfo(self.__spellId)\n" + " return name, nil, self.__spellId\n" + " end\n" + " return nil\n" + "end\n" + "function GameTooltip:GetUnit() return nil end\n" + "function GameTooltip:NumLines() return #self.__lines end\n" + "function GameTooltip:GetText() return self.__lines[1] and self.__lines[1].text or '' end\n" + "function GameTooltip:SetUnitBuff(unit, index, filter)\n" + " self:ClearLines()\n" + " local name, rank, icon, count, debuffType, duration, expTime, caster, steal, consolidate, spellId = UnitBuff(unit, index, filter)\n" + " if name then\n" + " self:SetText(name, 1, 1, 1)\n" + " if duration and duration > 0 then\n" + " self:AddLine(string.format('%.0f sec remaining', expTime - GetTime()), 1, 1, 1)\n" + " end\n" + " self.__spellId = spellId\n" + " end\n" + "end\n" + "function GameTooltip:SetUnitDebuff(unit, index, filter)\n" + " self:ClearLines()\n" + " local name, rank, icon, count, debuffType, duration, expTime, caster, steal, consolidate, spellId = UnitDebuff(unit, index, filter)\n" + " if name then\n" + " self:SetText(name, 1, 0, 0)\n" + " if debuffType then self:AddLine(debuffType, 0.5, 0.5, 0.5) end\n" + " self.__spellId = spellId\n" + " end\n" + "end\n" + "function GameTooltip:SetHyperlink(link)\n" + " self:ClearLines()\n" + " if not link then return end\n" + " local id = link:match('item:(%d+)')\n" + " if id then\n" + " local name, _, quality = GetItemInfo(tonumber(id))\n" + " if name then self:SetText(name, 1, 1, 1) end\n" + " return\n" + " end\n" + " id = link:match('spell:(%d+)')\n" + " if id then\n" + " local name = GetSpellInfo(tonumber(id))\n" + " if name then self:SetText(name, 1, 1, 1) end\n" + " end\n" + "end\n" + "function GameTooltip:SetInventoryItem(unit, slot)\n" + " self:ClearLines()\n" + " if unit ~= 'player' then return false, false, 0 end\n" + " local link = GetInventoryItemLink(unit, slot)\n" + " if not link then return false, false, 0 end\n" + " local id = link:match('item:(%d+)')\n" + " if not id then return false, false, 0 end\n" + " local name, itemLink, quality, iLevel, reqLevel, class, subclass = GetItemInfo(tonumber(id))\n" + " if name then\n" + " local colors = {[0]={0.62,0.62,0.62},[1]={1,1,1},[2]={0.12,1,0},[3]={0,0.44,0.87},[4]={0.64,0.21,0.93},[5]={1,0.5,0},[6]={0.9,0.8,0.5}}\n" + " local c = colors[quality or 1] or {1,1,1}\n" + " self:SetText(name, c[1], c[2], c[3])\n" + " if class and class ~= '' then self:AddLine(class, 1, 1, 1) end\n" + " self.__itemId = tonumber(id)\n" + " end\n" + " return true, false, 0\n" + "end\n" + "function GameTooltip:SetBagItem(bag, slot)\n" + " self:ClearLines()\n" + " local tex, count, locked, quality, readable, lootable, link = GetContainerItemInfo(bag, slot)\n" + " if not link then return end\n" + " local id = link:match('item:(%d+)')\n" + " if not id then return end\n" + " local name, itemLink, q = GetItemInfo(tonumber(id))\n" + " if name then\n" + " local colors = {[0]={0.62,0.62,0.62},[1]={1,1,1},[2]={0.12,1,0},[3]={0,0.44,0.87},[4]={0.64,0.21,0.93},[5]={1,0.5,0}}\n" + " local c = colors[q or 1] or {1,1,1}\n" + " self:SetText(name, c[1], c[2], c[3])\n" + " if count and count > 1 then self:AddLine('Count: '..count, 1, 1, 1) end\n" + " self.__itemId = tonumber(id)\n" + " end\n" + "end\n" + "function GameTooltip:SetSpellByID(spellId)\n" + " self:ClearLines()\n" + " if not spellId or spellId == 0 then return end\n" + " local name, rank, icon = GetSpellInfo(spellId)\n" + " if name then\n" + " self:SetText(name, 1, 1, 1)\n" + " if rank and rank ~= '' then self:AddLine(rank, 0.5, 0.5, 0.5) end\n" + " self.__spellId = spellId\n" + " end\n" + "end\n" + "function GameTooltip:SetAction(slot)\n" + " self:ClearLines()\n" + " if not slot then return end\n" + " local actionType, id = GetActionInfo(slot)\n" + " if actionType == 'spell' and id and id > 0 then\n" + " self:SetSpellByID(id)\n" + " elseif actionType == 'item' and id and id > 0 then\n" + " local name, _, quality = GetItemInfo(id)\n" + " if name then self:SetText(name, 1, 1, 1) end\n" + " end\n" + "end\n" + "function GameTooltip:FadeOut() end\n" + "function GameTooltip:SetFrameStrata(...) end\n" + "function GameTooltip:SetClampedToScreen(...) end\n" + "function GameTooltip:IsOwned(f) return self.__owner == f end\n" + // ShoppingTooltip: used by comparison tooltips + "ShoppingTooltip1 = CreateFrame('Frame', 'ShoppingTooltip1')\n" + "ShoppingTooltip2 = CreateFrame('Frame', 'ShoppingTooltip2')\n" + // Error handling stubs (used by many addons) + "local _errorHandler = function(err) return err end\n" + "function geterrorhandler() return _errorHandler end\n" + "function seterrorhandler(fn) if type(fn)=='function' then _errorHandler=fn end end\n" + "function debugstack(start, count1, count2) return '' end\n" + "function securecall(fn, ...) if type(fn)=='function' then return fn(...) end end\n" + "function issecurevariable(...) return false end\n" + "function issecure() return false end\n" + // GetCVarBool wraps C-side GetCVar (registered in table) for boolean queries + "function GetCVarBool(name) return GetCVar(name) == '1' end\n" + // Misc compatibility stubs + // GetScreenWidth, GetScreenHeight, GetNumLootItems are now C functions + // GetFramerate is now a C function + "function GetNetStats() return 0, 0, 0, 0 end\n" + "function IsLoggedIn() return true end\n" + "function StaticPopup_Show() end\n" + "function StaticPopup_Hide() end\n" + // CreateTexture/CreateFontString are now C frame methods in the metatable + "do\n" + " local function cc(r,g,b)\n" + " local t = {r=r, g=g, b=b}\n" + " t.colorStr = string.format('%02x%02x%02x', math.floor(r*255), math.floor(g*255), math.floor(b*255))\n" + " function t:GenerateHexColor() return '|cff' .. self.colorStr end\n" + " function t:GenerateHexColorMarkup() return '|cff' .. self.colorStr end\n" + " return t\n" + " end\n" + " RAID_CLASS_COLORS = {\n" + " WARRIOR=cc(0.78,0.61,0.43), PALADIN=cc(0.96,0.55,0.73),\n" + " HUNTER=cc(0.67,0.83,0.45), ROGUE=cc(1.0,0.96,0.41),\n" + " PRIEST=cc(1.0,1.0,1.0), DEATHKNIGHT=cc(0.77,0.12,0.23),\n" + " SHAMAN=cc(0.0,0.44,0.87), MAGE=cc(0.41,0.80,0.94),\n" + " WARLOCK=cc(0.58,0.51,0.79), DRUID=cc(1.0,0.49,0.04),\n" + " }\n" + "end\n" + // Money formatting utility + "function GetCoinTextureString(copper)\n" + " if not copper or copper == 0 then return '0c' end\n" + " copper = math.floor(copper)\n" + " local g = math.floor(copper / 10000)\n" + " local s = math.floor(math.fmod(copper, 10000) / 100)\n" + " local c = math.fmod(copper, 100)\n" + " local r = ''\n" + " if g > 0 then r = r .. g .. 'g ' end\n" + " if s > 0 then r = r .. s .. 's ' end\n" + " if c > 0 or r == '' then r = r .. c .. 'c' end\n" + " return r\n" + "end\n" + "GetCoinText = GetCoinTextureString\n" + ); + + // UIDropDownMenu framework — minimal compat for addons using dropdown menus + luaL_dostring(L_, + "UIDROPDOWNMENU_MENU_LEVEL = 1\n" + "UIDROPDOWNMENU_MENU_VALUE = nil\n" + "UIDROPDOWNMENU_OPEN_MENU = nil\n" + "local _ddMenuList = {}\n" + "function UIDropDownMenu_Initialize(frame, initFunc, displayMode, level, menuList)\n" + " if frame then frame.__initFunc = initFunc end\n" + "end\n" + "function UIDropDownMenu_CreateInfo() return {} end\n" + "function UIDropDownMenu_AddButton(info, level) table.insert(_ddMenuList, info) end\n" + "function UIDropDownMenu_SetWidth(frame, width) end\n" + "function UIDropDownMenu_SetButtonWidth(frame, width) end\n" + "function UIDropDownMenu_SetText(frame, text)\n" + " if frame then frame.__text = text end\n" + "end\n" + "function UIDropDownMenu_GetText(frame)\n" + " return frame and frame.__text or ''\n" + "end\n" + "function UIDropDownMenu_SetSelectedID(frame, id) end\n" + "function UIDropDownMenu_SetSelectedValue(frame, value) end\n" + "function UIDropDownMenu_GetSelectedID(frame) return 1 end\n" + "function UIDropDownMenu_GetSelectedValue(frame) return nil end\n" + "function UIDropDownMenu_JustifyText(frame, justify) end\n" + "function UIDropDownMenu_EnableDropDown(frame) end\n" + "function UIDropDownMenu_DisableDropDown(frame) end\n" + "function CloseDropDownMenus() end\n" + "function ToggleDropDownMenu(level, value, frame, anchor, xOfs, yOfs) end\n" + ); + + // UISpecialFrames: frames in this list close on Escape key + luaL_dostring(L_, + "UISpecialFrames = {}\n" + // Font object stubs — addons reference these for CreateFontString templates + "GameFontNormal = {}\n" + "GameFontNormalSmall = {}\n" + "GameFontNormalLarge = {}\n" + "GameFontHighlight = {}\n" + "GameFontHighlightSmall = {}\n" + "GameFontHighlightLarge = {}\n" + "GameFontDisable = {}\n" + "GameFontDisableSmall = {}\n" + "GameFontWhite = {}\n" + "GameFontRed = {}\n" + "GameFontGreen = {}\n" + "NumberFontNormal = {}\n" + "ChatFontNormal = {}\n" + "SystemFont = {}\n" + // InterfaceOptionsFrame: addons register settings panels here + "InterfaceOptionsFrame = CreateFrame('Frame', 'InterfaceOptionsFrame')\n" + "InterfaceOptionsFramePanelContainer = CreateFrame('Frame', 'InterfaceOptionsFramePanelContainer')\n" + "function InterfaceOptions_AddCategory(panel) end\n" + "function InterfaceOptionsFrame_OpenToCategory(panel) end\n" + // Commonly expected global tables + "SLASH_RELOAD1 = '/reload'\n" + "SLASH_RELOADUI1 = '/reloadui'\n" + "GRAY_FONT_COLOR = {r=0.5,g=0.5,b=0.5}\n" + "NORMAL_FONT_COLOR = {r=1.0,g=0.82,b=0.0}\n" + "HIGHLIGHT_FONT_COLOR = {r=1.0,g=1.0,b=1.0}\n" + "GREEN_FONT_COLOR = {r=0.1,g=1.0,b=0.1}\n" + "RED_FONT_COLOR = {r=1.0,g=0.1,b=0.1}\n" + // C_ChatInfo — addon message prefix API used by some addons + "C_ChatInfo = C_ChatInfo or {}\n" + "C_ChatInfo.RegisterAddonMessagePrefix = RegisterAddonMessagePrefix\n" + "C_ChatInfo.IsAddonMessagePrefixRegistered = IsAddonMessagePrefixRegistered\n" + "C_ChatInfo.SendAddonMessage = SendAddonMessage\n" + ); + + // Action bar constants and functions used by action bar addons + luaL_dostring(L_, + "NUM_ACTIONBAR_BUTTONS = 12\n" + "NUM_ACTIONBAR_PAGES = 6\n" + "ACTION_BUTTON_SHOW_GRID_REASON_CVAR = 1\n" + "ACTION_BUTTON_SHOW_GRID_REASON_EVENT = 2\n" + // Action bar page tracking + "local _actionBarPage = 1\n" + "function GetActionBarPage() return _actionBarPage end\n" + "function ChangeActionBarPage(page) _actionBarPage = page end\n" + "function GetBonusBarOffset() return 0 end\n" + // Action type query + "function GetActionText(slot) return nil end\n" + "function GetActionCount(slot) return 0 end\n" + // Binding functions + "function GetBindingKey(action) return nil end\n" + "function GetBindingAction(key) return nil end\n" + "function SetBinding(key, action) end\n" + "function SaveBindings(which) end\n" + "function GetCurrentBindingSet() return 1 end\n" + // Macro functions + "function GetNumMacros() return 0, 0 end\n" + "function GetMacroInfo(id) return nil end\n" + "function GetMacroBody(id) return nil end\n" + "function GetMacroIndexByName(name) return 0 end\n" + // Stance bar + "function GetNumShapeshiftForms() return 0 end\n" + "function GetShapeshiftFormInfo(index) return nil, nil, nil, nil end\n" + // Pet action bar + "NUM_PET_ACTION_SLOTS = 10\n" + // Common WoW constants used by many addons + "MAX_TALENT_TABS = 3\n" + "MAX_NUM_TALENTS = 100\n" + "BOOKTYPE_SPELL = 0\n" + "BOOKTYPE_PET = 1\n" + "MAX_PARTY_MEMBERS = 4\n" + "MAX_RAID_MEMBERS = 40\n" + "MAX_ARENA_TEAMS = 3\n" + "INVSLOT_FIRST_EQUIPPED = 1\n" + "INVSLOT_LAST_EQUIPPED = 19\n" + "NUM_BAG_SLOTS = 4\n" + "NUM_BANKBAGSLOTS = 7\n" + "CONTAINER_BAG_OFFSET = 0\n" + "MAX_SKILLLINE_TABS = 8\n" + "TRADE_ENCHANT_SLOT = 7\n" + "function GetPetActionInfo(slot) return nil end\n" + "function GetPetActionsUsable() return false end\n" + ); + + // WoW table/string utility functions used by many addons + luaL_dostring(L_, + // Table utilities + "function tContains(tbl, item)\n" + " for _, v in pairs(tbl) do if v == item then return true end end\n" + " return false\n" + "end\n" + "function tInvert(tbl)\n" + " local inv = {}\n" + " for k, v in pairs(tbl) do inv[v] = k end\n" + " return inv\n" + "end\n" + "function CopyTable(src)\n" + " if type(src) ~= 'table' then return src end\n" + " local copy = {}\n" + " for k, v in pairs(src) do copy[k] = CopyTable(v) end\n" + " return setmetatable(copy, getmetatable(src))\n" + "end\n" + "function tDeleteItem(tbl, item)\n" + " for i = #tbl, 1, -1 do if tbl[i] == item then table.remove(tbl, i) end end\n" + "end\n" + // String utilities (WoW globals that alias Lua string functions) + "strupper = string.upper\n" + "strlower = string.lower\n" + "strfind = string.find\n" + "strsub = string.sub\n" + "strlen = string.len\n" + "strrep = string.rep\n" + "strbyte = string.byte\n" + "strchar = string.char\n" + "strrev = string.reverse\n" + "gsub = string.gsub\n" + "gmatch = string.gmatch\n" + "strjoin = function(delim, ...)\n" + " return table.concat({...}, delim)\n" + "end\n" + // Math utilities + "function Clamp(val, lo, hi) return math.min(math.max(val, lo), hi) end\n" + "function Round(val) return math.floor(val + 0.5) end\n" + // Bit operations (WoW provides these; Lua 5.1 doesn't have native bit ops) + "bit = bit or {}\n" + "bit.band = bit.band or function(a, b) local r,m=0,1 for i=0,31 do if a%2==1 and b%2==1 then r=r+m end a=math.floor(a/2) b=math.floor(b/2) m=m*2 end return r end\n" + "bit.bor = bit.bor or function(a, b) local r,m=0,1 for i=0,31 do if a%2==1 or b%2==1 then r=r+m end a=math.floor(a/2) b=math.floor(b/2) m=m*2 end return r end\n" + "bit.bxor = bit.bxor or function(a, b) local r,m=0,1 for i=0,31 do if (a%2==1)~=(b%2==1) then r=r+m end a=math.floor(a/2) b=math.floor(b/2) m=m*2 end return r end\n" + "bit.bnot = bit.bnot or function(a) return 4294967295 - a end\n" + "bit.lshift = bit.lshift or function(a, n) return a * (2^n) end\n" + "bit.rshift = bit.rshift or function(a, n) return math.floor(a / (2^n)) end\n" + ); +} + +// ---- Event System ---- +// Lua-side: WoweeEvents table holds { ["EVENT_NAME"] = { handler1, handler2, ... } } +// RegisterEvent("EVENT", handler) adds a handler function +// UnregisterEvent("EVENT", handler) removes it + +static int lua_RegisterEvent(lua_State* L) { + const char* eventName = luaL_checkstring(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + + // Get or create the WoweeEvents table + lua_getglobal(L, "__WoweeEvents"); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setglobal(L, "__WoweeEvents"); + } + + // Get or create the handler list for this event + lua_getfield(L, -1, eventName); + if (lua_isnil(L, -1)) { + lua_pop(L, 1); + lua_newtable(L); + lua_pushvalue(L, -1); + lua_setfield(L, -3, eventName); + } + + // Append the handler function to the list + int len = static_cast(lua_objlen(L, -1)); + lua_pushvalue(L, 2); // push the handler function + lua_rawseti(L, -2, len + 1); + + lua_pop(L, 2); // pop handler list + WoweeEvents + return 0; +} + +static int lua_UnregisterEvent(lua_State* L) { + const char* eventName = luaL_checkstring(L, 1); + luaL_checktype(L, 2, LUA_TFUNCTION); + + lua_getglobal(L, "__WoweeEvents"); + if (lua_isnil(L, -1)) { lua_pop(L, 1); return 0; } + + lua_getfield(L, -1, eventName); + if (lua_isnil(L, -1)) { lua_pop(L, 2); return 0; } + + // Remove matching handler from the list + int len = static_cast(lua_objlen(L, -1)); + for (int i = 1; i <= len; i++) { + lua_rawgeti(L, -1, i); + if (lua_rawequal(L, -1, 2)) { + lua_pop(L, 1); + // Shift remaining elements down + for (int j = i; j < len; j++) { + lua_rawgeti(L, -1, j + 1); + lua_rawseti(L, -2, j); + } + lua_pushnil(L); + lua_rawseti(L, -2, len); + break; + } + lua_pop(L, 1); + } + lua_pop(L, 2); + return 0; +} + +void LuaEngine::registerEventAPI() { + lua_pushcfunction(L_, lua_RegisterEvent); + lua_setglobal(L_, "RegisterEvent"); + + lua_pushcfunction(L_, lua_UnregisterEvent); + lua_setglobal(L_, "UnregisterEvent"); + + // Create the events table + lua_newtable(L_); + lua_setglobal(L_, "__WoweeEvents"); +} + +void LuaEngine::fireEvent(const std::string& eventName, + const std::vector& args) { + if (!L_) return; + + lua_getglobal(L_, "__WoweeEvents"); + if (lua_isnil(L_, -1)) { lua_pop(L_, 1); return; } + + lua_getfield(L_, -1, eventName.c_str()); + if (lua_isnil(L_, -1)) { lua_pop(L_, 2); return; } + + int handlerCount = static_cast(lua_objlen(L_, -1)); + for (int i = 1; i <= handlerCount; i++) { + lua_rawgeti(L_, -1, i); + if (!lua_isfunction(L_, -1)) { lua_pop(L_, 1); continue; } + + // Push arguments: event name first, then extra args + lua_pushstring(L_, eventName.c_str()); + for (const auto& arg : args) { + lua_pushstring(L_, arg.c_str()); + } + + int nargs = 1 + static_cast(args.size()); + if (lua_pcall(L_, nargs, 0, 0) != 0) { + const char* err = lua_tostring(L_, -1); + std::string errStr = err ? err : "(unknown)"; + LOG_ERROR("LuaEngine: event '", eventName, "' handler error: ", errStr); + if (luaErrorCallback_) luaErrorCallback_(errStr); + lua_pop(L_, 1); + } + } + lua_pop(L_, 2); // pop handler list + WoweeEvents + + // Also dispatch to frames that registered for this event via frame:RegisterEvent() + lua_getglobal(L_, "__WoweeFrameEvents"); + if (lua_istable(L_, -1)) { + lua_getfield(L_, -1, eventName.c_str()); + if (lua_istable(L_, -1)) { + int frameCount = static_cast(lua_objlen(L_, -1)); + for (int i = 1; i <= frameCount; i++) { + lua_rawgeti(L_, -1, i); + if (!lua_istable(L_, -1)) { lua_pop(L_, 1); continue; } + + // Get the frame's OnEvent script + lua_getfield(L_, -1, "__scripts"); + if (lua_istable(L_, -1)) { + lua_getfield(L_, -1, "OnEvent"); + if (lua_isfunction(L_, -1)) { + lua_pushvalue(L_, -3); // self (frame) + lua_pushstring(L_, eventName.c_str()); + for (const auto& arg : args) lua_pushstring(L_, arg.c_str()); + int nargs = 2 + static_cast(args.size()); + if (lua_pcall(L_, nargs, 0, 0) != 0) { + const char* ferr = lua_tostring(L_, -1); + std::string ferrStr = ferr ? ferr : "(unknown)"; + LOG_ERROR("LuaEngine: frame OnEvent error: ", ferrStr); + if (luaErrorCallback_) luaErrorCallback_(ferrStr); + lua_pop(L_, 1); + } + } else { + lua_pop(L_, 1); // pop non-function + } + } + lua_pop(L_, 2); // pop __scripts + frame + } + } + lua_pop(L_, 1); // pop event frame list + } + lua_pop(L_, 1); // pop __WoweeFrameEvents +} + +void LuaEngine::dispatchOnUpdate(float elapsed) { + if (!L_) return; + + lua_getglobal(L_, "__WoweeOnUpdateFrames"); + if (!lua_istable(L_, -1)) { lua_pop(L_, 1); return; } + + int count = static_cast(lua_objlen(L_, -1)); + for (int i = 1; i <= count; i++) { + lua_rawgeti(L_, -1, i); + if (!lua_istable(L_, -1)) { lua_pop(L_, 1); continue; } + + // Check if frame is visible + lua_getfield(L_, -1, "__visible"); + bool visible = lua_toboolean(L_, -1); + lua_pop(L_, 1); + if (!visible) { lua_pop(L_, 1); continue; } + + // Get OnUpdate script + lua_getfield(L_, -1, "__scripts"); + if (lua_istable(L_, -1)) { + lua_getfield(L_, -1, "OnUpdate"); + if (lua_isfunction(L_, -1)) { + lua_pushvalue(L_, -3); // self (frame) + lua_pushnumber(L_, static_cast(elapsed)); + if (lua_pcall(L_, 2, 0, 0) != 0) { + const char* uerr = lua_tostring(L_, -1); + std::string uerrStr = uerr ? uerr : "(unknown)"; + LOG_ERROR("LuaEngine: OnUpdate error: ", uerrStr); + if (luaErrorCallback_) luaErrorCallback_(uerrStr); + lua_pop(L_, 1); + } + } else { + lua_pop(L_, 1); + } + } + lua_pop(L_, 2); // pop __scripts + frame + } + lua_pop(L_, 1); // pop __WoweeOnUpdateFrames +} + +bool LuaEngine::dispatchSlashCommand(const std::string& command, const std::string& args) { + if (!L_) return false; + + // Check each SlashCmdList entry: for key NAME, check SLASH_NAME1, SLASH_NAME2, etc. + lua_getglobal(L_, "SlashCmdList"); + if (!lua_istable(L_, -1)) { lua_pop(L_, 1); return false; } + + std::string cmdLower = command; + for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); + + lua_pushnil(L_); + while (lua_next(L_, -2) != 0) { + // Stack: SlashCmdList, key, handler + if (!lua_isfunction(L_, -1) || !lua_isstring(L_, -2)) { + lua_pop(L_, 1); + continue; + } + const char* name = lua_tostring(L_, -2); + + // Check SLASH_1 through SLASH_9 + for (int i = 1; i <= 9; i++) { + std::string globalName = "SLASH_" + std::string(name) + std::to_string(i); + lua_getglobal(L_, globalName.c_str()); + if (lua_isstring(L_, -1)) { + std::string slashStr = lua_tostring(L_, -1); + for (char& c : slashStr) c = static_cast(std::tolower(static_cast(c))); + if (slashStr == cmdLower) { + lua_pop(L_, 1); // pop global + // Call the handler with args + lua_pushvalue(L_, -1); // copy handler + lua_pushstring(L_, args.c_str()); + if (lua_pcall(L_, 1, 0, 0) != 0) { + LOG_ERROR("LuaEngine: SlashCmdList['", name, "'] error: ", + lua_tostring(L_, -1)); + lua_pop(L_, 1); + } + lua_pop(L_, 3); // pop handler, key, SlashCmdList + return true; + } + } + lua_pop(L_, 1); // pop global + } + lua_pop(L_, 1); // pop handler, keep key for next iteration + } + lua_pop(L_, 1); // pop SlashCmdList + return false; +} + +// ---- SavedVariables serialization ---- + +static void serializeLuaValue(lua_State* L, int idx, std::string& out, int indent); + +static void serializeLuaTable(lua_State* L, int idx, std::string& out, int indent) { + out += "{\n"; + std::string pad(indent + 2, ' '); + lua_pushnil(L); + while (lua_next(L, idx) != 0) { + out += pad; + // Key + if (lua_type(L, -2) == LUA_TSTRING) { + const char* k = lua_tostring(L, -2); + out += "[\""; + for (const char* p = k; *p; ++p) { + if (*p == '"' || *p == '\\') out += '\\'; + out += *p; + } + out += "\"] = "; + } else if (lua_type(L, -2) == LUA_TNUMBER) { + out += "[" + std::to_string(static_cast(lua_tonumber(L, -2))) + "] = "; + } else { + lua_pop(L, 1); + continue; + } + // Value + serializeLuaValue(L, lua_gettop(L), out, indent + 2); + out += ",\n"; + lua_pop(L, 1); + } + out += std::string(indent, ' ') + "}"; +} + +static void serializeLuaValue(lua_State* L, int idx, std::string& out, int indent) { + switch (lua_type(L, idx)) { + case LUA_TNIL: out += "nil"; break; + case LUA_TBOOLEAN: out += lua_toboolean(L, idx) ? "true" : "false"; break; + case LUA_TNUMBER: { + double v = lua_tonumber(L, idx); + char buf[64]; + snprintf(buf, sizeof(buf), "%.17g", v); + out += buf; + break; + } + case LUA_TSTRING: { + const char* s = lua_tostring(L, idx); + out += "\""; + for (const char* p = s; *p; ++p) { + if (*p == '"' || *p == '\\') out += '\\'; + else if (*p == '\n') { out += "\\n"; continue; } + else if (*p == '\r') continue; + out += *p; + } + out += "\""; + break; + } + case LUA_TTABLE: + serializeLuaTable(L, idx, out, indent); + break; + default: + out += "nil"; // Functions, userdata, etc. can't be serialized + break; + } +} + +void LuaEngine::setAddonList(const std::vector& addons) { + if (!L_) return; + lua_pushnumber(L_, static_cast(addons.size())); + lua_setfield(L_, LUA_REGISTRYINDEX, "wowee_addon_count"); + + lua_newtable(L_); + for (size_t i = 0; i < addons.size(); i++) { + lua_newtable(L_); + lua_pushstring(L_, addons[i].addonName.c_str()); + lua_setfield(L_, -2, "name"); + lua_pushstring(L_, addons[i].getTitle().c_str()); + lua_setfield(L_, -2, "title"); + auto notesIt = addons[i].directives.find("Notes"); + lua_pushstring(L_, notesIt != addons[i].directives.end() ? notesIt->second.c_str() : ""); + lua_setfield(L_, -2, "notes"); + // Store all TOC directives for GetAddOnMetadata + lua_newtable(L_); + for (const auto& [key, val] : addons[i].directives) { + lua_pushstring(L_, val.c_str()); + lua_setfield(L_, -2, key.c_str()); + } + lua_setfield(L_, -2, "metadata"); + lua_rawseti(L_, -2, static_cast(i + 1)); + } + lua_setfield(L_, LUA_REGISTRYINDEX, "wowee_addon_info"); +} + +bool LuaEngine::loadSavedVariables(const std::string& path) { + if (!L_) return false; + std::ifstream f(path); + if (!f.is_open()) return false; // No saved data yet — not an error + std::string content((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (content.empty()) return true; + int err = luaL_dostring(L_, content.c_str()); + if (err != 0) { + LOG_WARNING("LuaEngine: error loading saved variables from '", path, "': ", + lua_tostring(L_, -1)); + lua_pop(L_, 1); + return false; + } + return true; +} + +bool LuaEngine::saveSavedVariables(const std::string& path, const std::vector& varNames) { + if (!L_ || varNames.empty()) return false; + std::string output; + for (const auto& name : varNames) { + lua_getglobal(L_, name.c_str()); + if (!lua_isnil(L_, -1)) { + output += name + " = "; + serializeLuaValue(L_, lua_gettop(L_), output, 0); + output += "\n"; + } + lua_pop(L_, 1); + } + if (output.empty()) return true; + + // Ensure directory exists + size_t lastSlash = path.find_last_of("/\\"); + if (lastSlash != std::string::npos) { + std::error_code ec; + std::filesystem::create_directories(path.substr(0, lastSlash), ec); + } + + std::ofstream f(path); + if (!f.is_open()) { + LOG_WARNING("LuaEngine: cannot write saved variables to '", path, "'"); + return false; + } + f << output; + LOG_INFO("LuaEngine: saved variables to '", path, "' (", output.size(), " bytes)"); + return true; +} + +bool LuaEngine::executeFile(const std::string& path) { + if (!L_) return false; + + int err = luaL_dofile(L_, path.c_str()); + if (err != 0) { + const char* errMsg = lua_tostring(L_, -1); + std::string msg = errMsg ? errMsg : "(unknown error)"; + LOG_ERROR("LuaEngine: error loading '", path, "': ", msg); + if (luaErrorCallback_) luaErrorCallback_(msg); + if (gameHandler_) { + game::MessageChatData errChat; + errChat.type = game::ChatType::SYSTEM; + errChat.language = game::ChatLanguage::UNIVERSAL; + errChat.message = "|cffff4040[Lua Error] " + msg + "|r"; + gameHandler_->addLocalChatMessage(errChat); + } + lua_pop(L_, 1); + return false; + } + return true; +} + +bool LuaEngine::executeString(const std::string& code) { + if (!L_) return false; + + int err = luaL_dostring(L_, code.c_str()); + if (err != 0) { + const char* errMsg = lua_tostring(L_, -1); + std::string msg = errMsg ? errMsg : "(unknown error)"; + LOG_ERROR("LuaEngine: script error: ", msg); + if (luaErrorCallback_) luaErrorCallback_(msg); + if (gameHandler_) { + game::MessageChatData errChat; + errChat.type = game::ChatType::SYSTEM; + errChat.language = game::ChatLanguage::UNIVERSAL; + errChat.message = "|cffff4040[Lua Error] " + msg + "|r"; + gameHandler_->addLocalChatMessage(errChat); + } + lua_pop(L_, 1); + return false; + } + return true; +} + +} // namespace wowee::addons diff --git a/src/addons/toc_parser.cpp b/src/addons/toc_parser.cpp new file mode 100644 index 00000000..523a164a --- /dev/null +++ b/src/addons/toc_parser.cpp @@ -0,0 +1,110 @@ +#include "addons/toc_parser.hpp" +#include +#include + +namespace wowee::addons { + +std::string TocFile::getTitle() const { + auto it = directives.find("Title"); + return (it != directives.end()) ? it->second : addonName; +} + +std::string TocFile::getInterface() const { + auto it = directives.find("Interface"); + return (it != directives.end()) ? it->second : ""; +} + +bool TocFile::isLoadOnDemand() const { + auto it = directives.find("LoadOnDemand"); + return (it != directives.end()) && it->second == "1"; +} + +static std::vector parseVarList(const std::string& val) { + std::vector result; + size_t pos = 0; + while (pos <= val.size()) { + size_t comma = val.find(',', pos); + std::string name = (comma != std::string::npos) ? val.substr(pos, comma - pos) : val.substr(pos); + size_t start = name.find_first_not_of(" \t"); + size_t end = name.find_last_not_of(" \t"); + if (start != std::string::npos) + result.push_back(name.substr(start, end - start + 1)); + if (comma == std::string::npos) break; + pos = comma + 1; + } + return result; +} + +std::vector TocFile::getSavedVariables() const { + auto it = directives.find("SavedVariables"); + return (it != directives.end()) ? parseVarList(it->second) : std::vector{}; +} + +std::vector TocFile::getSavedVariablesPerCharacter() const { + auto it = directives.find("SavedVariablesPerCharacter"); + return (it != directives.end()) ? parseVarList(it->second) : std::vector{}; +} + +std::optional parseTocFile(const std::string& tocPath) { + std::ifstream f(tocPath); + if (!f.is_open()) return std::nullopt; + + TocFile toc; + toc.basePath = tocPath; + // Strip filename to get directory + size_t lastSlash = tocPath.find_last_of("/\\"); + if (lastSlash != std::string::npos) { + toc.basePath = tocPath.substr(0, lastSlash); + toc.addonName = tocPath.substr(lastSlash + 1); + } + // Strip .toc extension from addon name + size_t dotPos = toc.addonName.rfind(".toc"); + if (dotPos != std::string::npos) toc.addonName.resize(dotPos); + + std::string line; + while (std::getline(f, line)) { + // Strip trailing CR (Windows line endings) + if (!line.empty() && line.back() == '\r') line.pop_back(); + + // Skip empty lines + if (line.empty()) continue; + + // ## directives + if (line.size() >= 3 && line[0] == '#' && line[1] == '#') { + std::string directive = line.substr(2); + size_t colon = directive.find(':'); + if (colon != std::string::npos) { + std::string key = directive.substr(0, colon); + std::string val = directive.substr(colon + 1); + // Trim whitespace + auto trim = [](std::string& s) { + size_t start = s.find_first_not_of(" \t"); + size_t end = s.find_last_not_of(" \t"); + s = (start == std::string::npos) ? "" : s.substr(start, end - start + 1); + }; + trim(key); + trim(val); + if (!key.empty()) toc.directives[key] = val; + } + continue; + } + + // Single # comment + if (line[0] == '#') continue; + + // Whitespace-only line + size_t firstNonSpace = line.find_first_not_of(" \t"); + if (firstNonSpace == std::string::npos) continue; + + // File entry — normalize backslashes to forward slashes + std::string filename = line.substr(firstNonSpace); + size_t lastNonSpace = filename.find_last_not_of(" \t"); + if (lastNonSpace != std::string::npos) filename.resize(lastNonSpace + 1); + std::replace(filename.begin(), filename.end(), '\\', '/'); + toc.files.push_back(std::move(filename)); + } + + return toc; +} + +} // namespace wowee::addons diff --git a/src/audio/ui_sound_manager.cpp b/src/audio/ui_sound_manager.cpp index f32f0d9b..6518259e 100644 --- a/src/audio/ui_sound_manager.cpp +++ b/src/audio/ui_sound_manager.cpp @@ -122,6 +122,20 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { deselectTargetSounds_.resize(1); loadSound("Sound\\Interface\\iDeselectTarget.wav", deselectTargetSounds_[0], assets); + // Whisper notification (falls back to iSelectTarget if the dedicated file is absent) + whisperSounds_.resize(1); + if (!loadSound("Sound\\Interface\\Whisper_TellMale.wav", whisperSounds_[0], assets)) { + if (!loadSound("Sound\\Interface\\Whisper_TellFemale.wav", whisperSounds_[0], assets)) { + whisperSounds_ = selectTargetSounds_; + } + } + + // Minimap ping sound + minimapPingSounds_.resize(1); + if (!loadSound("Sound\\Interface\\MapPing.wav", minimapPingSounds_[0], assets)) { + minimapPingSounds_ = selectTargetSounds_; // fallback to target select sound + } + LOG_INFO("UISoundManager: Window sounds - Bag: ", (bagOpenLoaded && bagCloseLoaded) ? "YES" : "NO", ", QuestLog: ", (questLogOpenLoaded && questLogCloseLoaded) ? "YES" : "NO", ", CharSheet: ", (charSheetOpenLoaded && charSheetCloseLoaded) ? "YES" : "NO"); @@ -225,5 +239,11 @@ void UiSoundManager::playError() { playSound(errorSounds_); } void UiSoundManager::playTargetSelect() { playSound(selectTargetSounds_); } void UiSoundManager::playTargetDeselect() { playSound(deselectTargetSounds_); } +// Chat notifications +void UiSoundManager::playWhisperReceived() { playSound(whisperSounds_); } + +// Minimap ping +void UiSoundManager::playMinimapPing() { playSound(minimapPingSounds_); } + } // namespace audio } // namespace wowee diff --git a/src/core/application.cpp b/src/core/application.cpp index 45ac82b9..49c40976 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -31,6 +31,7 @@ #include "audio/footstep_manager.hpp" #include "audio/activity_sound_manager.hpp" #include "audio/audio_engine.hpp" +#include "addons/addon_manager.hpp" #include #include "pipeline/m2_loader.hpp" #include "pipeline/wmo_loader.hpp" @@ -329,6 +330,263 @@ bool Application::initialize() { } } + // Initialize addon system + addonManager_ = std::make_unique(); + if (addonManager_->initialize(gameHandler.get())) { + std::string addonsDir = assetPath + "/interface/AddOns"; + addonManager_->scanAddons(addonsDir); + // Wire Lua errors to UI error display + addonManager_->getLuaEngine()->setLuaErrorCallback([gh = gameHandler.get()](const std::string& err) { + if (gh) gh->addUIError(err); + }); + // Wire chat messages to addon event dispatch + gameHandler->setAddonChatCallback([this](const game::MessageChatData& msg) { + if (!addonManager_ || !addonsLoaded_) return; + // Map ChatType to WoW event name + const char* eventName = nullptr; + switch (msg.type) { + case game::ChatType::SAY: eventName = "CHAT_MSG_SAY"; break; + case game::ChatType::YELL: eventName = "CHAT_MSG_YELL"; break; + case game::ChatType::WHISPER: eventName = "CHAT_MSG_WHISPER"; break; + case game::ChatType::PARTY: eventName = "CHAT_MSG_PARTY"; break; + case game::ChatType::GUILD: eventName = "CHAT_MSG_GUILD"; break; + case game::ChatType::OFFICER: eventName = "CHAT_MSG_OFFICER"; break; + case game::ChatType::RAID: eventName = "CHAT_MSG_RAID"; break; + case game::ChatType::RAID_WARNING: eventName = "CHAT_MSG_RAID_WARNING"; break; + case game::ChatType::BATTLEGROUND: eventName = "CHAT_MSG_BATTLEGROUND"; break; + case game::ChatType::SYSTEM: eventName = "CHAT_MSG_SYSTEM"; break; + case game::ChatType::CHANNEL: eventName = "CHAT_MSG_CHANNEL"; break; + case game::ChatType::EMOTE: + case game::ChatType::TEXT_EMOTE: eventName = "CHAT_MSG_EMOTE"; break; + case game::ChatType::ACHIEVEMENT: eventName = "CHAT_MSG_ACHIEVEMENT"; break; + case game::ChatType::GUILD_ACHIEVEMENT: eventName = "CHAT_MSG_GUILD_ACHIEVEMENT"; break; + case game::ChatType::WHISPER_INFORM: eventName = "CHAT_MSG_WHISPER_INFORM"; break; + case game::ChatType::RAID_LEADER: eventName = "CHAT_MSG_RAID_LEADER"; break; + case game::ChatType::BATTLEGROUND_LEADER: eventName = "CHAT_MSG_BATTLEGROUND_LEADER"; break; + case game::ChatType::MONSTER_SAY: eventName = "CHAT_MSG_MONSTER_SAY"; break; + case game::ChatType::MONSTER_YELL: eventName = "CHAT_MSG_MONSTER_YELL"; break; + case game::ChatType::MONSTER_EMOTE: eventName = "CHAT_MSG_MONSTER_EMOTE"; break; + case game::ChatType::MONSTER_WHISPER: eventName = "CHAT_MSG_MONSTER_WHISPER"; break; + case game::ChatType::RAID_BOSS_EMOTE: eventName = "CHAT_MSG_RAID_BOSS_EMOTE"; break; + case game::ChatType::RAID_BOSS_WHISPER: eventName = "CHAT_MSG_RAID_BOSS_WHISPER"; break; + case game::ChatType::BG_SYSTEM_NEUTRAL: eventName = "CHAT_MSG_BG_SYSTEM_NEUTRAL"; break; + case game::ChatType::BG_SYSTEM_ALLIANCE: eventName = "CHAT_MSG_BG_SYSTEM_ALLIANCE"; break; + case game::ChatType::BG_SYSTEM_HORDE: eventName = "CHAT_MSG_BG_SYSTEM_HORDE"; break; + case game::ChatType::MONSTER_PARTY: eventName = "CHAT_MSG_MONSTER_PARTY"; break; + case game::ChatType::AFK: eventName = "CHAT_MSG_AFK"; break; + case game::ChatType::DND: eventName = "CHAT_MSG_DND"; break; + case game::ChatType::LOOT: eventName = "CHAT_MSG_LOOT"; break; + case game::ChatType::SKILL: eventName = "CHAT_MSG_SKILL"; break; + default: break; + } + if (eventName) { + addonManager_->fireEvent(eventName, {msg.message, msg.senderName}); + } + }); + // Wire generic game events to addon dispatch + gameHandler->setAddonEventCallback([this](const std::string& event, const std::vector& args) { + if (addonManager_ && addonsLoaded_) { + addonManager_->fireEvent(event, args); + } + }); + // Wire spell icon path resolver for Lua API (GetSpellInfo, UnitBuff icon, etc.) + { + auto spellIconPaths = std::make_shared>(); + auto spellIconIds = std::make_shared>(); + auto loaded = std::make_shared(false); + auto* am = assetManager.get(); + gameHandler->setSpellIconPathResolver([spellIconPaths, spellIconIds, loaded, am](uint32_t spellId) -> std::string { + if (!am) return {}; + // Lazy-load SpellIcon.dbc + Spell.dbc icon IDs on first call + if (!*loaded) { + *loaded = true; + auto iconDbc = am->loadDBC("SpellIcon.dbc"); + const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; + if (iconDbc && iconDbc->isLoaded()) { + for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) { + uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0); + std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1); + if (!path.empty() && id > 0) (*spellIconPaths)[id] = path; + } + } + auto spellDbc = am->loadDBC("Spell.dbc"); + const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + if (spellDbc && spellDbc->isLoaded()) { + uint32_t fieldCount = spellDbc->getFieldCount(); + uint32_t iconField = 133; // WotLK default + uint32_t idField = 0; + if (spellL) { + uint32_t layoutIcon = (*spellL)["IconID"]; + if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { + iconField = layoutIcon; + idField = (*spellL)["ID"]; + } + } + for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) { + uint32_t id = spellDbc->getUInt32(i, idField); + uint32_t iconId = spellDbc->getUInt32(i, iconField); + if (id > 0 && iconId > 0) (*spellIconIds)[id] = iconId; + } + } + } + auto iit = spellIconIds->find(spellId); + if (iit == spellIconIds->end()) return {}; + auto pit = spellIconPaths->find(iit->second); + if (pit == spellIconPaths->end()) return {}; + return pit->second; + }); + } + // Wire item icon path resolver: displayInfoId -> "Interface\\Icons\\INV_..." + { + auto iconNames = std::make_shared>(); + auto loaded = std::make_shared(false); + auto* am = assetManager.get(); + gameHandler->setItemIconPathResolver([iconNames, loaded, am](uint32_t displayInfoId) -> std::string { + if (!am || displayInfoId == 0) return {}; + if (!*loaded) { + *loaded = true; + auto dbc = am->loadDBC("ItemDisplayInfo.dbc"); + const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + if (dbc && dbc->isLoaded()) { + uint32_t iconField = dispL ? (*dispL)["InventoryIcon"] : 5; + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); // field 0 = ID + std::string name = dbc->getString(i, iconField); + if (id > 0 && !name.empty()) (*iconNames)[id] = name; + } + LOG_INFO("Loaded ", iconNames->size(), " item icon names from ItemDisplayInfo.dbc"); + } + } + auto it = iconNames->find(displayInfoId); + if (it == iconNames->end()) return {}; + return "Interface\\Icons\\" + it->second; + }); + } + // Wire spell data resolver: spellId -> {castTimeMs, minRange, maxRange} + { + auto castTimeMap = std::make_shared>(); + auto rangeMap = std::make_shared>>(); + auto spellCastIdx = std::make_shared>(); // spellId→castTimeIdx + auto spellRangeIdx = std::make_shared>(); // spellId→rangeIdx + struct SpellCostEntry { uint32_t manaCost = 0; uint8_t powerType = 0; }; + auto spellCostMap = std::make_shared>(); + auto loaded = std::make_shared(false); + auto* am = assetManager.get(); + gameHandler->setSpellDataResolver([castTimeMap, rangeMap, spellCastIdx, spellRangeIdx, spellCostMap, loaded, am](uint32_t spellId) -> game::GameHandler::SpellDataInfo { + if (!am) return {}; + if (!*loaded) { + *loaded = true; + // Load SpellCastTimes.dbc + auto ctDbc = am->loadDBC("SpellCastTimes.dbc"); + if (ctDbc && ctDbc->isLoaded()) { + for (uint32_t i = 0; i < ctDbc->getRecordCount(); ++i) { + uint32_t id = ctDbc->getUInt32(i, 0); + int32_t base = static_cast(ctDbc->getUInt32(i, 1)); + if (id > 0 && base > 0) (*castTimeMap)[id] = static_cast(base); + } + } + // Load SpellRange.dbc + const auto* srL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") : nullptr; + uint32_t minRField = srL ? (*srL)["MinRange"] : 1; + uint32_t maxRField = srL ? (*srL)["MaxRange"] : 4; + auto rDbc = am->loadDBC("SpellRange.dbc"); + if (rDbc && rDbc->isLoaded()) { + for (uint32_t i = 0; i < rDbc->getRecordCount(); ++i) { + uint32_t id = rDbc->getUInt32(i, 0); + float minR = rDbc->getFloat(i, minRField); + float maxR = rDbc->getFloat(i, maxRField); + if (id > 0) (*rangeMap)[id] = {minR, maxR}; + } + } + // Load Spell.dbc: extract castTimeIndex and rangeIndex per spell + auto sDbc = am->loadDBC("Spell.dbc"); + const auto* spL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + if (sDbc && sDbc->isLoaded()) { + uint32_t idF = spL ? (*spL)["ID"] : 0; + uint32_t ctF = spL ? (*spL)["CastingTimeIndex"] : 134; // WotLK default + uint32_t rF = spL ? (*spL)["RangeIndex"] : 132; + uint32_t ptF = UINT32_MAX, mcF = UINT32_MAX; + if (spL) { + try { ptF = (*spL)["PowerType"]; } catch (...) {} + try { mcF = (*spL)["ManaCost"]; } catch (...) {} + } + uint32_t fc = sDbc->getFieldCount(); + for (uint32_t i = 0; i < sDbc->getRecordCount(); ++i) { + uint32_t id = sDbc->getUInt32(i, idF); + if (id == 0) continue; + uint32_t ct = sDbc->getUInt32(i, ctF); + uint32_t ri = sDbc->getUInt32(i, rF); + if (ct > 0) (*spellCastIdx)[id] = ct; + if (ri > 0) (*spellRangeIdx)[id] = ri; + // Extract power cost + uint32_t mc = (mcF < fc) ? sDbc->getUInt32(i, mcF) : 0; + uint8_t pt = (ptF < fc) ? static_cast(sDbc->getUInt32(i, ptF)) : 0; + if (mc > 0) (*spellCostMap)[id] = {mc, pt}; + } + } + LOG_INFO("SpellDataResolver: loaded ", spellCastIdx->size(), " cast indices, ", + spellRangeIdx->size(), " range indices"); + } + game::GameHandler::SpellDataInfo info; + auto ciIt = spellCastIdx->find(spellId); + if (ciIt != spellCastIdx->end()) { + auto ctIt = castTimeMap->find(ciIt->second); + if (ctIt != castTimeMap->end()) info.castTimeMs = ctIt->second; + } + auto riIt = spellRangeIdx->find(spellId); + if (riIt != spellRangeIdx->end()) { + auto rIt = rangeMap->find(riIt->second); + if (rIt != rangeMap->end()) { + info.minRange = rIt->second.first; + info.maxRange = rIt->second.second; + } + } + auto mcIt = spellCostMap->find(spellId); + if (mcIt != spellCostMap->end()) { + info.manaCost = mcIt->second.manaCost; + info.powerType = mcIt->second.powerType; + } + return info; + }); + } + // Wire random property/suffix name resolver for item display + { + auto propNames = std::make_shared>(); + auto propLoaded = std::make_shared(false); + auto* amPtr = assetManager.get(); + gameHandler->setRandomPropertyNameResolver([propNames, propLoaded, amPtr](int32_t id) -> std::string { + if (!amPtr || id == 0) return {}; + if (!*propLoaded) { + *propLoaded = true; + // ItemRandomProperties.dbc: ID=0, Name=4 (string) + if (auto dbc = amPtr->loadDBC("ItemRandomProperties.dbc"); dbc && dbc->isLoaded()) { + uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + int32_t rid = static_cast(dbc->getUInt32(r, 0)); + std::string name = dbc->getString(r, nameField); + if (!name.empty() && rid > 0) (*propNames)[rid] = name; + } + } + // ItemRandomSuffix.dbc: ID=0, Name=4 (string) — stored as negative IDs + if (auto dbc = amPtr->loadDBC("ItemRandomSuffix.dbc"); dbc && dbc->isLoaded()) { + uint32_t nameField = (dbc->getFieldCount() > 4) ? 4 : 1; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + int32_t rid = static_cast(dbc->getUInt32(r, 0)); + std::string name = dbc->getString(r, nameField); + if (!name.empty() && rid > 0) (*propNames)[-rid] = name; + } + } + } + auto it = propNames->find(id); + return (it != propNames->end()) ? it->second : std::string{}; + }); + } + LOG_INFO("Addon system initialized, found ", addonManager_->getAddons().size(), " addon(s)"); + } else { + LOG_WARNING("Failed to initialize addon system"); + addonManager_.reset(); + } + } else { LOG_WARNING("Failed to initialize asset manager - asset loading will be unavailable"); LOG_WARNING("Set WOW_DATA_PATH environment variable to your WoW Data directory"); @@ -391,143 +649,192 @@ void Application::run() { } auto lastTime = std::chrono::high_resolution_clock::now(); + std::atomic watchdogRunning{true}; + std::atomic watchdogHeartbeatMs{ + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count() + }; + std::thread watchdogThread([this, &watchdogRunning, &watchdogHeartbeatMs]() { + bool releasedForCurrentStall = false; + while (watchdogRunning.load(std::memory_order_acquire)) { + std::this_thread::sleep_for(std::chrono::milliseconds(250)); + const int64_t nowMs = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + const int64_t lastBeatMs = watchdogHeartbeatMs.load(std::memory_order_acquire); + const int64_t stallMs = nowMs - lastBeatMs; - while (running && !window->shouldClose()) { - // Calculate delta time - auto currentTime = std::chrono::high_resolution_clock::now(); - std::chrono::duration deltaTimeDuration = currentTime - lastTime; - float deltaTime = deltaTimeDuration.count(); - lastTime = currentTime; - - // Cap delta time to prevent large jumps - if (deltaTime > 0.1f) { - deltaTime = 0.1f; + // Failsafe: if the main loop stalls while relative mouse mode is active, + // forcibly release grab so the user can move the cursor and close the app. + if (stallMs > 1500) { + if (!releasedForCurrentStall) { + SDL_SetRelativeMouseMode(SDL_FALSE); + SDL_ShowCursor(SDL_ENABLE); + if (window && window->getSDLWindow()) { + SDL_SetWindowGrab(window->getSDLWindow(), SDL_FALSE); + } + LOG_WARNING("Main-loop stall detected (", stallMs, + "ms) — force-released mouse capture failsafe"); + releasedForCurrentStall = true; + } + } else { + releasedForCurrentStall = false; + } } + }); - // Poll events - SDL_Event event; - while (SDL_PollEvent(&event)) { - // Pass event to UI manager first - if (uiManager) { - uiManager->processEvent(event); + try { + while (running && !window->shouldClose()) { + watchdogHeartbeatMs.store( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(), + std::memory_order_release); + + // Calculate delta time + auto currentTime = std::chrono::high_resolution_clock::now(); + std::chrono::duration deltaTimeDuration = currentTime - lastTime; + float deltaTime = deltaTimeDuration.count(); + lastTime = currentTime; + + // Cap delta time to prevent large jumps + if (deltaTime > 0.1f) { + deltaTime = 0.1f; } - // Pass mouse events to camera controller (skip when UI has mouse focus) - if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) { - if (event.type == SDL_MOUSEMOTION) { - renderer->getCameraController()->processMouseMotion(event.motion); + // Poll events + SDL_Event event; + while (SDL_PollEvent(&event)) { + // Pass event to UI manager first + if (uiManager) { + uiManager->processEvent(event); } - else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { - renderer->getCameraController()->processMouseButton(event.button); + + // Pass mouse events to camera controller (skip when UI has mouse focus) + if (renderer && renderer->getCameraController() && !ImGui::GetIO().WantCaptureMouse) { + if (event.type == SDL_MOUSEMOTION) { + renderer->getCameraController()->processMouseMotion(event.motion); + } + else if (event.type == SDL_MOUSEBUTTONDOWN || event.type == SDL_MOUSEBUTTONUP) { + renderer->getCameraController()->processMouseButton(event.button); + } + else if (event.type == SDL_MOUSEWHEEL) { + renderer->getCameraController()->processMouseWheel(static_cast(event.wheel.y)); + } } - else if (event.type == SDL_MOUSEWHEEL) { - renderer->getCameraController()->processMouseWheel(static_cast(event.wheel.y)); + + // Handle window events + if (event.type == SDL_QUIT) { + window->setShouldClose(true); + } + else if (event.type == SDL_WINDOWEVENT) { + if (event.window.event == SDL_WINDOWEVENT_RESIZED) { + int newWidth = event.window.data1; + int newHeight = event.window.data2; + window->setSize(newWidth, newHeight); + // Vulkan viewport set in command buffer, not globally + if (renderer && renderer->getCamera()) { + renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); + } + } + } + // Debug controls + else if (event.type == SDL_KEYDOWN) { + // Skip non-function-key input when UI (chat) has keyboard focus + bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard; + auto sc = event.key.keysym.scancode; + bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12); + if (uiHasKeyboard && !isFKey) { + continue; // Let ImGui handle the keystroke + } + + // F1: Toggle performance HUD + if (event.key.keysym.scancode == SDL_SCANCODE_F1) { + if (renderer && renderer->getPerformanceHUD()) { + renderer->getPerformanceHUD()->toggle(); + bool enabled = renderer->getPerformanceHUD()->isEnabled(); + LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF"); + } + } + // F4: Toggle shadows + else if (event.key.keysym.scancode == SDL_SCANCODE_F4) { + if (renderer) { + bool enabled = !renderer->areShadowsEnabled(); + renderer->setShadowsEnabled(enabled); + LOG_INFO("Shadows: ", enabled ? "ON" : "OFF"); + } + } + // F8: Debug WMO floor at current position + else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) { + if (renderer && renderer->getWMORenderer()) { + glm::vec3 pos = renderer->getCharacterPosition(); + LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z); + } + } } } - // Handle window events - if (event.type == SDL_QUIT) { + // Update input + Input::getInstance().update(); + + // Update application state + try { + update(deltaTime); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::update (state=", static_cast(state), + ", dt=", deltaTime, "): ", e.what()); + throw; + } + // Render + try { + render(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); + throw; + } + // Swap buffers + try { + window->swapBuffers(); + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM during swapBuffers: ", e.what()); + throw; + } catch (const std::exception& e) { + LOG_ERROR("Exception during swapBuffers: ", e.what()); + throw; + } + + // Exit gracefully on GPU device lost (unrecoverable) + if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) { + LOG_ERROR("GPU device lost — exiting application"); window->setShouldClose(true); } - else if (event.type == SDL_WINDOWEVENT) { - if (event.window.event == SDL_WINDOWEVENT_RESIZED) { - int newWidth = event.window.data1; - int newHeight = event.window.data2; - window->setSize(newWidth, newHeight); - // Vulkan viewport set in command buffer, not globally - if (renderer && renderer->getCamera()) { - renderer->getCamera()->setAspectRatio(static_cast(newWidth) / newHeight); - } - } - } - // Debug controls - else if (event.type == SDL_KEYDOWN) { - // Skip non-function-key input when UI (chat) has keyboard focus - bool uiHasKeyboard = ImGui::GetIO().WantCaptureKeyboard; - auto sc = event.key.keysym.scancode; - bool isFKey = (sc >= SDL_SCANCODE_F1 && sc <= SDL_SCANCODE_F12); - if (uiHasKeyboard && !isFKey) { - continue; // Let ImGui handle the keystroke - } - // F1: Toggle performance HUD - if (event.key.keysym.scancode == SDL_SCANCODE_F1) { - if (renderer && renderer->getPerformanceHUD()) { - renderer->getPerformanceHUD()->toggle(); - bool enabled = renderer->getPerformanceHUD()->isEnabled(); - LOG_INFO("Performance HUD: ", enabled ? "ON" : "OFF"); - } - } - // F4: Toggle shadows - else if (event.key.keysym.scancode == SDL_SCANCODE_F4) { - if (renderer) { - bool enabled = !renderer->areShadowsEnabled(); - renderer->setShadowsEnabled(enabled); - LOG_INFO("Shadows: ", enabled ? "ON" : "OFF"); - } - } - // F7: Test level-up effect (ignore key repeat) - else if (event.key.keysym.scancode == SDL_SCANCODE_F7 && event.key.repeat == 0) { - if (renderer) { - renderer->triggerLevelUpEffect(renderer->getCharacterPosition()); - LOG_INFO("Triggered test level-up effect"); - } - if (uiManager) { - uiManager->getGameScreen().triggerDing(99); - } - } - // F8: Debug WMO floor at current position - else if (event.key.keysym.scancode == SDL_SCANCODE_F8 && event.key.repeat == 0) { - if (renderer && renderer->getWMORenderer()) { - glm::vec3 pos = renderer->getCharacterPosition(); - LOG_WARNING("F8: WMO floor debug at render pos (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - renderer->getWMORenderer()->debugDumpGroupsAtPosition(pos.x, pos.y, pos.z); - } - } + // Soft frame rate cap when vsync is off to prevent 100% CPU usage. + // Target ~240 FPS max (~4.2ms per frame); vsync handles its own pacing. + if (!window->isVsyncEnabled() && deltaTime < 0.004f) { + float sleepMs = (0.004f - deltaTime) * 1000.0f; + if (sleepMs > 0.5f) + std::this_thread::sleep_for(std::chrono::microseconds( + static_cast(sleepMs * 900.0f))); // 90% of target to account for sleep overshoot } } + } catch (...) { + watchdogRunning.store(false, std::memory_order_release); + if (watchdogThread.joinable()) { + watchdogThread.join(); + } + throw; + } - // Update input - Input::getInstance().update(); - - // Update application state - try { - update(deltaTime); - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM during Application::update (state=", static_cast(state), - ", dt=", deltaTime, "): ", e.what()); - throw; - } catch (const std::exception& e) { - LOG_ERROR("Exception during Application::update (state=", static_cast(state), - ", dt=", deltaTime, "): ", e.what()); - throw; - } - // Render - try { - render(); - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM during Application::render (state=", static_cast(state), "): ", e.what()); - throw; - } catch (const std::exception& e) { - LOG_ERROR("Exception during Application::render (state=", static_cast(state), "): ", e.what()); - throw; - } - // Swap buffers - try { - window->swapBuffers(); - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM during swapBuffers: ", e.what()); - throw; - } catch (const std::exception& e) { - LOG_ERROR("Exception during swapBuffers: ", e.what()); - throw; - } - - // Exit gracefully on GPU device lost (unrecoverable) - if (renderer && renderer->getVkContext() && renderer->getVkContext()->isDeviceLost()) { - LOG_ERROR("GPU device lost — exiting application"); - window->setShouldClose(true); - } + watchdogRunning.store(false, std::memory_order_release); + if (watchdogThread.joinable()) { + watchdogThread.join(); } LOG_INFO("Main loop ended"); @@ -536,6 +843,12 @@ void Application::run() { void Application::shutdown() { LOG_WARNING("Shutting down application..."); + // Hide the window immediately so the OS doesn't think the app is frozen + // during the (potentially slow) resource cleanup below. + if (window && window->getSDLWindow()) { + SDL_HideWindow(window->getSDLWindow()); + } + // Stop background world preloader before destroying AssetManager cancelWorldPreload(); @@ -602,8 +915,13 @@ void Application::setState(AppState newState) { } // Ensure no stale in-world player model leaks into the next login attempt. // If we reuse a previously spawned instance without forcing a respawn, appearance (notably hair) can desync. + if (addonManager_ && addonsLoaded_) { + addonManager_->fireEvent("PLAYER_LEAVING_WORLD"); + addonManager_->saveAllSavedVariables(); + } npcsSpawned = false; playerCharacterSpawned = false; + addonsLoaded_ = false; weaponsSheathed_ = false; wasAutoAttacking_ = false; loadedMapId_ = 0xFFFFFFFF; @@ -628,6 +946,11 @@ void Application::setState(AppState newState) { gameHandler->sendMovement(static_cast(opcode)); } }); + cc->setStandUpCallback([this]() { + if (gameHandler) { + gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND) + } + }); cc->setUseWoWSpeed(true); } if (gameHandler) { @@ -636,6 +959,16 @@ void Application::setState(AppState newState) { renderer->triggerMeleeSwing(); } }); + gameHandler->setKnockBackCallback([this](float vcos, float vsin, float hspeed, float vspeed) { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->applyKnockBack(vcos, vsin, hspeed, vspeed); + } + }); + gameHandler->setCameraShakeCallback([this](float magnitude, float frequency, float duration) { + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->triggerShake(magnitude, frequency, duration); + } + }); } // Load quest marker models loadQuestMarkerModels(); @@ -749,6 +1082,13 @@ void Application::logoutToLogin() { creatureRenderPosCache_.clear(); creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); + creatureWasMoving_.clear(); + creatureWasSwimming_.clear(); + creatureWasFlying_.clear(); + creatureWasWalking_.clear(); + creatureSwimmingState_.clear(); + creatureWalkingState_.clear(); + creatureFlyingState_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -762,6 +1102,7 @@ void Application::logoutToLogin() { if (load.future.valid()) load.future.wait(); } asyncCreatureLoads_.clear(); + asyncCreatureDisplayLoads_.clear(); // --- Creature spawn queues --- pendingCreatureSpawns_.clear(); @@ -780,11 +1121,13 @@ void Application::logoutToLogin() { gameObjectInstances_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportMoves_.clear(); + pendingTransportRegistrations_.clear(); pendingTransportDoodadBatches_.clear(); world.reset(); if (renderer) { + renderer->resetCombatVisualState(); // Remove old player model so it doesn't persist into next session if (auto* charRenderer = renderer->getCharacterRenderer()) { charRenderer->removeInstance(1); @@ -884,6 +1227,9 @@ void Application::update(float deltaTime) { gameHandler->update(deltaTime); } }); + if (addonManager_ && addonsLoaded_) { + addonManager_->update(deltaTime); + } // Always unsheath on combat engage. inGameStep = "auto-unsheathe"; updateCheckpoint = "in_game: auto-unsheathe"; @@ -968,6 +1314,18 @@ void Application::update(float deltaTime) { retrySpawn.y = unit->getY(); retrySpawn.z = unit->getZ(); retrySpawn.orientation = unit->getOrientation(); + { + using game::fieldIndex; using game::UF; + uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (si != 0xFFFF) { + uint32_t raw = unit->getField(si); + if (raw != 0) { + float s2 = 1.0f; + std::memcpy(&s2, &raw, sizeof(float)); + if (s2 > 0.01f && s2 < 100.0f) retrySpawn.scale = s2; + } + } + } pendingCreatureSpawns_.push_back(retrySpawn); pendingCreatureSpawnGuids_.insert(guid); } @@ -978,6 +1336,7 @@ void Application::update(float deltaTime) { updateCheckpoint = "in_game: gameobject/transport queues"; runInGameStage("gameobject/transport queues", [&] { processGameObjectSpawnQueue(); + processPendingTransportRegistrations(); processPendingTransportDoodads(); }); inGameStep = "pending mount"; @@ -997,6 +1356,42 @@ void Application::update(float deltaTime) { runInGameStage("post-update sync", [&] { if (renderer && gameHandler && renderer->getCameraController()) { renderer->getCameraController()->setRunSpeedOverride(gameHandler->getServerRunSpeed()); + renderer->getCameraController()->setWalkSpeedOverride(gameHandler->getServerWalkSpeed()); + renderer->getCameraController()->setSwimSpeedOverride(gameHandler->getServerSwimSpeed()); + renderer->getCameraController()->setSwimBackSpeedOverride(gameHandler->getServerSwimBackSpeed()); + renderer->getCameraController()->setFlightSpeedOverride(gameHandler->getServerFlightSpeed()); + renderer->getCameraController()->setFlightBackSpeedOverride(gameHandler->getServerFlightBackSpeed()); + renderer->getCameraController()->setRunBackSpeedOverride(gameHandler->getServerRunBackSpeed()); + renderer->getCameraController()->setTurnRateOverride(gameHandler->getServerTurnRate()); + renderer->getCameraController()->setMovementRooted(gameHandler->isPlayerRooted()); + renderer->getCameraController()->setGravityDisabled(gameHandler->isGravityDisabled()); + renderer->getCameraController()->setFeatherFallActive(gameHandler->isFeatherFalling()); + renderer->getCameraController()->setWaterWalkActive(gameHandler->isWaterWalking()); + renderer->getCameraController()->setFlyingActive(gameHandler->isPlayerFlying()); + renderer->getCameraController()->setHoverActive(gameHandler->isHovering()); + + // Sync camera forward pitch to movement packets during flight / swimming. + // The server writes the pitch field when FLYING or SWIMMING flags are set; + // without this sync it would always be 0 (horizontal), causing other + // players to see the character flying flat even when pitching up/down. + if (gameHandler->isPlayerFlying() || gameHandler->isSwimming()) { + if (auto* cam = renderer->getCamera()) { + glm::vec3 fwd = cam->getForward(); + float len = glm::length(fwd); + if (len > 1e-4f) { + float pitchRad = std::asin(std::clamp(fwd.z / len, -1.0f, 1.0f)); + gameHandler->setMovementPitch(pitchRad); + // Tilt the mount/character model to match flight direction + // (taxi flight uses setTaxiOrientationCallback for this instead) + if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) { + renderer->setMountPitchRoll(pitchRad, 0.0f); + } + } + } + } else if (gameHandler->isMounted()) { + // Reset mount pitch when not flying + renderer->setMountPitchRoll(0.0f, 0.0f); + } } bool onTaxi = gameHandler && @@ -1004,6 +1399,15 @@ void Application::update(float deltaTime) { gameHandler->isTaxiMountActive() || gameHandler->isTaxiActivationPending()); bool onTransportNow = gameHandler && gameHandler->isOnTransport(); + // Clear stale client-side transport state when the tracked transport no longer exists. + if (onTransportNow && gameHandler->getTransportManager()) { + auto* currentTracked = gameHandler->getTransportManager()->getTransport( + gameHandler->getPlayerTransportGuid()); + if (!currentTracked) { + gameHandler->clearPlayerTransport(); + onTransportNow = false; + } + } // M2 transports (trams) use position-delta approach: player keeps normal // movement and the transport's frame-to-frame delta is applied on top. // Only WMO transports (ships) use full external-driven mode. @@ -1239,23 +1643,29 @@ void Application::update(float deltaTime) { } else { glm::vec3 renderPos = renderer->getCharacterPosition(); - // M2 transport riding: apply transport's frame-to-frame position delta - // so the player moves with the tram while retaining normal movement input. + // M2 transport riding: resolve in canonical space and lock once per frame. + // This avoids visible jitter from mixed render/canonical delta application. if (isM2Transport && gameHandler->getTransportManager()) { auto* tr = gameHandler->getTransportManager()->getTransport( gameHandler->getPlayerTransportGuid()); if (tr) { - static glm::vec3 lastTransportCanonical(0); - static uint64_t lastTransportGuid = 0; - if (lastTransportGuid == gameHandler->getPlayerTransportGuid()) { - glm::vec3 deltaCanonical = tr->position - lastTransportCanonical; - glm::vec3 deltaRender = core::coords::canonicalToRender(deltaCanonical) - - core::coords::canonicalToRender(glm::vec3(0)); - renderPos += deltaRender; - renderer->getCharacterPosition() = renderPos; + // Keep passenger locked to elevator vertical motion while grounded. + // Without this, floor clamping can hold world-Z static unless the + // player is jumping, which makes lifts appear to not move vertically. + glm::vec3 tentativeCanonical = core::coords::renderToCanonical(renderPos); + glm::vec3 localOffset = gameHandler->getPlayerTransportOffset(); + localOffset.x = tentativeCanonical.x - tr->position.x; + localOffset.y = tentativeCanonical.y - tr->position.y; + if (renderer->getCameraController() && + !renderer->getCameraController()->isGrounded()) { + // While airborne (jump/fall), allow local Z offset to change. + localOffset.z = tentativeCanonical.z - tr->position.z; } - lastTransportCanonical = tr->position; - lastTransportGuid = gameHandler->getPlayerTransportGuid(); + gameHandler->setPlayerTransportOffset(localOffset); + + glm::vec3 lockedCanonical = tr->position + localOffset; + renderPos = core::coords::canonicalToRender(lockedCanonical); + renderer->getCharacterPosition() = renderPos; } } @@ -1292,21 +1702,45 @@ void Application::update(float deltaTime) { } // Client-side transport boarding detection (for M2 transports like trams - // where the server doesn't send transport attachment data). - // Use a generous AABB around each transport's current position. + // and lifts where the server doesn't send transport attachment data). + // Thunder Bluff elevators use model origins that can be far from the deck + // the player stands on, so they need wider attachment bounds. if (gameHandler->getTransportManager() && !gameHandler->isOnTransport()) { auto* tm = gameHandler->getTransportManager(); glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); + constexpr float kM2BoardHorizDistSq = 12.0f * 12.0f; + constexpr float kM2BoardVertDist = 15.0f; + constexpr float kTbLiftBoardHorizDistSq = 22.0f * 22.0f; + constexpr float kTbLiftBoardVertDist = 14.0f; + uint64_t bestGuid = 0; + float bestScore = 1e30f; for (auto& [guid, transport] : tm->getTransports()) { if (!transport.isM2) continue; + const bool isThunderBluffLift = + (transport.entry >= 20649u && transport.entry <= 20657u); + const float maxHorizDistSq = isThunderBluffLift + ? kTbLiftBoardHorizDistSq + : kM2BoardHorizDistSq; + const float maxVertDist = isThunderBluffLift + ? kTbLiftBoardVertDist + : kM2BoardVertDist; glm::vec3 diff = playerCanonical - transport.position; float horizDistSq = diff.x * diff.x + diff.y * diff.y; float vertDist = std::abs(diff.z); - if (horizDistSq < 144.0f && vertDist < 15.0f) { - gameHandler->setPlayerOnTransport(guid, playerCanonical - transport.position); - LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, guid, std::dec); - break; + if (horizDistSq < maxHorizDistSq && vertDist < maxVertDist) { + float score = horizDistSq + vertDist * vertDist; + if (score < bestScore) { + bestScore = score; + bestGuid = guid; + } + } + } + if (bestGuid != 0) { + auto* tr = tm->getTransport(bestGuid); + if (tr) { + gameHandler->setPlayerOnTransport(bestGuid, playerCanonical - tr->position); + LOG_DEBUG("M2 transport boarding: guid=0x", std::hex, bestGuid, std::dec); } } } @@ -1319,7 +1753,19 @@ void Application::update(float deltaTime) { glm::vec3 playerCanonical = core::coords::renderToCanonical(renderPos); glm::vec3 diff = playerCanonical - tr->position; float horizDistSq = diff.x * diff.x + diff.y * diff.y; - if (horizDistSq > 225.0f) { + const bool isThunderBluffLift = + (tr->entry >= 20649u && tr->entry <= 20657u); + constexpr float kM2DisembarkHorizDistSq = 15.0f * 15.0f; + constexpr float kTbLiftDisembarkHorizDistSq = 28.0f * 28.0f; + constexpr float kM2DisembarkVertDist = 18.0f; + constexpr float kTbLiftDisembarkVertDist = 16.0f; + const float disembarkHorizDistSq = isThunderBluffLift + ? kTbLiftDisembarkHorizDistSq + : kM2DisembarkHorizDistSq; + const float disembarkVertDist = isThunderBluffLift + ? kTbLiftDisembarkVertDist + : kM2DisembarkVertDist; + if (horizDistSq > disembarkHorizDistSq || std::abs(diff.z) > disembarkVertDist) { gameHandler->clearPlayerTransport(); LOG_DEBUG("M2 transport disembark"); } @@ -1378,14 +1824,20 @@ void Application::update(float deltaTime) { } } - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + // Distance check uses getLatestX/Y/Z (server-authoritative destination) to + // avoid false-culling entities that moved while getX/Y/Z was stale. + // Position sync still uses getX/Y/Z to preserve smooth interpolation for + // nearby entities; distant entities (> 150u) have planarDist≈0 anyway + // so the renderer remains driven correctly by creatureMoveCallback_. + glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); float canonDistSq = 0.0f; if (havePlayerPos) { - glm::vec3 d = canonical - playerPos; + glm::vec3 d = latestCanonical - playerPos; canonDistSq = glm::dot(d, d); if (canonDistSq > syncRadiusSq) continue; } + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); // Visual collision guard: keep hostile melee units from rendering inside the @@ -1466,14 +1918,69 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); + // isEntityMoving() reflects server-authoritative move state set by + // startMoveTo() in handleMonsterMove, regardless of distance-cull. + // This correctly detects movement for distant creatures (> 150u) + // where updateMovement() is not called and getX/Y/Z() stays stale. + // Use isActivelyMoving() (not isEntityMoving()) so the + // Run/Walk animation stops when the creature reaches its + // destination, rather than persisting through the dead- + // reckoning overrun window. + const bool entityIsMoving = entity->isActivelyMoving(); + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || 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. + // Position changed in entity coords → drive renderer toward it. float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } + // When entity is moving but getX/Y/Z is stale (distance-culled), + // don't call moveInstanceTo — creatureMoveCallback_ already drove + // the renderer to the correct destination via the spline packet. posIt->second = renderPos; + + // 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; + const bool isFlyingNow = creatureFlyingState_.count(guid) > 0; + bool prevMoving = creatureWasMoving_[guid]; + bool prevSwimming = creatureWasSwimming_[guid]; + bool prevFlying = creatureWasFlying_[guid]; + bool prevWalking = creatureWasWalking_[guid]; + // Trigger animation update on any locomotion-state transition, not just + // moving/idle — e.g. creature lands while still moving → FlyForward→Run, + // or server changes WALKING flag while creature is already running → Walk. + const bool stateChanged = (isMovingNow != prevMoving) || + (isSwimmingNow != prevSwimming) || + (isFlyingNow != prevFlying) || + (isWalkingNow != prevWalking && isMovingNow); + if (stateChanged) { + creatureWasMoving_[guid] = isMovingNow; + creatureWasSwimming_[guid] = isSwimmingNow; + creatureWasFlying_[guid] = isFlyingNow; + creatureWasWalking_[guid] = isWalkingNow; + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + uint32_t targetAnim; + if (isMovingNow) { + if (isFlyingNow) targetAnim = 159u; // FlyForward + else if (isSwimmingNow) targetAnim = 42u; // Swim + else if (isWalkingNow) targetAnim = 4u; // Walk + else targetAnim = 5u; // Run + } else { + if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) + else if (isSwimmingNow) targetAnim = 41u; // SwimIdle + else targetAnim = 0u; // Stand + } + charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); + } + } } float renderYaw = entity->getOrientation() + glm::radians(90.0f); charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); @@ -1488,6 +1995,110 @@ void Application::update(float deltaTime) { } } + // --- Online player render sync (position, orientation, animation) --- + // Mirrors the creature sync loop above but without collision guard or + // weapon-attach logic. Without this, online players never transition + // back to Stand after movement stops ("run in place" bug). + auto playerSyncStart = std::chrono::steady_clock::now(); + if (renderer && gameHandler && renderer->getCharacterRenderer()) { + auto* charRenderer = renderer->getCharacterRenderer(); + glm::vec3 pPos(0.0f); + bool havePPos = false; + if (auto pe = gameHandler->getEntityManager().getEntity(gameHandler->getPlayerGuid())) { + pPos = glm::vec3(pe->getX(), pe->getY(), pe->getZ()); + havePPos = true; + } + const float pSyncRadiusSq = 320.0f * 320.0f; + + for (const auto& [guid, instanceId] : playerInstances_) { + auto entity = gameHandler->getEntityManager().getEntity(guid); + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + + // Distance cull + if (havePPos) { + glm::vec3 latestCanonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + glm::vec3 d = latestCanonical - pPos; + if (glm::dot(d, d) > pSyncRadiusSq) continue; + } + + // Position sync + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + auto posIt = creatureRenderPosCache_.find(guid); + if (posIt == creatureRenderPosCache_.end()) { + charRenderer->setInstancePosition(instanceId, renderPos); + creatureRenderPosCache_[guid] = renderPos; + } else { + const glm::vec3 prevPos = posIt->second; + const glm::vec2 delta2(renderPos.x - prevPos.x, renderPos.y - prevPos.y); + float planarDist = glm::length(delta2); + float dz = std::abs(renderPos.z - prevPos.z); + + auto unitPtr = std::static_pointer_cast(entity); + const bool deadOrCorpse = unitPtr->getHealth() == 0; + const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); + const bool entityIsMoving = entity->isActivelyMoving(); + const bool isMovingNow = !deadOrCorpse && (entityIsMoving || planarDist > 0.03f || dz > 0.08f); + + if (deadOrCorpse || largeCorrection) { + charRenderer->setInstancePosition(instanceId, renderPos); + } else if (planarDist > 0.03f || dz > 0.08f) { + float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); + charRenderer->moveInstanceTo(instanceId, renderPos, duration); + } + posIt->second = renderPos; + + // Drive movement animation (same logic as creatures) + const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; + const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; + const bool isFlyingNow = creatureFlyingState_.count(guid) > 0; + bool prevMoving = creatureWasMoving_[guid]; + bool prevSwimming = creatureWasSwimming_[guid]; + bool prevFlying = creatureWasFlying_[guid]; + bool prevWalking = creatureWasWalking_[guid]; + const bool stateChanged = (isMovingNow != prevMoving) || + (isSwimmingNow != prevSwimming) || + (isFlyingNow != prevFlying) || + (isWalkingNow != prevWalking && isMovingNow); + if (stateChanged) { + creatureWasMoving_[guid] = isMovingNow; + creatureWasSwimming_[guid] = isSwimmingNow; + creatureWasFlying_[guid] = isFlyingNow; + creatureWasWalking_[guid] = isWalkingNow; + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + uint32_t targetAnim; + if (isMovingNow) { + if (isFlyingNow) targetAnim = 159u; // FlyForward + else if (isSwimmingNow) targetAnim = 42u; // Swim + else if (isWalkingNow) targetAnim = 4u; // Walk + else targetAnim = 5u; // Run + } else { + if (isFlyingNow) targetAnim = 158u; // FlyIdle (hover) + else if (isSwimmingNow) targetAnim = 41u; // SwimIdle + else targetAnim = 0u; // Stand + } + charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); + } + } + } + + // Orientation sync + float renderYaw = entity->getOrientation() + glm::radians(90.0f); + charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); + } + } + { + float psMs = std::chrono::duration( + std::chrono::steady_clock::now() - playerSyncStart).count(); + if (psMs > 5.0f) { + LOG_WARNING("SLOW update stage 'player render sync': ", psMs, "ms (", + playerInstances_.size(), " players)"); + } + } + // Movement heartbeat is sent from GameHandler::update() to avoid // duplicate packets from multiple update loops. @@ -1506,6 +2117,19 @@ void Application::update(float deltaTime) { break; } + if (pendingWorldEntry_ && !loadingWorld_ && state != AppState::DISCONNECTED) { + auto entry = *pendingWorldEntry_; + pendingWorldEntry_.reset(); + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + if (renderer && renderer->getCameraController()) { + renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(1.0f); + } + loadOnlineWorldTerrain(entry.mapId, entry.x, entry.y, entry.z); + } + // Update renderer (camera, etc.) only when in-game updateCheckpoint = "renderer update"; if (renderer && state == AppState::IN_GAME) { @@ -1692,8 +2316,75 @@ 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); + if (renderer) { + renderer->resetCombatVisualState(); + } + + // 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 guids; + guids.reserve(creatureInstances_.size()); + for (const auto& [g, _] : creatureInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlineCreature(g); + } + { + std::vector guids; + guids.reserve(playerInstances_.size()); + for (const auto& [g, _] : playerInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlinePlayer(g); + } + { + std::vector 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()->processReadyTiles(); + { + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + std::vector> 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. @@ -1715,10 +2406,12 @@ void Application::setupUICallbacks() { renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(0.5f); } - // Flush any tiles that finished background parsing during the cast - // (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before - // the first frame at the new position. - renderer->getTerrainManager()->processAllReadyTiles(); + // Kick off async upload for any tiles that finished background + // parsing. Use the bounded processReadyTiles() instead of + // processAllReadyTiles() to avoid multi-second main-thread stalls + // when many tiles are ready (the rest will finalize over subsequent + // frames via the normal terrain update loop). + renderer->getTerrainManager()->processReadyTiles(); // Queue all remaining tiles within the load radius (8 tiles = 17x17) // at the new position. precacheTiles skips already-loaded/pending tiles, @@ -1739,24 +2432,19 @@ void Application::setupUICallbacks() { // If a world load is already in progress (re-entrant call from // gameHandler->update() processing SMSG_NEW_WORLD during warmup), - // defer this entry. The current load will pick it up when it finishes. + // defer this entry. The current load will pick it up when it finishes. if (loadingWorld_) { LOG_WARNING("World entry deferred: map ", mapId, " while loading (will process after current load)"); pendingWorldEntry_ = {mapId, x, y, z}; return; } - worldEntryMovementGraceTimer_ = 2.0f; - taxiLandingClampTimer_ = 0.0f; - lastTaxiFlight_ = false; - // Stop any movement that was active before the teleport - if (renderer && renderer->getCameraController()) { - renderer->getCameraController()->clearMovementInputs(); - renderer->getCameraController()->suppressMovementFor(1.0f); - } - loadOnlineWorldTerrain(mapId, x, y, z); - // loadedMapId_ is set inside loadOnlineWorldTerrain (including - // any deferred entries it processes), so we must NOT override it here. + // Full world loads are expensive and `loadOnlineWorldTerrain()` itself + // drives `gameHandler->update()` during warmup. Queue the load here so + // it runs after the current packet handler returns instead of recursing + // from `SMSG_LOGIN_VERIFY_WORLD` / `SMSG_NEW_WORLD`. + LOG_WARNING("Queued world entry: map ", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + pendingWorldEntry_ = {mapId, x, y, z}; }); auto sampleBestFloorAt = [this](float x, float y, float probeZ) -> std::optional { @@ -2024,12 +2712,12 @@ void Application::setupUICallbacks() { // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models - gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { + gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { // Queue spawns to avoid hanging when many creatures appear at once. // Deduplicate so repeated updates don't flood pending queue. if (creatureInstances_.count(guid)) return; if (pendingCreatureSpawnGuids_.count(guid)) return; - pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation}); + pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation, scale}); pendingCreatureSpawnGuids_.insert(guid); }); @@ -2075,8 +2763,8 @@ void Application::setupUICallbacks() { }); // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) - gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation}); + gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation, scale}); }); // GameObject despawn callback (online mode) - remove static models @@ -2161,9 +2849,9 @@ void Application::setupUICallbacks() { }); // Achievement earned callback — show toast banner - gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) { + gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { if (uiManager) { - uiManager->getGameScreen().triggerAchievementToast(achievementId); + uiManager->getGameScreen().triggerAchievementToast(achievementId, name); } }); @@ -2359,12 +3047,18 @@ void Application::setupUICallbacks() { } }); + // Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER + gameHandler->setOpenLfgCallback([this]() { + if (uiManager) uiManager->getGameScreen().openDungeonFinder(); + }); + // Creature move callback (online mode) - update creature positions 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; @@ -2373,6 +3067,25 @@ void Application::setupUICallbacks() { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float durationSec = static_cast(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) { + // Player animation is managed by the local renderer state machine — + // don't reset it here or every server movement packet restarts the + // run cycle from frame 0, causing visible stutter. + if (!isPlayer) { + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + auto* cr = renderer->getCharacterRenderer(); + bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); + // Only start Run if not already running and not in Death animation. + if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) { + cr->playAnimation(instanceId, 5u, /*loop=*/true); + } + creatureWasMoving_[guid] = true; + } + } } }); @@ -2401,133 +3114,28 @@ void Application::setupUICallbacks() { // Transport spawn callback (online mode) - register transports with TransportManager gameHandler->setTransportSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - auto* transportManager = gameHandler->getTransportManager(); - if (!transportManager || !renderer) return; + if (!renderer) return; - // Get the WMO instance ID from the GameObject spawn + // Get the GameObject instance now so late queue processing can rely on stable IDs. auto it = gameObjectInstances_.find(guid); if (it == gameObjectInstances_.end()) { LOG_WARNING("Transport spawn callback: GameObject instance not found for GUID 0x", std::hex, guid, std::dec); return; } - uint32_t wmoInstanceId = it->second.instanceId; - LOG_WARNING("Registering server transport: GUID=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, " wmoInstance=", wmoInstanceId, - " pos=(", x, ", ", y, ", ", z, ")"); - - // TransportAnimation.dbc is indexed by GameObject entry - uint32_t pathId = entry; - const bool preferServerData = gameHandler && gameHandler->hasServerTransportUpdate(guid); - - bool clientAnim = transportManager->isClientSideAnimation(); - LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, - " guid=0x", std::hex, guid, std::dec, " entry=", entry, " pathId=", pathId, - " preferServer=", preferServerData); - - // Coordinates are already canonical (converted in game_handler.cpp when entity was created) - glm::vec3 canonicalSpawnPos(x, y, z); - - // Check if we have a real path from TransportAnimation.dbc (indexed by entry). - // AzerothCore transport entries are not always 1:1 with DBC path ids. - const bool shipOrZeppelinDisplay = - (displayId == 3015 || displayId == 3031 || displayId == 7546 || - displayId == 7446 || displayId == 1587 || displayId == 2454 || - displayId == 807 || displayId == 808); - bool hasUsablePath = transportManager->hasPathForEntry(entry); - if (shipOrZeppelinDisplay) { - // For true transports, reject tiny XY tracks that effectively look stationary. - hasUsablePath = transportManager->hasUsableMovingPathForEntry(entry, 25.0f); - } - - LOG_WARNING("Transport path check: entry=", entry, " hasUsablePath=", hasUsablePath, - " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); - - if (preferServerData) { - // Strict server-authoritative mode: do not infer/remap fallback routes. - if (!hasUsablePath) { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", - std::hex, guid, std::dec, " entry=", entry); - } else { - LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", entry); - } - } else if (!hasUsablePath) { - // Remap/infer path by spawn position when entry doesn't map 1:1 to DBC ids. - // For elevators (TB lift platforms), we must allow z-only paths here. - bool allowZOnly = (displayId == 455 || displayId == 462); - uint32_t inferredPath = transportManager->inferDbcPathForSpawn( - canonicalSpawnPos, 1200.0f, allowZOnly); - if (inferredPath != 0) { - pathId = inferredPath; - LOG_WARNING("Using inferred transport path ", pathId, " for entry ", entry); - } else { - uint32_t remappedPath = transportManager->pickFallbackMovingPath(entry, displayId); - if (remappedPath != 0) { - pathId = remappedPath; - LOG_WARNING("Using remapped fallback transport path ", pathId, - " for entry ", entry, " displayId=", displayId, - " (usableEntryPath=", transportManager->hasPathForEntry(entry), ")"); - } else { - LOG_WARNING("No TransportAnimation.dbc path for entry ", entry, - " - transport will be stationary"); - - // Fallback: Stationary at spawn point (wait for server to send real position) - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - } - } + auto pendingIt = std::find_if( + pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), + [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); + if (pendingIt != pendingTransportRegistrations_.end()) { + pendingIt->entry = entry; + pendingIt->displayId = displayId; + pendingIt->x = x; + pendingIt->y = y; + pendingIt->z = z; + pendingIt->orientation = orientation; } else { - LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", entry); - } - - // Register the transport with spawn position (prevents rendering at origin until server update) - transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); - - // Mark M2 transports (e.g. Deeprun Tram cars) so TransportManager uses M2Renderer - if (!it->second.isWmo) { - if (auto* tr = transportManager->getTransport(guid)) { - tr->isM2 = true; - } - } - - // Server-authoritative movement - set initial position from spawn data - glm::vec3 canonicalPos(x, y, z); - transportManager->updateServerTransport(guid, canonicalPos, orientation); - - // If a move packet arrived before registration completed, replay latest now. - auto pendingIt = pendingTransportMoves_.find(guid); - if (pendingIt != pendingTransportMoves_.end()) { - const PendingTransportMove pending = pendingIt->second; - transportManager->updateServerTransport(guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, guid, std::dec, - " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ") orientation=", pending.orientation); - pendingTransportMoves_.erase(pendingIt); - } - - // For MO_TRANSPORT at (0,0,0): check if GO data is already cached with a taxiPathId - if (glm::length(canonicalSpawnPos) < 1.0f && gameHandler) { - auto goData = gameHandler->getCachedGameObjectInfo(entry); - if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { - uint32_t taxiPathId = goData->data[0]; - if (transportManager->hasTaxiPath(taxiPathId)) { - transportManager->assignTaxiPathToTransport(entry, taxiPathId); - LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", entry, - " taxiPathId=", taxiPathId); - } - } - } - - if (auto* tr = transportManager->getTransport(guid); tr) { - LOG_WARNING("Transport registered: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, - " pathId=", tr->pathId, - " mode=", (tr->useClientAnimation ? "client" : "server"), - " serverUpdates=", tr->serverUpdateCount); - } else { - LOG_DEBUG("Transport registered: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, " (TransportManager instance missing)"); + pendingTransportRegistrations_.push_back( + PendingTransportRegistration{guid, entry, displayId, x, y, z, orientation}); } }); @@ -2542,6 +3150,15 @@ void Application::setupUICallbacks() { return; } + auto pendingRegIt = std::find_if( + pendingTransportRegistrations_.begin(), pendingTransportRegistrations_.end(), + [guid](const PendingTransportRegistration& pending) { return pending.guid == guid; }); + if (pendingRegIt != pendingTransportRegistrations_.end()) { + pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; + LOG_DEBUG("Queued transport move for pending registration GUID=0x", std::hex, guid, std::dec); + return; + } + // Check if transport exists - if not, treat this as a late spawn (reconnection/server restart) if (!transportManager->getTransport(guid)) { LOG_DEBUG("Received position update for unregistered transport 0x", std::hex, guid, std::dec, @@ -2616,6 +3233,12 @@ void Application::setupUICallbacks() { } transportManager->registerTransport(guid, wmoInstanceId, pathId, canonicalSpawnPos, entry); + // Keep type in sync with the spawned instance; needed for M2 lift boarding/motion. + if (!it->second.isWmo) { + if (auto* tr = transportManager->getTransport(guid)) { + tr->isM2 = true; + } + } } else { pendingTransportMoves_[guid] = PendingTransportMove{x, y, z, orientation}; LOG_DEBUG("Cannot auto-spawn transport 0x", std::hex, guid, std::dec, @@ -2651,30 +3274,186 @@ void Application::setupUICallbacks() { } }); - // NPC death callback (online mode) - play death animation + // NPC/player death callback (online mode) - play death animation gameHandler->setNpcDeathCallback([this](uint64_t guid) { deadCreatureGuids_.insert(guid); + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 1, false); // Death + if (it != creatureInstances_.end()) instanceId = it->second; + else { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0) { + renderer->getCharacterRenderer()->playAnimation(instanceId, 1, false); // Death } }); - // NPC respawn callback (online mode) - reset to idle animation + // NPC/player respawn callback (online mode) - reset to idle animation gameHandler->setNpcRespawnCallback([this](uint64_t guid) { deadCreatureGuids_.erase(guid); + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 0, true); // Idle + if (it != creatureInstances_.end()) instanceId = it->second; + else { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0) { + renderer->getCharacterRenderer()->playAnimation(instanceId, 0, true); // Idle } }); - // NPC swing callback (online mode) - play attack animation + // NPC/player swing callback (online mode) - play attack animation gameHandler->setNpcSwingCallback([this](uint64_t guid) { + if (!renderer || !renderer->getCharacterRenderer()) return; + uint32_t instanceId = 0; auto it = creatureInstances_.find(guid); - if (it != creatureInstances_.end() && renderer && renderer->getCharacterRenderer()) { - renderer->getCharacterRenderer()->playAnimation(it->second, 16, false); // Attack + if (it != creatureInstances_.end()) instanceId = it->second; + else { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; } + if (instanceId != 0) { + renderer->getCharacterRenderer()->playAnimation(instanceId, 16, false); // Attack + } + }); + + // 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(game::MovementFlags::SWIMMING)) != 0; + const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; + const bool isFlying = (moveFlags & static_cast(game::MovementFlags::FLYING)) != 0; + if (isSwimming) creatureSwimmingState_[guid] = true; + else creatureSwimmingState_.erase(guid); + if (isWalking) creatureWalkingState_[guid] = true; + else creatureWalkingState_.erase(guid); + if (isFlying) creatureFlyingState_[guid] = true; + else creatureFlyingState_.erase(guid); + }); + + // Emote animation callback — play server-driven emote animations on NPCs and other players + 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 + // Do not force Stand(0) here: locomotion state machine already owns standing/running. + // Forcing Stand on packet timing causes visible run-cycle hitching while steering. + uint32_t animId = 0; + if (standState == 0) { + return; + } 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 @@ -3154,7 +3933,7 @@ void Application::spawnPlayerCharacter() { // Facial hair geoset: group 2 = 200 + variation + 1 activeGeosets.insert(static_cast(200 + facialId + 1)); activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 - activeGeosets.insert(502); // Bare shins (no boots) — group 5 + activeGeosets.insert(503); // Bare shins (no boots) — group 5 activeGeosets.insert(702); // Ears: default activeGeosets.insert(801); // Bare wrists (no chest armor sleeves) — group 8 activeGeosets.insert(902); // Kneepads: default — group 9 @@ -3684,6 +4463,15 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float window->swapBuffers(); }; + // Set zone name on loading screen from Map.dbc + if (gameHandler) { + std::string mapDisplayName = gameHandler->getMapName(mapId); + if (!mapDisplayName.empty()) + loadingScreen.setZoneName(mapDisplayName); + else + loadingScreen.setZoneName("Loading..."); + } + showProgress("Entering world...", 0.0f); // --- Clean up previous map's state on map change --- @@ -3703,6 +4491,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float deferredEquipmentQueue_.clear(); pendingGameObjectSpawns_.clear(); pendingTransportMoves_.clear(); + pendingTransportRegistrations_.clear(); pendingTransportDoodadBatches_.clear(); if (renderer) { @@ -3758,12 +4547,15 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (load.future.valid()) load.future.wait(); } asyncCreatureLoads_.clear(); + asyncCreatureDisplayLoads_.clear(); playerInstances_.clear(); onlinePlayerAppearance_.clear(); gameObjectInstances_.clear(); gameObjectDisplayIdModelCache_.clear(); + gameObjectDisplayIdWmoCache_.clear(); + gameObjectDisplayIdFailedCache_.clear(); // Force player character re-spawn on new map playerCharacterSpawned = false; @@ -4114,7 +4906,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float glm::vec3 worldPos = glm::vec3(worldMatrix[3]); uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); - m2Renderer->loadModel(m2Model, doodadModelId); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos); if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true); loadedDoodads++; @@ -4346,24 +5138,42 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float gameHandler->setNpcDeathCallback([cr, app](uint64_t guid) { app->deadCreatureGuids_.insert(guid); + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 1, false); // animation ID 1 = Death + if (it != app->creatureInstances_.end()) instanceId = it->second; + else { + auto pit = app->playerInstances_.find(guid); + if (pit != app->playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 1, false); // animation ID 1 = Death } }); gameHandler->setNpcRespawnCallback([cr, app](uint64_t guid) { app->deadCreatureGuids_.erase(guid); + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 0, true); // animation ID 0 = Idle + if (it != app->creatureInstances_.end()) instanceId = it->second; + else { + auto pit = app->playerInstances_.find(guid); + if (pit != app->playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 0, true); // animation ID 0 = Idle } }); gameHandler->setNpcSwingCallback([cr, app](uint64_t guid) { + uint32_t instanceId = 0; auto it = app->creatureInstances_.find(guid); - if (it != app->creatureInstances_.end() && cr) { - cr->playAnimation(it->second, 16, false); // animation ID 16 = Attack1 + if (it != app->creatureInstances_.end()) instanceId = it->second; + else { + auto pit = app->playerInstances_.find(guid); + if (pit != app->playerInstances_.end()) instanceId = pit->second; + } + if (instanceId != 0 && cr) { + cr->playAnimation(instanceId, 16, false); // animation ID 16 = Attack1 } }); } @@ -4412,25 +5222,23 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (world) world->update(1.0f / 60.0f); processPlayerSpawnQueue(); - // 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(true); - // Process equipment queue more aggressively during warmup (multiple per iteration) - for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { + // Keep warmup bounded: unbounded queue draining can stall the main thread + // long enough to trigger socket timeouts. + processCreatureSpawnQueue(false); + processAsyncNpcCompositeResults(false); + // Process equipment queue with a small bounded burst during warmup. + for (int i = 0; i < 2 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { processDeferredEquipmentQueue(); } if (auto* cr = renderer ? renderer->getCharacterRenderer() : nullptr) { - cr->processPendingNormalMaps(INT_MAX); + cr->processPendingNormalMaps(4); } - // Process ALL pending game object spawns. - while (!pendingGameObjectSpawns_.empty()) { - auto& s = pendingGameObjectSpawns_.front(); - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - } + // Keep warmup responsive: process gameobject queue with the same bounded + // budget logic used in-world instead of draining everything in one tick. + processGameObjectSpawnQueue(); + processPendingTransportRegistrations(); processPendingTransportDoodads(); processPendingMount(); updateQuestMarkers(); @@ -4507,6 +5315,33 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // Only enter IN_GAME when this is the final map (no deferred entry pending). setState(AppState::IN_GAME); + + // Load addons once per session on first world entry + if (addonManager_ && !addonsLoaded_) { + // Set character name for per-character SavedVariables + if (gameHandler) { + const std::string& charName = gameHandler->lookupName(gameHandler->getPlayerGuid()); + if (!charName.empty()) { + addonManager_->setCharacterName(charName); + } else { + // Fallback: find name from character list + for (const auto& c : gameHandler->getCharacters()) { + if (c.guid == gameHandler->getPlayerGuid()) { + addonManager_->setCharacterName(c.name); + break; + } + } + } + } + addonManager_->loadAllAddons(); + addonsLoaded_ = true; + addonManager_->fireEvent("VARIABLES_LOADED"); + addonManager_->fireEvent("PLAYER_LOGIN"); + addonManager_->fireEvent("PLAYER_ENTERING_WORLD"); + } else if (addonManager_ && addonsLoaded_) { + // Subsequent world entries (e.g. teleport, instance entry) + addonManager_->fireEvent("PLAYER_ENTERING_WORLD"); + } } void Application::buildCharSectionsCache() { @@ -4518,9 +5353,9 @@ void Application::buildCharSectionsCache() { uint32_t raceF = csL ? (*csL)["RaceID"] : 1; uint32_t sexF = csL ? (*csL)["SexID"] : 2; uint32_t secF = csL ? (*csL)["BaseSection"] : 3; - uint32_t varF = csL ? (*csL)["VariationIndex"] : 4; - uint32_t colF = csL ? (*csL)["ColorIndex"] : 5; - uint32_t tex1F = csL ? (*csL)["Texture1"] : 6; + uint32_t varF = csL ? (*csL)["VariationIndex"] : 8; + uint32_t colF = csL ? (*csL)["ColorIndex"] : 9; + uint32_t tex1F = csL ? (*csL)["Texture1"] : 4; for (uint32_t r = 0; r < dbc->getRecordCount(); r++) { uint32_t race = dbc->getUInt32(r, raceF); uint32_t sex = dbc->getUInt32(r, sexF); @@ -4958,7 +5793,7 @@ pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) { return model; } -void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { +void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return; // Skip if lookups not yet built (asset manager not ready) @@ -5127,9 +5962,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (rId != npcRace || sId != npcSex) continue; uint32_t section = csDbc->getUInt32(r, csL ? (*csL)["BaseSection"] : 3); - uint32_t variation = csDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 4); - uint32_t color = csDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 5); - uint32_t tex1F = csL ? (*csL)["Texture1"] : 6; + uint32_t variation = csDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 8); + uint32_t color = csDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 9); + uint32_t tex1F = csL ? (*csL)["Texture1"] : 4; if (section == 0 && def.basePath.empty() && color == npcSkin) { def.basePath = csDbc->getString(r, tex1F); @@ -5245,11 +6080,11 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (raceId != targetRace || sexId != targetSex) continue; uint32_t section = csDbc->getUInt32(r, csL ? (*csL)["BaseSection"] : 3); if (section != 3) continue; - uint32_t variation = csDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 4); - uint32_t colorIdx = csDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 5); + uint32_t variation = csDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 8); + uint32_t colorIdx = csDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 9); if (variation != static_cast(extraCopy.hairStyleId)) continue; if (colorIdx != static_cast(extraCopy.hairColorId)) continue; - def.hairTexturePath = csDbc->getString(r, csL ? (*csL)["Texture1"] : 6); + def.hairTexturePath = csDbc->getString(r, csL ? (*csL)["Texture1"] : 4); break; } @@ -5395,9 +6230,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Convert canonical WoW orientation (0=north) -> render yaw (0=west) float renderYaw = orientation + glm::radians(90.0f); - // Create instance + // Create instance (apply server-provided scale from OBJECT_FIELD_SCALE_X) uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); + glm::vec3(0.0f, 0.0f, renderYaw), scale); if (instanceId == 0) { LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec); @@ -5607,7 +6442,7 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } // Default equipment geosets (bare/no armor) - // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 9=kneepads, 13=pants + // CharGeosets: group 4=gloves(forearm), 5=boots(shin), 8=sleeves, 12=tabard, 13=pants std::unordered_set modelGeosets; std::unordered_map firstByGroup; if (const auto* md = charRenderer->getModelData(modelId)) { @@ -5628,13 +6463,12 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x return preferred; }; - uint16_t geosetGloves = pickGeoset(301, 3); // Bare gloves/forearms (group 3) - uint16_t geosetBoots = pickGeoset(401, 4); // Bare boots/shins (group 4) - uint16_t geosetTorso = pickGeoset(501, 5); // Base torso/waist (group 5) + uint16_t geosetGloves = pickGeoset(401, 4); // Bare gloves/forearms (group 4) + uint16_t geosetBoots = pickGeoset(503, 5); // Bare boots/shins (group 5) uint16_t geosetSleeves = pickGeoset(801, 8); // Bare wrists (group 8, controlled by chest) uint16_t geosetPants = pickGeoset(1301, 13); // Bare legs (group 13) uint16_t geosetCape = 0; // Group 15 disabled unless cape is equipped - uint16_t geosetTabard = 0; // TODO: NPC tabard geosets currently flicker/apron; keep hidden for now + uint16_t geosetTabard = pickGeoset(1201, 12); // Group 12 (tabard), default variant 1201 rendering::VkTexture* npcCapeTextureId = nullptr; // Load equipment geosets from ItemDisplayInfo.dbc @@ -5658,10 +6492,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x return gg; }; - // Chest (slot 3) → group 5 (torso) + group 8 (sleeves/wristbands) + // Chest (slot 3) → group 8 (sleeves/wristbands) { uint32_t gg = readGeosetGroup(3, "chest"); - if (gg > 0) geosetTorso = pickGeoset(static_cast(501 + gg), 5); if (gg > 0) geosetSleeves = pickGeoset(static_cast(801 + gg), 8); } @@ -5671,19 +6504,23 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (gg > 0) geosetPants = pickGeoset(static_cast(1301 + gg), 13); } - // Feet (slot 6) → group 4 (boots/shins) + // Feet (slot 6) → group 5 (boots/shins) { uint32_t gg = readGeosetGroup(6, "feet"); - if (gg > 0) geosetBoots = pickGeoset(static_cast(401 + gg), 4); + if (gg > 0) geosetBoots = pickGeoset(static_cast(501 + gg), 5); } - // Hands (slot 8) → group 3 (gloves/forearms) + // Hands (slot 8) → group 4 (gloves/forearms) { uint32_t gg = readGeosetGroup(8, "hands"); - if (gg > 0) geosetGloves = pickGeoset(static_cast(301 + gg), 3); + if (gg > 0) geosetGloves = pickGeoset(static_cast(401 + gg), 4); } - // Tabard (slot 9) intentionally disabled for now (see geosetTabard TODO above). + // Tabard (slot 9) → group 12 (tabard/robe mesh) + { + uint32_t gg = readGeosetGroup(9, "tabard"); + if (gg > 0) geosetTabard = pickGeoset(static_cast(1200 + gg), 12); + } // Cape (slot 10) → group 15 if (extra.equipDisplayId[10] != 0) { @@ -5753,7 +6590,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Apply equipment geosets activeGeosets.insert(geosetGloves); activeGeosets.insert(geosetBoots); - activeGeosets.insert(geosetTorso); activeGeosets.insert(geosetSleeves); activeGeosets.insert(geosetPants); if (geosetCape != 0) { @@ -5799,9 +6635,10 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x " sleeves=", geosetSleeves, " pants=", geosetPants, " boots=", geosetBoots, " gloves=", geosetGloves); - // TODO(#helmet-attach): NPC helmet attachment anchors are currently unreliable - // on some humanoid models (floating/incorrect bone bind). Keep hidden for now. - static constexpr bool kEnableNpcHelmetAttachmentsMainPath = false; + // NOTE: NPC helmet attachment with fallback logic to use bone 0 if attachment + // point 11 is missing. This improves compatibility with models that don't have + // attachment 11 explicitly defined. + static constexpr bool kEnableNpcHelmetAttachmentsMainPath = true; // Load and attach helmet model if equipped if (kEnableNpcHelmetAttachmentsMainPath && extra.equipDisplayId[0] != 0 && itemDisplayDbc) { int32_t helmIdx = itemDisplayDbc->findRecordById(extra.equipDisplayId[0]); @@ -6107,7 +6944,34 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Show tabard mesh only when CreatureDisplayInfoExtra equips one. if (hasGroup12 && hasEquippedTabard) { - uint16_t tabardSid = pickFromGroup(1201, 12); + uint16_t wantTabard = 1201; // Default fallback + + // Try to read tabard geoset variant from ItemDisplayInfo.dbc (slot 9) + if (hasHumanoidExtra && itDisplayData != displayDataMap_.end() && + itDisplayData->second.extraDisplayId != 0) { + auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); + if (itExtra != humanoidExtraMap_.end()) { + uint32_t tabardDisplayId = itExtra->second.equipDisplayId[9]; + if (tabardDisplayId != 0) { + auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + if (itemDisplayDbc && idiL) { + int32_t tabardIdx = itemDisplayDbc->findRecordById(tabardDisplayId); + if (tabardIdx >= 0) { + // Get geoset variant from ItemDisplayInfo GeosetGroup1 field + const uint32_t ggField = (*idiL)["GeosetGroup1"]; + uint32_t tabardGG = itemDisplayDbc->getUInt32(static_cast(tabardIdx), ggField); + if (tabardGG > 0) { + wantTabard = static_cast(1200 + tabardGG); + } + } + } + } + } + } + + uint16_t tabardSid = pickFromGroup(wantTabard, 12); if (tabardSid != 0) normalizedGeosets.insert(tabardSid); } @@ -6143,84 +7007,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x } } - // Optional NPC helmet attachments (kept disabled for stability: this path - // can increase spawn-time pressure and regress NPC visibility in crowded areas). - static constexpr bool kEnableNpcHelmetAttachments = false; - if (kEnableNpcHelmetAttachments && - itDisplayData != displayDataMap_.end() && - itDisplayData->second.extraDisplayId != 0) { - auto itExtra = humanoidExtraMap_.find(itDisplayData->second.extraDisplayId); - if (itExtra != humanoidExtraMap_.end()) { - const auto& extra = itExtra->second; - if (extra.equipDisplayId[0] != 0) { // Helm slot - auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); - const auto* idiL2 = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - if (itemDisplayDbc) { - int32_t helmIdx = itemDisplayDbc->findRecordById(extra.equipDisplayId[0]); - if (helmIdx >= 0) { - std::string helmModelName = itemDisplayDbc->getString(static_cast(helmIdx), idiL2 ? (*idiL2)["LeftModel"] : 1); - if (!helmModelName.empty()) { - size_t dotPos = helmModelName.rfind('.'); - if (dotPos != std::string::npos) { - helmModelName = helmModelName.substr(0, dotPos); - } - - static const std::unordered_map racePrefix = { - {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, - {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} - }; - std::string genderSuffix = (extra.sexId == 0) ? "M" : "F"; - std::string raceSuffix; - auto itRace = racePrefix.find(extra.raceId); - if (itRace != racePrefix.end()) { - raceSuffix = "_" + itRace->second + genderSuffix; - } - - std::string helmPath; - std::vector helmData; - if (!raceSuffix.empty()) { - helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; - helmData = assetManager->readFile(helmPath); - } - if (helmData.empty()) { - helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; - helmData = assetManager->readFile(helmPath); - } - - if (!helmData.empty()) { - auto helmModel = pipeline::M2Loader::load(helmData); - std::string skinPath = helmPath.substr(0, helmPath.size() - 3) + "00.skin"; - auto skinData = assetManager->readFile(skinPath); - if (!skinData.empty() && helmModel.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, helmModel); - } - - if (helmModel.isValid()) { - uint32_t helmModelId = nextCreatureModelId_++; - std::string helmTexName = itemDisplayDbc->getString(static_cast(helmIdx), idiL2 ? (*idiL2)["LeftModelTexture"] : 3); - std::string helmTexPath; - if (!helmTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; - if (assetManager->fileExists(suffixedTex)) { - helmTexPath = suffixedTex; - } - } - if (helmTexPath.empty()) { - helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; - } - } - // Attachment point 11 = Head - charRenderer->attachWeapon(instanceId, 11, helmModel, helmModelId, helmTexPath); - } - } - } - } - } - } - } - } - // Try attaching NPC held weapons; if update fields are not ready yet, // IN_GAME retry loop will attempt again shortly. bool weaponsAttachedNow = tryAttachCreatureVirtualWeapons(guid, instanceId); @@ -6308,7 +7094,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (!model.isValid() || model.vertices.empty()) { + if (model.vertices.empty()) { LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); return; } @@ -6320,6 +7106,12 @@ void Application::spawnOnlinePlayer(uint64_t guid, pipeline::M2Loader::loadSkin(skinData, model); } + // After skin loading, full model must be valid (vertices + indices) + if (!model.isValid()) { + LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); + return; + } + // Load only core external animations (stand/walk/run) to avoid stalls for (uint32_t si = 0; si < model.sequences.size(); si++) { if (!(model.sequences[si].flags & 0x20)) { @@ -6401,7 +7193,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; uint32_t targetRaceId = raceId; uint32_t targetSexId = genderId; - const uint32_t csTex1 = csL ? (*csL)["Texture1"] : 6; + const uint32_t csTex1 = csL ? (*csL)["Texture1"] : 4; bool foundSkin = false; bool foundUnderwear = false; @@ -6412,8 +7204,8 @@ void Application::spawnOnlinePlayer(uint64_t guid, uint32_t rRace = charSectionsDbc->getUInt32(r, csL ? (*csL)["RaceID"] : 1); uint32_t rSex = charSectionsDbc->getUInt32(r, csL ? (*csL)["SexID"] : 2); uint32_t baseSection = charSectionsDbc->getUInt32(r, csL ? (*csL)["BaseSection"] : 3); - uint32_t variationIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 4); - uint32_t colorIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 5); + uint32_t variationIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 8); + uint32_t colorIndex = charSectionsDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 9); if (rRace != targetRaceId || rSex != targetSexId) continue; @@ -6484,7 +7276,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, activeGeosets.insert(static_cast(100 + hairStyleId + 1)); activeGeosets.insert(static_cast(200 + facialFeatures + 1)); activeGeosets.insert(401); // Bare forearms (no gloves) — group 4 - activeGeosets.insert(502); // Bare shins (no boots) — group 5 + activeGeosets.insert(503); // Bare shins (no boots) — group 5 activeGeosets.insert(702); // Ears activeGeosets.insert(801); // Bare wrists (no sleeves) — group 8 activeGeosets.insert(902); // Kneepads — group 9 @@ -6573,6 +7365,10 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, }; // --- Geosets --- + // Mirror the same group-range logic as CharacterPreview::applyEquipment to + // keep other-player rendering consistent with the local character preview. + // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, + // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). std::unordered_set geosets; // Body parts (group 0: IDs 0-99, some models use up to 27) for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); @@ -6580,8 +7376,6 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); geosets.insert(static_cast(100 + hairStyleId + 1)); geosets.insert(static_cast(200 + st.facialFeatures + 1)); - geosets.insert(401); // Body joint patches (knees) - geosets.insert(402); // Body joint patches (elbows) geosets.insert(701); // Ears geosets.insert(902); // Kneepads geosets.insert(2002); // Bare feet mesh @@ -6589,39 +7383,47 @@ void Application::setOnlinePlayerEquipment(uint64_t guid, const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; - // Chest/Shirt/Robe (invType 4,5,20) + // Per-group defaults — overridden below when equipment provides a geoset value. + uint16_t geosetGloves = 401; // Bare forearms (group 4, no gloves) + uint16_t geosetBoots = 503; // Bare shins (group 5, no boots) + uint16_t geosetSleeves = 801; // Bare wrists (group 8, no chest/sleeves) + uint16_t geosetPants = 1301; // Bare legs (group 13, no leggings) + + // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 { uint32_t did = findDisplayIdByInvType({4, 5, 20}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 501 + gg1 : 501)); - + if (gg1 > 0) geosetSleeves = static_cast(801 + gg1); + // Robe kilt → leg group 13 uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); - if (gg3 > 0) geosets.insert(static_cast(1301 + gg3)); + if (gg3 > 0) geosetPants = static_cast(1301 + gg3); } - // Legs (invType 7) + // Legs (invType 7) → leg group 13 { uint32_t did = findDisplayIdByInvType({7}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { - geosets.insert(static_cast(gg1 > 0 ? 1301 + gg1 : 1301)); - } + if (gg1 > 0) geosetPants = static_cast(1301 + gg1); } - // Feet (invType 8): 401/402 are body patches (always on), 403+ are boot meshes + // Feet/Boots (invType 8) → shin group 5 { uint32_t did = findDisplayIdByInvType({8}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosets.insert(static_cast(402 + gg1)); + if (gg1 > 0) geosetBoots = static_cast(501 + gg1); } - // Hands (invType 10) + // Hands/Gloves (invType 10) → forearm group 4 { uint32_t did = findDisplayIdByInvType({10}); uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - geosets.insert(static_cast(gg1 > 0 ? 301 + gg1 : 301)); + if (gg1 > 0) geosetGloves = static_cast(401 + gg1); } + geosets.insert(geosetGloves); + geosets.insert(geosetBoots); + geosets.insert(geosetSleeves); + geosets.insert(geosetPants); // Back/Cloak (invType 16) geosets.insert(hasInvType({16}) ? 1502 : 1501); // Tabard (invType 19) @@ -6699,9 +7501,17 @@ void Application::despawnOnlinePlayer(uint64_t guid) { playerInstances_.erase(it); onlinePlayerAppearance_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid); + creatureRenderPosCache_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); } -void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { +void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer || !assetManager) return; if (!gameObjectLookupsBuilt_) { @@ -6798,8 +7608,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); if (itCache != gameObjectDisplayIdWmoCache_.end()) { modelId = itCache->second; - loadedAsWmo = true; - } else { + // Only use cached entry if the model is still resident in the renderer + if (wmoRenderer->isModelLoaded(modelId)) { + loadedAsWmo = true; + } else { + gameObjectDisplayIdWmoCache_.erase(itCache); + modelId = 0; + } + } + if (!loadedAsWmo && modelId == 0) { auto wmoData = assetManager->readFile(modelPath); if (!wmoData.empty()) { pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); @@ -6858,7 +7675,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (loadedAsWmo) { uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawWmo), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawWmo), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); return; @@ -6924,6 +7741,11 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto* m2Renderer = renderer->getM2Renderer(); if (!m2Renderer) return; + // Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s). + // Without this guard the same empty model is re-parsed every frame, causing + // sustained log spam and wasted CPU. + if (gameObjectDisplayIdFailedCache_.count(displayId)) return; + uint32_t modelId = 0; auto itCache = gameObjectDisplayIdModelCache_.find(displayId); if (itCache != gameObjectDisplayIdModelCache_.end()) { @@ -6942,12 +7764,14 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto m2Data = assetManager->readFile(modelPath); if (m2Data.empty()) { LOG_WARNING("Failed to read gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } @@ -6959,6 +7783,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (!m2Renderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load gameobject model: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } @@ -6966,7 +7791,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawM2go), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawM2go), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); return; @@ -7001,12 +7826,23 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t void Application::processAsyncCreatureResults(bool unlimited) { // Check completed async model loads and finalize on main thread (GPU upload + instance creation). - // Limit GPU model uploads per frame to avoid spikes, but always drain cheap bookkeeping. - // In unlimited mode (load screen), process all pending uploads without cap. - static constexpr int kMaxModelUploadsPerFrame = 1; + // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. + // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. + static constexpr int kMaxModelUploadsPerTick = 1; + static constexpr int kMaxModelUploadsPerTickWarmup = 1; + static constexpr float kFinalizeBudgetMs = 2.0f; + static constexpr float kFinalizeBudgetWarmupMs = 2.0f; + const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; + const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; + const auto tickStart = std::chrono::steady_clock::now(); int modelUploads = 0; for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { + if (std::chrono::duration( + std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { + break; + } + if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; @@ -7015,12 +7851,13 @@ void Application::processAsyncCreatureResults(bool unlimited) { // Peek: if this result needs a NEW model upload (not cached) and we've hit // the upload budget, defer to next frame without consuming the future. - if (!unlimited && modelUploads >= kMaxModelUploadsPerFrame) { + if (modelUploads >= maxUploadsThisTick) { break; } auto result = it->future.get(); it = asyncCreatureLoads_.erase(it); + asyncCreatureDisplayLoads_.erase(result.displayId); if (result.permanent_failure) { nonRenderableCreatureDisplayIds_.insert(result.displayId); @@ -7035,6 +7872,27 @@ void Application::processAsyncCreatureResults(bool unlimited) { continue; } + // Another async result may have already uploaded this displayId while this + // task was still running; in that case, skip duplicate GPU upload. + if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + continue; + } + // Model parsed on background thread — upload to GPU on main thread. auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; if (!charRenderer) { @@ -7042,6 +7900,10 @@ void Application::processAsyncCreatureResults(bool unlimited) { continue; } + // Count upload attempts toward the frame budget even if upload fails. + // Otherwise repeated failures can consume an unbounded amount of frame time. + modelUploads++; + // Upload model to GPU (must happen on main thread) // Use pre-decoded BLP cache to skip main-thread texture decode auto uploadStart = std::chrono::steady_clock::now(); @@ -7068,8 +7930,6 @@ void Application::processAsyncCreatureResults(bool unlimited) { displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); } displayIdModelCache_[result.displayId] = result.modelId; - modelUploads++; - pendingCreatureSpawnGuids_.erase(result.guid); creatureSpawnRetryCounts_.erase(result.guid); @@ -7084,6 +7944,7 @@ void Application::processAsyncCreatureResults(bool unlimited) { s.y = result.y; s.z = result.z; s.orientation = result.orientation; + s.scale = result.scale; pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(result.guid); } @@ -7222,6 +8083,14 @@ void Application::processCreatureSpawnQueue(bool unlimited) { // For new models: launch async load on background thread instead of blocking. if (needsNewModel) { + // Keep exactly one background load per displayId. Additional spawns for + // the same displayId stay queued and will spawn once cache is populated. + if (asyncCreatureDisplayLoads_.count(s.displayId)) { + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { // Too many in-flight — defer to next frame @@ -7320,9 +8189,9 @@ void Application::processCreatureSpawnQueue(bool unlimited) { uint32_t sId = csDbc->getUInt32(r, csL ? (*csL)["SexID"] : 2); if (rId != nRace || sId != nSex) continue; uint32_t section = csDbc->getUInt32(r, csL ? (*csL)["BaseSection"] : 3); - uint32_t variation = csDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 4); - uint32_t color = csDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 5); - uint32_t tex1F = csL ? (*csL)["Texture1"] : 6; + uint32_t variation = csDbc->getUInt32(r, csL ? (*csL)["VariationIndex"] : 8); + uint32_t color = csDbc->getUInt32(r, csL ? (*csL)["ColorIndex"] : 9); + uint32_t tex1F = csL ? (*csL)["Texture1"] : 4; if (section == 0 && color == nSkin) { std::string t = csDbc->getString(r, tex1F); if (!t.empty()) displaySkinPaths.push_back(t); @@ -7398,6 +8267,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { result.y = s.y; result.z = s.z; result.orientation = s.orientation; + result.scale = s.scale; auto m2Data = am->readFile(m2Path); if (m2Data.empty()) { @@ -7466,6 +8336,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { return result; }); asyncCreatureLoads_.push_back(std::move(load)); + asyncCreatureDisplayLoads_.insert(s.displayId); asyncLaunched++; // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it rotationsLeft = pendingCreatureSpawns_.size(); @@ -7476,7 +8347,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) { auto spawnStart = std::chrono::steady_clock::now(); - spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); auto spawnEnd = std::chrono::steady_clock::now(); float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); if (spawnMs > 100.0f) { @@ -7681,7 +8552,7 @@ void Application::processAsyncGameObjectResults() { if (!result.valid || !result.isWmo || !result.wmoModel) { // Fallback: spawn via sync path (likely an M2 or failed WMO) spawnOnlineGameObject(result.guid, result.entry, result.displayId, - result.x, result.y, result.z, result.orientation); + result.x, result.y, result.z, result.orientation, result.scale); continue; } @@ -7708,7 +8579,7 @@ void Application::processAsyncGameObjectResults() { glm::vec3 renderPos = core::coords::canonicalToRender( glm::vec3(result.x, result.y, result.z)); uint32_t instanceId = wmoRenderer->createInstance( - modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), 1.0f); + modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); if (instanceId == 0) continue; gameObjectInstances_[result.guid] = {modelId, instanceId, true}; @@ -7795,6 +8666,7 @@ void Application::processGameObjectSpawnQueue() { result.y = capture.y; result.z = capture.z; result.orientation = capture.orientation; + result.scale = capture.scale; result.modelPath = capturePath; result.isWmo = true; @@ -7860,11 +8732,156 @@ void Application::processGameObjectSpawnQueue() { } // Cached WMO or M2 — spawn synchronously (cheap) - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); } } +void Application::processPendingTransportRegistrations() { + if (pendingTransportRegistrations_.empty()) return; + if (!gameHandler || !renderer) return; + + auto* transportManager = gameHandler->getTransportManager(); + if (!transportManager) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr int kMaxRegistrationsPerFrame = 2; + static constexpr float kRegistrationBudgetMs = 2.0f; + int processed = 0; + + for (auto it = pendingTransportRegistrations_.begin(); + it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kRegistrationBudgetMs) break; + + const PendingTransportRegistration pending = *it; + auto goIt = gameObjectInstances_.find(pending.guid); + if (goIt == gameObjectInstances_.end()) { + it = pendingTransportRegistrations_.erase(it); + continue; + } + + if (transportManager->getTransport(pending.guid)) { + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + it = pendingTransportRegistrations_.erase(it); + continue; + } + + const uint32_t wmoInstanceId = goIt->second.instanceId; + LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, + " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); + + // TransportAnimation.dbc is indexed by GameObject entry. + uint32_t pathId = pending.entry; + const bool preferServerData = gameHandler->hasServerTransportUpdate(pending.guid); + + bool clientAnim = transportManager->isClientSideAnimation(); + LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, + " guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " pathId=", pathId, + " preferServer=", preferServerData); + + glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); + const bool shipOrZeppelinDisplay = + (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || + pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || + pending.displayId == 807 || pending.displayId == 808); + bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); + if (shipOrZeppelinDisplay) { + hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); + } + + LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + + if (preferServerData) { + if (!hasUsablePath) { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", + std::hex, pending.guid, std::dec, " entry=", pending.entry); + } else { + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); + } + } else if (!hasUsablePath) { + bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); + uint32_t inferredPath = transportManager->inferDbcPathForSpawn( + canonicalSpawnPos, 1200.0f, allowZOnly); + if (inferredPath != 0) { + pathId = inferredPath; + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); + } else { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_WARNING("Using remapped fallback transport path ", pathId, + " for entry ", pending.entry, " displayId=", pending.displayId, + " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); + } else { + LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, + " - transport will be stationary"); + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + } + } + } else { + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); + } + + transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); + + if (!goIt->second.isWmo) { + if (auto* tr = transportManager->getTransport(pending.guid)) { + tr->isM2 = true; + } + } + + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + + auto moveIt = pendingTransportMoves_.find(pending.guid); + if (moveIt != pendingTransportMoves_.end()) { + const PendingTransportMove latestMove = moveIt->second; + transportManager->updateServerTransport( + pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); + LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, + " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, + ") orientation=", latestMove.orientation); + pendingTransportMoves_.erase(moveIt); + } + + if (glm::length(canonicalSpawnPos) < 1.0f) { + auto goData = gameHandler->getCachedGameObjectInfo(pending.entry); + if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { + uint32_t taxiPathId = goData->data[0]; + if (transportManager->hasTaxiPath(taxiPathId)) { + transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); + LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, + " taxiPathId=", taxiPathId); + } + } + } + + if (auto* tr = transportManager->getTransport(pending.guid); tr) { + LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " pathId=", tr->pathId, + " mode=", (tr->useClientAnimation ? "client" : "server"), + " serverUpdates=", tr->serverUpdateCount); + } else { + LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " (TransportManager instance missing)"); + } + + ++processed; + it = pendingTransportRegistrations_.erase(it); + } +} + void Application::processPendingTransportDoodads() { if (pendingTransportDoodadBatches_.empty()) return; if (!renderer || !assetManager) return; @@ -7876,6 +8893,13 @@ void Application::processPendingTransportDoodads() { auto startTime = std::chrono::steady_clock::now(); static constexpr float kDoodadBudgetMs = 4.0f; + // Batch all GPU uploads into a single async command buffer submission so that + // N doodads with multiple textures each don't each block on vkQueueSubmit + + // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds + // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. + auto* vkCtx = renderer->getVkContext(); + if (vkCtx) vkCtx->beginUploadBatch(); + size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; for (auto it = pendingTransportDoodadBatches_.begin(); it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { @@ -7919,7 +8943,7 @@ void Application::processPendingTransportDoodads() { } if (!m2Model.isValid()) continue; - m2Renderer->loadModel(m2Model, doodadModelId); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); if (m2InstanceId == 0) continue; m2Renderer->setSkipCollision(m2InstanceId, true); @@ -7943,6 +8967,9 @@ void Application::processPendingTransportDoodads() { ++it; } } + + // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). + if (vkCtx) vkCtx->endUploadBatch(); } void Application::processPendingMount() { @@ -8291,6 +9318,13 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureRenderPosCache_.erase(guid); creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } @@ -8366,17 +9400,21 @@ void Application::updateQuestMarkers() { int markerType = -1; // -1 = no marker using game::QuestGiverStatus; + float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests) switch (status) { case QuestGiverStatus::AVAILABLE: + markerType = 0; // Yellow ! + break; case QuestGiverStatus::AVAILABLE_LOW: - markerType = 0; // Available (yellow !) + markerType = 0; // Grey ! (same texture, desaturated in shader) + markerGrayscale = 1.0f; break; case QuestGiverStatus::REWARD: case QuestGiverStatus::REWARD_REP: - markerType = 1; // Turn-in (yellow ?) + markerType = 1; // Yellow ? break; case QuestGiverStatus::INCOMPLETE: - markerType = 2; // Incomplete (grey ?) + markerType = 2; // Grey ? break; default: break; @@ -8410,7 +9448,7 @@ void Application::updateQuestMarkers() { } // Set the marker (renderer will handle positioning, bob, glow, etc.) - questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight); + questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale); markersAdded++; } diff --git a/src/core/memory_monitor.cpp b/src/core/memory_monitor.cpp index 913240fd..080a1ef6 100644 --- a/src/core/memory_monitor.cpp +++ b/src/core/memory_monitor.cpp @@ -109,16 +109,16 @@ size_t MemoryMonitor::getAvailableRAM() const { size_t MemoryMonitor::getRecommendedCacheBudget() const { size_t available = getAvailableRAM(); - // Use 80% of available RAM for caches (very aggressive), but cap at 90% of total - size_t budget = available * 80 / 100; - size_t maxBudget = totalRAM_ * 90 / 100; - return budget < maxBudget ? budget : maxBudget; + // Use 50% of available RAM for caches, hard-capped at 16 GB. + static constexpr size_t kHardCapBytes = 16ull * 1024 * 1024 * 1024; // 16 GB + size_t budget = available * 50 / 100; + return budget < kHardCapBytes ? budget : kHardCapBytes; } bool MemoryMonitor::isMemoryPressure() const { size_t available = getAvailableRAM(); - // Memory pressure if < 20% RAM available - return available < (totalRAM_ * 20 / 100); + // Memory pressure if < 10% RAM available + return available < (totalRAM_ * 10 / 100); } } // namespace core diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index c6355e9c..b908245b 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -80,6 +80,28 @@ bool isAuthCharPipelineOpcode(LogicalOpcode op) { } } +// Build a WoW-format item link for use in system chat messages. +// The chat renderer in game_screen.cpp parses this format and draws the +// item name in its quality colour with a small icon and tooltip. +// Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r +std::string buildItemLink(uint32_t itemId, uint32_t quality, const std::string& name) { + static const char* kQualHex[] = { + "9d9d9d", // 0 Poor + "ffffff", // 1 Common + "1eff00", // 2 Uncommon + "0070dd", // 3 Rare + "a335ee", // 4 Epic + "ff8000", // 5 Legendary + "e6cc80", // 6 Artifact + "e6cc80", // 7 Heirloom + }; + uint32_t qi = quality < 8 ? quality : 1u; + char buf[512]; + snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + kQualHex[qi], itemId, name.c_str()); + return buf; +} + bool isActiveExpansion(const char* expansionId) { auto& app = core::Application::getInstance(); auto* registry = app.getExpansionRegistry(); @@ -100,6 +122,94 @@ bool envFlagEnabled(const char* key, bool defaultValue = false) { raw[0] == 'n' || raw[0] == 'N'); } +int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return defaultValue; + return static_cast(std::clamp(parsed, minValue, maxValue)); +} + +int incomingPacketsBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float incomingPacketBudgetMs(WorldState state) { + static const int inWorldBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50); + static const int loginBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50); + return static_cast(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs); +} + +int updateObjectBlocksBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS", 24, 1, 2048); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_UPDATE_OBJECT_BLOCKS_LOGIN", 128, 1, 4096); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float slowPacketLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +float slowUpdateObjectBlockLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_UPDATE_BLOCK_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +constexpr size_t kMaxQueuedInboundPackets = 4096; + +bool hasFullPackedGuid(const network::Packet& packet) { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; +} + +bool packetHasRemaining(const network::Packet& packet, size_t need) { + const size_t size = packet.getSize(); + const size_t pos = packet.getReadPos(); + return pos <= size && need <= (size - pos); +} + +CombatTextEntry::Type combatTextTypeFromSpellMissInfo(uint8_t missInfo) { + switch (missInfo) { + case 0: return CombatTextEntry::MISS; + case 1: return CombatTextEntry::DODGE; + case 2: return CombatTextEntry::PARRY; + case 3: return CombatTextEntry::BLOCK; + case 4: return CombatTextEntry::EVADE; + case 5: return CombatTextEntry::IMMUNE; + case 6: return CombatTextEntry::DEFLECT; + case 7: return CombatTextEntry::ABSORB; + case 8: return CombatTextEntry::RESIST; + case 9: // Some cores encode SPELL_MISS_IMMUNE2 as 9. + case 10: // Others encode SPELL_MISS_IMMUNE2 as 10. + return CombatTextEntry::IMMUNE; + case 11: return CombatTextEntry::REFLECT; + default: return CombatTextEntry::MISS; + } +} + std::string formatCopperAmount(uint32_t amount) { uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; @@ -123,6 +233,40 @@ std::string formatCopperAmount(uint32_t amount) { return oss.str(); } +std::string displaySpellName(GameHandler& handler, uint32_t spellId) { + if (spellId == 0) return {}; + const std::string& name = handler.getSpellName(spellId); + if (!name.empty()) return name; + return "spell " + std::to_string(spellId); +} + +std::string formatSpellNameList(GameHandler& handler, + const std::vector& spellIds, + size_t maxShown = 3) { + if (spellIds.empty()) return {}; + + const size_t shownCount = std::min(spellIds.size(), maxShown); + std::ostringstream oss; + for (size_t i = 0; i < shownCount; ++i) { + if (i > 0) { + if (shownCount == 2) { + oss << " and "; + } else if (i == shownCount - 1) { + oss << ", and "; + } else { + oss << ", "; + } + } + oss << displaySpellName(handler, spellIds[i]); + } + + if (spellIds.size() > shownCount) { + oss << ", and " << (spellIds.size() - shownCount) << " more"; + } + + return oss.str(); +} + bool readCStringAt(const std::vector& data, size_t start, std::string& out, size_t& nextPos) { out.clear(); if (start >= data.size()) return false; @@ -259,6 +403,16 @@ bool isPlaceholderQuestTitle(const std::string& s) { return s.rfind("Quest #", 0) == 0; } +float mergeCooldownSeconds(float current, float incoming) { + constexpr float kEpsilon = 0.05f; + if (incoming <= 0.0f) return 0.0f; + if (current <= 0.0f) return incoming; + // Cooldowns should normally tick down. If a duplicate/late packet reports a + // larger value, keep the local remaining time to avoid visible timer resets. + if (incoming > current + kEpsilon) return current; + return incoming; +} + bool looksLikeQuestDescriptionText(const std::string& s) { int spaces = 0; int commas = 0; @@ -357,6 +511,130 @@ QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector& data return best; } + +// Parse kill/item objectives from SMSG_QUEST_QUERY_RESPONSE raw data. +// Returns true if the objective block was found and at least one entry read. +// +// Format after the fixed integer header (40*4 Classic or 55*4 WotLK bytes post questId+questMethod): +// N strings (title, objectives, details, endText; + completedText for WotLK) +// 4x { int32 npcOrGoId, uint32 count } -- entity (kill/interact) objectives +// 6x { uint32 itemId, uint32 count } -- item collect objectives +// 4x cstring -- per-objective display text +// +// We use the same fixed-offset heuristic as pickBestQuestQueryTexts and then scan past +// the string section to reach the objective data. +struct QuestQueryObjectives { + struct Kill { int32_t npcOrGoId; uint32_t required; }; + struct Item { uint32_t itemId; uint32_t required; }; + std::array kills{}; + std::array items{}; + bool valid = false; +}; + +static uint32_t readU32At(const std::vector& d, size_t pos) { + return static_cast(d[pos]) + | (static_cast(d[pos + 1]) << 8) + | (static_cast(d[pos + 2]) << 16) + | (static_cast(d[pos + 3]) << 24); +} + +// Try to parse objective block starting at `startPos` with `nStrings` strings before it. +// Returns a valid QuestQueryObjectives if the data looks plausible, otherwise invalid. +static QuestQueryObjectives tryParseQuestObjectivesAt(const std::vector& data, + size_t startPos, int nStrings) { + QuestQueryObjectives out; + size_t pos = startPos; + + // Scan past each string (null-terminated). + for (int si = 0; si < nStrings; ++si) { + while (pos < data.size() && data[pos] != 0) ++pos; + if (pos >= data.size()) return out; // truncated + ++pos; // consume null terminator + } + + // Read 4 entity objectives: int32 npcOrGoId + uint32 count each. + for (int i = 0; i < 4; ++i) { + if (pos + 8 > data.size()) return out; + out.kills[i].npcOrGoId = static_cast(readU32At(data, pos)); pos += 4; + out.kills[i].required = readU32At(data, pos); pos += 4; + } + + // Read 6 item objectives: uint32 itemId + uint32 count each. + for (int i = 0; i < 6; ++i) { + if (pos + 8 > data.size()) break; + out.items[i].itemId = readU32At(data, pos); pos += 4; + out.items[i].required = readU32At(data, pos); pos += 4; + } + + out.valid = true; + return out; +} + +QuestQueryObjectives extractQuestQueryObjectives(const std::vector& data, bool classicHint) { + if (data.size() < 16) return {}; + + // questId(4) + questMethod(4) prefix before the fixed integer header. + const size_t base = 8; + // Classic/TBC: 40 fixed uint32 fields + 4 strings before objectives. + // WotLK: 55 fixed uint32 fields + 5 strings before objectives. + const size_t classicStart = base + 40u * 4u; + const size_t wotlkStart = base + 55u * 4u; + + // Try the expected layout first, then fall back to the other. + if (classicHint) { + auto r = tryParseQuestObjectivesAt(data, classicStart, 4); + if (r.valid) return r; + return tryParseQuestObjectivesAt(data, wotlkStart, 5); + } else { + auto r = tryParseQuestObjectivesAt(data, wotlkStart, 5); + if (r.valid) return r; + return tryParseQuestObjectivesAt(data, classicStart, 4); + } +} + +// Parse quest reward fields from SMSG_QUEST_QUERY_RESPONSE fixed header. +// Classic/TBC: 40 fixed fields; WotLK: 55 fixed fields. +struct QuestQueryRewards { + int32_t rewardMoney = 0; + std::array itemId{}; + std::array itemCount{}; + std::array choiceItemId{}; + std::array choiceItemCount{}; + bool valid = false; +}; + +static QuestQueryRewards tryParseQuestRewards(const std::vector& data, + bool classicLayout) { + const size_t base = 8; // after questId(4) + questMethod(4) + const size_t fieldCount = classicLayout ? 40u : 55u; + const size_t headerEnd = base + fieldCount * 4u; + if (data.size() < headerEnd) return {}; + + // Field indices (0-based) for each expansion: + // Classic/TBC: rewardMoney=[14], rewardItemId[4]=[20..23], rewardItemCount[4]=[24..27], + // rewardChoiceItemId[6]=[28..33], rewardChoiceItemCount[6]=[34..39] + // WotLK: rewardMoney=[17], rewardItemId[4]=[30..33], rewardItemCount[4]=[34..37], + // rewardChoiceItemId[6]=[38..43], rewardChoiceItemCount[6]=[44..49] + const size_t moneyField = classicLayout ? 14u : 17u; + const size_t itemIdField = classicLayout ? 20u : 30u; + const size_t itemCountField = classicLayout ? 24u : 34u; + const size_t choiceIdField = classicLayout ? 28u : 38u; + const size_t choiceCntField = classicLayout ? 34u : 44u; + + QuestQueryRewards out; + out.rewardMoney = static_cast(readU32At(data, base + moneyField * 4u)); + for (size_t i = 0; i < 4; ++i) { + out.itemId[i] = readU32At(data, base + (itemIdField + i) * 4u); + out.itemCount[i] = readU32At(data, base + (itemCountField + i) * 4u); + } + for (size_t i = 0; i < 6; ++i) { + out.choiceItemId[i] = readU32At(data, base + (choiceIdField + i) * 4u); + out.choiceItemCount[i] = readU32At(data, base + (choiceCntField + i) * 4u); + } + out.valid = true; + return out; +} + } // namespace @@ -450,8 +728,7 @@ bool GameHandler::connect(const std::string& host, // Set up packet callback socket->setPacketCallback([this](const network::Packet& packet) { - network::Packet mutablePacket = packet; - handlePacket(mutablePacket); + enqueueIncomingPacket(packet); }); // Connect to world server @@ -482,10 +759,16 @@ void GameHandler::disconnect() { activeCharacterGuid_ = 0; playerNameCache.clear(); pendingNameQueries.clear(); + guildNameCache_.clear(); + pendingGuildNameQueries_.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; @@ -499,6 +782,25 @@ void GameHandler::disconnect() { wardenModuleSize_ = 0; wardenModuleData_.clear(); wardenLoadedModule_.reset(); + pendingIncomingPackets_.clear(); + pendingUpdateObjectWork_.clear(); + // Fire despawn callbacks so the renderer releases M2/character model resources. + for (const auto& [guid, entity] : entityManager.getEntities()) { + if (guid == playerGuid) continue; + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) + creatureDespawnCallback_(guid); + else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) + playerDespawnCallback_(guid); + else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) + gameObjectDespawnCallback_(guid); + } + otherPlayerVisibleItemEntries_.clear(); + otherPlayerVisibleDirty_.clear(); + otherPlayerMoveTimeMs_.clear(); + unitCastStates_.clear(); + unitAurasCache_.clear(); + combatText.clear(); + entityManager.clear(); setState(WorldState::DISCONNECTED); LOG_INFO("Disconnected from world server"); } @@ -548,6 +850,10 @@ void GameHandler::update(float deltaTime) { return; } + // Reset per-tick monster-move budget tracking (Classic/Turtle flood protection). + monsterMovePacketsThisTick_ = 0; + monsterMovePacketsDroppedThisTick_ = 0; + // Update socket (processes incoming data and triggers callbacks) if (socket) { auto socketStart = std::chrono::steady_clock::now(); @@ -559,11 +865,60 @@ void GameHandler::update(float deltaTime) { } } + { + auto packetStart = std::chrono::steady_clock::now(); + processQueuedIncomingPackets(); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetStart).count(); + if (packetMs > 3.0f) { + LOG_WARNING("SLOW queued packet handling: ", packetMs, "ms"); + } + } + + // Drain pending async Warden response (built on background thread to avoid 5s stalls) + if (wardenResponsePending_) { + auto status = wardenPendingEncrypted_.wait_for(std::chrono::milliseconds(0)); + if (status == std::future_status::ready) { + auto plaintext = wardenPendingEncrypted_.get(); + wardenResponsePending_ = false; + if (!plaintext.empty() && wardenCrypto_) { + std::vector encrypted = wardenCrypto_->encrypt(plaintext); + network::Packet response(wireOpcode(Opcode::CMSG_WARDEN_DATA)); + for (uint8_t byte : encrypted) { + response.writeUInt8(byte); + } + if (socket && socket->isConnected()) { + socket->send(response); + LOG_WARNING("Warden: Sent async CHEAT_CHECKS_RESULT (", plaintext.size(), " bytes plaintext)"); + } + } + } + } + + // Detect RX silence (server stopped sending packets but TCP still open) + if (state == WorldState::IN_WORLD && socket && socket->isConnected() && + lastRxTime_.time_since_epoch().count() > 0) { + auto silenceMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - lastRxTime_).count(); + if (silenceMs > 10000 && !rxSilenceLogged_) { + rxSilenceLogged_ = true; + LOG_WARNING("RX SILENCE: No packets from server for ", silenceMs, "ms — possible soft disconnect"); + } + if (silenceMs > 15000 && silenceMs < 15500) { + LOG_WARNING("RX SILENCE: 15s — server appears to have stopped sending"); + } + } + // Detect server-side disconnect (socket closed during update) if (socket && !socket->isConnected() && state != WorldState::DISCONNECTED) { - LOG_WARNING("Server closed connection in state: ", worldStateName(state)); - disconnect(); - return; + if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + LOG_WARNING("Server closed connection in state: ", worldStateName(state)); + disconnect(); + return; + } + LOG_DEBUG("World socket closed with ", pendingIncomingPackets_.size(), + " queued packet(s) and ", pendingUpdateObjectWork_.size(), + " update-object batch(es) pending dispatch"); } // Post-gate visibility: determine whether server goes silent or closes after Warden requirement. @@ -582,6 +937,17 @@ void GameHandler::update(float deltaTime) { clearTarget(); } + // Detect combat state transitions → fire PLAYER_REGEN_DISABLED / PLAYER_REGEN_ENABLED + { + bool combatNow = isInCombat(); + if (combatNow != wasCombat_) { + wasCombat_ = combatNow; + if (addonEventCallback_) { + addonEventCallback_(combatNow ? "PLAYER_REGEN_DISABLED" : "PLAYER_REGEN_ENABLED", {}); + } + } + } + if (auctionSearchDelayTimer_ > 0.0f) { auctionSearchDelayTimer_ -= deltaTime; if (auctionSearchDelayTimer_ < 0.0f) auctionSearchDelayTimer_ = 0.0f; @@ -651,6 +1017,13 @@ void GameHandler::update(float deltaTime) { it->timer -= deltaTime; if (it->timer <= 0.0f) { if (state == WorldState::IN_WORLD && socket) { + // Avoid sending CMSG_LOOT while a timed cast is active (e.g. gathering). + // handleSpellGo will trigger loot after the cast completes. + if (casting && currentCastSpellId != 0) { + it->timer = 0.20f; + ++it; + continue; + } lootTarget(it->guid); } it = pendingGameObjectLootOpens_.erase(it); @@ -745,7 +1118,9 @@ void GameHandler::update(float deltaTime) { timeSinceLastPing += deltaTime; timeSinceLastMoveHeartbeat_ += deltaTime; - if (timeSinceLastPing >= pingInterval) { + const float currentPingInterval = + (isClassicLikeExpansion() || isActiveExpansion("tbc")) ? 10.0f : pingInterval; + if (timeSinceLastPing >= currentPingInterval) { if (socket) { sendPing(); } @@ -754,9 +1129,27 @@ void GameHandler::update(float deltaTime) { const bool classicLikeCombatSync = autoAttackRequested_ && (isClassicLikeExpansion() || isActiveExpansion("tbc")); + const uint32_t locomotionFlags = + static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT) | + static_cast(MovementFlags::TURN_LEFT) | + static_cast(MovementFlags::TURN_RIGHT) | + static_cast(MovementFlags::ASCENDING) | + static_cast(MovementFlags::FALLING) | + static_cast(MovementFlags::FALLINGFAR); + const bool classicLikeStationaryCombatSync = + classicLikeCombatSync && + !onTaxiFlight_ && + !taxiActivatePending_ && + !taxiClientActive_ && + (movementInfo.flags & locomotionFlags) == 0; float heartbeatInterval = (onTaxiFlight_ || taxiActivatePending_ || taxiClientActive_) ? 0.25f - : (classicLikeCombatSync ? 0.05f : moveHeartbeatInterval_); + : (classicLikeStationaryCombatSync ? 0.75f + : (classicLikeCombatSync ? 0.20f + : moveHeartbeatInterval_)); if (timeSinceLastMoveHeartbeat_ >= heartbeatInterval) { sendMovement(Opcode::MSG_MOVE_HEARTBEAT); timeSinceLastMoveHeartbeat_ = 0.0f; @@ -774,8 +1167,10 @@ void GameHandler::update(float deltaTime) { (autoAttacking || autoAttackRequested_)) { pendingGameObjectInteractGuid_ = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + addUIError("Interrupted."); addSystemChatMessage("Interrupted."); } if (casting && castTimeRemaining > 0.0f) { @@ -787,6 +1182,7 @@ void GameHandler::update(float deltaTime) { performGameObjectInteractionNow(interactGuid); } casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; } @@ -827,6 +1223,12 @@ void GameHandler::update(float deltaTime) { updateCombatText(deltaTime); tickMinimapPings(deltaTime); + // Tick logout countdown + if (loggingOut_ && logoutCountdown_ > 0.0f) { + logoutCountdown_ -= deltaTime; + if (logoutCountdown_ < 0.0f) logoutCountdown_ = 0.0f; + } + // Update taxi landing cooldown if (taxiLandingCooldown_ > 0.0f) { taxiLandingCooldown_ -= deltaTime; @@ -992,6 +1394,7 @@ void GameHandler::update(float deltaTime) { autoAttackOutOfRangeTime_ += deltaTime; if (autoAttackRangeWarnCooldown_ <= 0.0f) { addSystemChatMessage("Target is too far away."); + addUIError("Target is too far away."); autoAttackRangeWarnCooldown_ = 1.25f; } // Stop chasing stale swings when the target remains out of range. @@ -1009,26 +1412,28 @@ void GameHandler::update(float deltaTime) { autoAttackResendTimer_ += deltaTime; autoAttackFacingSyncTimer_ += deltaTime; - // Re-request swing more aggressively until server confirms active loop. - float resendInterval = 1.0f; - if (!autoAttacking || autoAttackOutOfRange_) { - resendInterval = classicLike ? 0.25f : 0.50f; - } - if (autoAttackResendTimer_ >= resendInterval) { + // Classic/Turtle servers do not tolerate steady attack-start + // reissues well. Only retry once after local start or an + // explicit server-side attack stop while intent is still set. + const float resendInterval = classicLike ? 1.0f : 0.50f; + if (!autoAttacking && !autoAttackOutOfRange_ && autoAttackRetryPending_ && + autoAttackResendTimer_ >= resendInterval) { autoAttackResendTimer_ = 0.0f; + autoAttackRetryPending_ = false; auto pkt = AttackSwingPacket::build(autoAttackTarget); socket->send(pkt); } - // Keep server-facing aligned with our current melee target. - // Some vanilla-family realms become strict about front-arc checks unless - // the client sends explicit facing updates while stationary. - const float facingSyncInterval = classicLike ? 0.10f : 0.20f; - if (autoAttackFacingSyncTimer_ >= facingSyncInterval) { + // Keep server-facing aligned while trying to acquire melee. + // Once the server confirms auto-attack, rely on explicit + // bad-facing feedback instead of periodic steady-state facing spam. + const float facingSyncInterval = classicLike ? 0.25f : 0.20f; + const bool allowPeriodicFacingSync = !classicLike || !autoAttacking; + if (allowPeriodicFacingSync && + autoAttackFacingSyncTimer_ >= facingSyncInterval) { autoAttackFacingSyncTimer_ = 0.0f; float toTargetX = targetX - movementInfo.x; float toTargetY = targetY - movementInfo.y; - bool sentMovement = false; if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) { float desired = std::atan2(-toTargetY, toTargetX); float diff = desired - movementInfo.orientation; @@ -1038,20 +1443,7 @@ void GameHandler::update(float deltaTime) { if (std::abs(diff) > facingThreshold) { movementInfo.orientation = desired; sendMovement(Opcode::MSG_MOVE_SET_FACING); - // Follow facing update with a heartbeat to tighten server range/facing checks. - sendMovement(Opcode::MSG_MOVE_HEARTBEAT); - sentMovement = true; } - } else if (classicLike) { - // Keep stationary melee position/facing fresh for strict vanilla-family checks. - sendMovement(Opcode::MSG_MOVE_HEARTBEAT); - sentMovement = true; - } - - // Even when facing is already correct, keep position fresh while - // trying to connect melee hits so servers don't require a step. - if (!sentMovement && (!autoAttacking || autoAttackOutOfRange_)) { - sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } } } @@ -1485,12 +1877,15 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CHAT_WRONG_FACTION: + addUIError("You cannot send messages to members of that faction."); addSystemChatMessage("You cannot send messages to members of that faction."); break; case Opcode::SMSG_CHAT_NOT_IN_PARTY: + addUIError("You are not in a party."); addSystemChatMessage("You are not in a party."); break; case Opcode::SMSG_CHAT_RESTRICTED: + addUIError("You cannot send chat messages in this area."); addSystemChatMessage("You cannot send chat messages in this area."); break; @@ -1512,6 +1907,29 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + case Opcode::SMSG_WHOIS: { + // GM/admin response to /whois command: cstring with account/IP info + // Format: string (the whois result text, typically "Name: ...\nAccount: ...\nIP: ...") + if (packet.getReadPos() < packet.getSize()) { + std::string whoisText = packet.readString(); + if (!whoisText.empty()) { + // Display each line of the whois response in system chat + std::string line; + for (char c : whoisText) { + if (c == '\n') { + if (!line.empty()) addSystemChatMessage("[Whois] " + line); + line.clear(); + } else { + line += c; + } + } + if (!line.empty()) addSystemChatMessage("[Whois] " + line); + LOG_INFO("SMSG_WHOIS: ", whoisText); + } + } + break; + } + case Opcode::SMSG_FRIEND_STATUS: if (state == WorldState::IN_WORLD) { handleFriendStatus(packet); @@ -1524,10 +1942,22 @@ void GameHandler::handlePacket(network::Packet& packet) { // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) handleFriendList(packet); break; - case Opcode::SMSG_IGNORE_LIST: - // Ignore list: consume to avoid spurious warnings; not parsed. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_IGNORE_LIST: { + // uint8 count + count × (uint64 guid + string name) + // Populate ignoreCache so /unignore works for pre-existing ignores. + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t ignCount = packet.readUInt8(); + for (uint8_t i = 0; i < ignCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + uint64_t ignGuid = packet.readUInt64(); + std::string ignName = packet.readString(); + if (!ignName.empty() && ignGuid != 0) { + ignoreCache[ignName] = ignGuid; + } + } + LOG_DEBUG("SMSG_IGNORE_LIST: loaded ", (int)ignCount, " ignored players"); break; + } case Opcode::MSG_RANDOM_ROLL: if (state == WorldState::IN_WORLD) { @@ -1548,19 +1978,42 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint32_t itemSlot =*/ packet.readUInt32(); uint32_t itemId = packet.readUInt32(); /*uint32_t suffixFactor =*/ packet.readUInt32(); - /*int32_t randomProp =*/ static_cast(packet.readUInt32()); + int32_t randomProp = static_cast(packet.readUInt32()); uint32_t count = packet.readUInt32(); /*uint32_t totalCount =*/ packet.readUInt32(); queryItemInfo(itemId, 0); if (showInChat) { - std::string itemName = "item #" + std::to_string(itemId); if (const ItemQueryResponseData* info = getItemInfo(itemId)) { - if (!info->name.empty()) itemName = info->name; + // Item info already cached — emit immediately. + std::string itemName = info->name.empty() ? ("item #" + std::to_string(itemId)) : info->name; + // Append random suffix name (e.g., "of the Eagle") if present + if (randomProp != 0) { + std::string suffix = getRandomPropertyName(randomProp); + if (!suffix.empty()) itemName += " " + suffix; + } + uint32_t quality = info->quality; + std::string link = buildItemLink(itemId, quality, itemName); + std::string msg = "Received: " + link; + if (count > 1) msg += " x" + std::to_string(count); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } + if (itemLootCallback_) itemLootCallback_(itemId, count, quality, itemName); + // Fire CHAT_MSG_LOOT for loot tracking addons + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_LOOT", {msg, "", std::to_string(itemId), std::to_string(count)}); + } else { + // Item info not yet cached; defer until SMSG_ITEM_QUERY_SINGLE_RESPONSE. + pendingItemPushNotifs_.push_back({itemId, count}); } - std::string msg = "Received: " + itemName; - if (count > 1) msg += " x" + std::to_string(count); - addSystemChatMessage(msg); + } + // Fire bag/inventory events for all item receipts (not just chat-visible ones) + if (addonEventCallback_) { + addonEventCallback_("BAG_UPDATE", {}); + addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"}); } LOG_INFO("Item push: itemId=", itemId, " count=", count, " showInChat=", static_cast(showInChat)); @@ -1605,14 +2058,28 @@ 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); + addCombatText(CombatTextEntry::XP_GAIN, + static_cast(xpGained), 0, true); // XP is updated via PLAYER_XP update fields from the server. + if (areaDiscoveryCallback_) + areaDiscoveryCallback_(areaName, xpGained); + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)}); } } break; @@ -1628,13 +2095,29 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t reason = packet.readUInt8(); const char* msg = (reason < 8) ? reasons[reason] : "Unknown reason"; std::string s = std::string("Failed to tame: ") + msg; + addUIError(s); addSystemChatMessage(s); } break; } case Opcode::SMSG_PET_ACTION_FEEDBACK: { - // uint8 action + uint8 flags - packet.setReadPos(packet.getSize()); // Consume; no UI for pet feedback yet. + // uint8 msg: 1=dead, 2=nothing_to_attack, 3=cant_attack_target, + // 4=target_too_far, 5=no_path, 6=cant_attack_immune + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t msg = packet.readUInt8(); + static const char* kPetFeedback[] = { + nullptr, + "Your pet is dead.", + "Your pet has nothing to attack.", + "Your pet cannot attack that target.", + "That target is too far away.", + "Your pet cannot find a path to the target.", + "Your pet cannot attack an immune target.", + }; + if (msg > 0 && msg < 7 && kPetFeedback[msg]) { + addSystemChatMessage(kPetFeedback[msg]); + } + packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_PET_NAME_QUERY_RESPONSE: { @@ -1646,9 +2129,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), "Quest %u failed!", questId); - addSystemChatMessage(buf); + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + addSystemChatMessage(questTitle.empty() + ? std::string("Quest failed!") + : ('"' + questTitle + "\" failed!")); } break; } @@ -1656,9 +2142,12 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 questId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t questId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), "Quest %u timed out!", questId); - addSystemChatMessage(buf); + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + addSystemChatMessage(questTitle.empty() + ? std::string("Quest timed out!") + : ('"' + questTitle + "\" has timed out.")); } break; } @@ -1678,6 +2167,15 @@ void GameHandler::handlePacket(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { unit->setHealth(hp); } + if (addonEventCallback_ && guid != 0) { + std::string unitId; + if (guid == playerGuid) unitId = "player"; + else if (guid == targetGuid) unitId = "target"; + else if (guid == focusGuid) unitId = "focus"; + else if (guid == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_HEALTH", {unitId}); + } break; } case Opcode::SMSG_POWER_UPDATE: { @@ -1695,6 +2193,15 @@ void GameHandler::handlePacket(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { unit->setPowerByType(powerType, value); } + if (addonEventCallback_ && guid != 0) { + std::string unitId; + if (guid == playerGuid) unitId = "player"; + else if (guid == targetGuid) unitId = "target"; + else if (guid == focusGuid) unitId = "focus"; + else if (guid == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_POWER", {unitId}); + } break; } @@ -1706,6 +2213,8 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t value = packet.readUInt32(); worldStates_[field] = value; LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); + if (addonEventCallback_) + addonEventCallback_("UPDATE_WORLD_STATES", {}); break; } case Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE: { @@ -1726,6 +2235,13 @@ void GameHandler::handlePacket(network::Packet& packet) { std::dec, " rank=", rank); std::string msg = "You gain " + std::to_string(honor) + " honor points."; addSystemChatMessage(msg); + if (honor > 0) + addCombatText(CombatTextEntry::HONOR_GAIN, static_cast(honor), 0, true); + if (pvpHonorCallback_) { + pvpHonorCallback_(honor, victimGuid, rank); + } + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_COMBAT_HONOR_GAIN", {msg}); } break; } @@ -1763,6 +2279,11 @@ void GameHandler::handlePacket(network::Packet& packet) { mirrorTimers_[type].scale = scale; mirrorTimers_[type].paused = (paused != 0); mirrorTimers_[type].active = true; + if (addonEventCallback_) + addonEventCallback_("MIRROR_TIMER_START", { + std::to_string(type), std::to_string(value), + std::to_string(maxV), std::to_string(scale), + paused ? "1" : "0"}); } break; } @@ -1773,6 +2294,8 @@ void GameHandler::handlePacket(network::Packet& packet) { if (type < 3) { mirrorTimers_[type].active = false; mirrorTimers_[type].value = 0; + if (addonEventCallback_) + addonEventCallback_("MIRROR_TIMER_STOP", {std::to_string(type)}); } break; } @@ -1783,6 +2306,8 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t paused = packet.readUInt8(); if (type < 3) { mirrorTimers_[type].paused = (paused != 0); + if (addonEventCallback_) + addonEventCallback_("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"}); } break; } @@ -1797,14 +2322,34 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { if (castResult != 0) { casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - const char* reason = getSpellCastResultString(castResult, -1); + lastInteractedGoGuid_ = 0; + // Cancel craft queue and spell queue on cast failure + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; + // Pass player's power type so result 85 says "Not enough rage/energy/etc." + int playerPowerType = -1; + if (auto pe = entityManager.getEntity(playerGuid)) { + if (auto pu = std::dynamic_pointer_cast(pe)) + playerPowerType = static_cast(pu->getPowerType()); + } + const char* reason = getSpellCastResultString(castResult, playerPowerType); + std::string errMsg = reason ? reason + : ("Spell cast failed (error " + std::to_string(castResult) + ")"); + addUIError(errMsg); + if (spellCastFailedCallback_) spellCastFailedCallback_(castResultSpellId); + if (addonEventCallback_) { + addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)}); + } MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - msg.message = reason ? reason - : ("Spell cast failed (error " + std::to_string(castResult) + ")"); + msg.message = errMsg; addLocalChatMessage(msg); } } @@ -1821,6 +2366,16 @@ void GameHandler::handlePacket(network::Packet& packet) { : UpdateObjectParser::readPackedGuid(packet); if (failOtherGuid != 0 && failOtherGuid != playerGuid) { unitCastStates_.erase(failOtherGuid); + // Fire cast failure events so cast bar addons clear the bar + if (addonEventCallback_) { + std::string unitId; + if (failOtherGuid == targetGuid) unitId = "target"; + else if (failOtherGuid == focusGuid) unitId = "focus"; + if (!unitId.empty()) { + addonEventCallback_("UNIT_SPELLCAST_FAILED", {unitId}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); + } + } } packet.setReadPos(packet.getSize()); break; @@ -1828,43 +2383,85 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { - // casterGuid(8) + victimGuid(8) + uint32 spellId + uint8 logSchoolMask - if (packet.getSize() - packet.getReadPos() >= 17) { - /*uint64_t caster =*/ packet.readUInt64(); - uint64_t victim = packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); - if (victim == playerGuid) - addCombatText(CombatTextEntry::MISS, 0, spellId, false); + // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + ... + // TBC: uint64 caster + uint64 victim + uint32 spellId + ... + const bool prUsesFullGuid = isActiveExpansion("tbc"); + auto readPrGuid = [&]() -> uint64_t { + if (prUsesFullGuid) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) + || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; } + uint64_t caster = readPrGuid(); + if (packet.getSize() - packet.getReadPos() < (prUsesFullGuid ? 8u : 1u) + || (!prUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victim = readPrGuid(); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t spellId = packet.readUInt32(); + if (victim == playerGuid) { + addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); + } else if (caster == playerGuid) { + addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); + } + packet.setReadPos(packet.getSize()); break; } // ---- Loot start roll (Need/Greed popup trigger) ---- case Opcode::SMSG_LOOT_START_ROLL: { - // uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId - // + uint32 randomSuffix + uint32 randomPropId + uint32 countdown + uint8 voteMask - if (packet.getSize() - packet.getReadPos() < 33) break; + // WotLK 3.3.5a: uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId + // + uint32 randomSuffix + uint32 randomPropId + uint32 countdown + uint8 voteMask (33 bytes) + // Classic/TBC: uint64 objectGuid + uint32 mapId + uint32 lootSlot + uint32 itemId + // + uint32 countdown + uint8 voteMask (25 bytes) + const bool isWotLK = isActiveExpansion("wotlk"); + const size_t minSize = isWotLK ? 33u : 25u; + if (packet.getSize() - packet.getReadPos() < minSize) break; uint64_t objectGuid = packet.readUInt64(); /*uint32_t mapId =*/ packet.readUInt32(); uint32_t slot = packet.readUInt32(); uint32_t itemId = packet.readUInt32(); - /*uint32_t randSuffix =*/ packet.readUInt32(); - /*uint32_t randProp =*/ packet.readUInt32(); - /*uint32_t countdown =*/ packet.readUInt32(); - /*uint8_t voteMask =*/ packet.readUInt8(); + int32_t rollRandProp = 0; + if (isWotLK) { + /*uint32_t randSuffix =*/ packet.readUInt32(); + rollRandProp = static_cast(packet.readUInt32()); + } + uint32_t countdown = packet.readUInt32(); + uint8_t voteMask = packet.readUInt8(); // Trigger the roll popup for local player pendingLootRollActive_ = true; pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + // Ensure item info is queried so the roll popup can show the name/icon. + queryItemInfo(itemId, 0); auto* info = getItemInfo(itemId); - pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); + std::string rollItemName = info ? info->name : std::to_string(itemId); + if (rollRandProp != 0) { + std::string suffix = getRandomPropertyName(rollRandProp); + if (!suffix.empty()) rollItemName += " " + suffix; + } + pendingLootRoll_.itemName = rollItemName; pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + pendingLootRoll_.rollCountdownMs = (countdown > 0 && countdown <= 120000) ? countdown : 60000; + pendingLootRoll_.voteMask = voteMask; + pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_START_ROLL: item=", itemId, " (", pendingLootRoll_.itemName, - ") slot=", slot); + ") slot=", slot, " voteMask=0x", std::hex, (int)voteMask, std::dec); + if (addonEventCallback_) + addonEventCallback_("START_LOOT_ROLL", {std::to_string(slot), std::to_string(countdown)}); break; } + // ---- Pet stable list ---- + case Opcode::MSG_LIST_STABLED_PETS: + if (state == WorldState::IN_WORLD) handleListStabledPets(packet); + break; + // ---- Pet stable result ---- case Opcode::SMSG_STABLE_RESULT: { // uint8 result @@ -1876,11 +2473,17 @@ void GameHandler::handlePacket(network::Packet& packet) { case 0x06: msg = "Pet retrieved from stable."; break; case 0x07: msg = "Stable slot purchased."; break; case 0x08: msg = "Stable list updated."; break; - case 0x09: msg = "Stable failed: not enough money or other error."; break; + case 0x09: msg = "Stable failed: not enough money or other error."; + addUIError(msg); break; default: break; } if (msg) addSystemChatMessage(msg); LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); + // Refresh the stable list after a result to reflect the new state + if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) { + auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(refreshPkt); + } break; } @@ -1890,31 +2493,79 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t titleBit = packet.readUInt32(); uint32_t isLost = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - isLost ? "Title removed (ID %u)." : "Title earned (ID %u)!", - titleBit); - addSystemChatMessage(buf); - LOG_INFO("SMSG_TITLE_EARNED: id=", titleBit, " lost=", isLost); + loadTitleNameCache(); + + // Format the title string using the player's own name + std::string titleStr; + auto tit = titleNameCache_.find(titleBit); + if (tit != titleNameCache_.end() && !tit->second.empty()) { + // Title strings contain "%s" as a player-name placeholder. + // Replace it with the local player's name if known. + auto nameIt = playerNameCache.find(playerGuid); + const std::string& pName = (nameIt != playerNameCache.end()) + ? nameIt->second : "you"; + const std::string& fmt = tit->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) { + titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + } else { + titleStr = fmt; + } + } + + std::string msg; + if (!titleStr.empty()) { + msg = isLost ? ("Title removed: " + titleStr + ".") + : ("Title earned: " + titleStr + "!"); + } else { + char buf[64]; + std::snprintf(buf, sizeof(buf), + isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", + titleBit); + msg = buf; + } + // Track in known title set + if (isLost) { + knownTitleBits_.erase(titleBit); + } else { + knownTitleBits_.insert(titleBit); + } + + // Only post chat message for actual earned/lost events (isLost and new earn) + // Server sends isLost=0 for all known titles during login — suppress the chat spam + // by only notifying when we already had some titles (after login sequence) + addSystemChatMessage(msg); + LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, + " title='", titleStr, "' known=", knownTitleBits_.size()); break; } + case Opcode::SMSG_LEARNED_DANCE_MOVES: + // Contains bitmask of learned dance moves — cosmetic only, no gameplay effect. + LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")"); + break; + // ---- Hearthstone binding ---- case Opcode::SMSG_PLAYERBOUND: { // uint64 binderGuid + uint32 mapId + uint32 zoneId if (packet.getSize() - packet.getReadPos() < 16) break; /*uint64_t binderGuid =*/ packet.readUInt64(); - uint32_t mapId = packet.readUInt32(); + uint32_t mapId = packet.readUInt32(); uint32_t zoneId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Your home location has been set (map %u, zone %u).", mapId, zoneId); - addSystemChatMessage(buf); + // Update home bind location so hearthstone tooltip reflects the new zone + homeBindMapId_ = mapId; + homeBindZoneId_ = zoneId; + std::string pbMsg = "Your home location has been set"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + pbMsg += " to " + zoneName; + pbMsg += '.'; + addSystemChatMessage(pbMsg); break; } case Opcode::SMSG_BINDER_CONFIRM: { - // uint64 npcGuid — server confirming bind point has been set - addSystemChatMessage("This innkeeper is now your home location."); + // uint64 npcGuid — fires just before SMSG_PLAYERBOUND; PLAYERBOUND shows + // the zone name so this confirm is redundant. Consume silently. packet.setReadPos(packet.getSize()); break; } @@ -1952,6 +2603,8 @@ void GameHandler::handlePacket(network::Packet& packet) { poi.icon = icon; poi.data = data; poi.name = std::move(name); + // Cap POI count to prevent unbounded growth from rapid gossip queries + if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin()); gossipPois_.push_back(std::move(poi)); LOG_DEBUG("SMSG_GOSSIP_POI: x=", poiX, " y=", poiY, " icon=", icon); break; @@ -1967,7 +2620,22 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Character name changed to: " + newName); } else { - addSystemChatMessage("Character rename failed (error " + std::to_string(result) + ")."); + // ResponseCodes for name changes (shared with char create) + static const char* kRenameErrors[] = { + nullptr, // 0 = success + "Name already in use.", // 1 + "Name too short.", // 2 + "Name too long.", // 3 + "Name contains invalid characters.", // 4 + "Name contains a profanity.", // 5 + "Name is reserved.", // 6 + "Character name does not meet requirements.", // 7 + }; + const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; + std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg + : "Character rename failed."; + addUIError(renameErr); + addSystemChatMessage(renameErr); } LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); } @@ -1980,6 +2648,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Your home is now set to this location."); } else { + addUIError("You are too far from the innkeeper."); addSystemChatMessage("You are too far from the innkeeper."); } } @@ -1998,12 +2667,14 @@ void GameHandler::handlePacket(network::Packet& packet) { "You must be in a raid group", "Player not in group" }; const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; + addUIError(std::string("Cannot change difficulty: ") + msg); addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); } } break; } case Opcode::SMSG_CORPSE_NOT_IN_INSTANCE: + addUIError("Your corpse is outside this instance."); addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); break; case Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD: { @@ -2030,9 +2701,11 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FORCE_ANIM: { // packed_guid + uint32 animId — force entity to play animation if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); + uint64_t animGuid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t animId =*/ packet.readUInt32(); + uint32_t animId = packet.readUInt32(); + if (emoteAnimCallback_) + emoteAnimCallback_(animGuid, animId); } } break; @@ -2047,10 +2720,18 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DAMAGE_CALC_LOG: case Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT: case Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE: - case Opcode::SMSG_FORCED_DEATH_UPDATE: // Consume — handled by broader object update or not yet implemented packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_FORCED_DEATH_UPDATE: + // Server forces player into dead state (GM command, scripted event, etc.) + playerDead_ = true; + if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet + if (addonEventCallback_) addonEventCallback_("PLAYER_DEAD", {}); + addSystemChatMessage("You have been killed."); + LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); + packet.setReadPos(packet.getSize()); + break; // ---- Zone defense messages ---- case Opcode::SMSG_DEFENSE_MESSAGE: { @@ -2065,34 +2746,61 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_CORPSE_RECLAIM_DELAY: { - // uint32 delayMs before player can reclaim corpse + // uint32 delayMs before player can reclaim corpse (PvP deaths) if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t delayMs = packet.readUInt32(); - uint32_t delaySec = (delayMs + 999) / 1000; - addSystemChatMessage("You can reclaim your corpse in " + - std::to_string(delaySec) + " seconds."); - LOG_DEBUG("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + corpseReclaimAvailableMs_ = nowMs + delayMs; + LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); } break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { - // uint32 mapId + float x + float y + float z — corpse/spirit healer position + // uint32 mapId + float x + float y + float z + // This is the GRAVEYARD / ghost-spawn position, NOT the actual corpse location. + // The corpse remains at the death position (already cached when health dropped to 0, + // and updated when the corpse object arrives via SMSG_UPDATE_OBJECT). + // Do NOT overwrite corpseX_/Y_/Z_/MapId_ here — that would break canReclaimCorpse() + // by making it check distance to the graveyard instead of the real corpse. if (packet.getSize() - packet.getReadPos() >= 16) { - corpseMapId_ = packet.readUInt32(); - corpseX_ = packet.readFloat(); - corpseY_ = packet.readFloat(); - corpseZ_ = packet.readFloat(); - LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_, - " x=", corpseX_, " y=", corpseY_, " z=", corpseZ_); + uint32_t relMapId = packet.readUInt32(); + float relX = packet.readFloat(); + float relY = packet.readFloat(); + float relZ = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, + " x=", relX, " y=", relY, " z=", relZ); } break; } case Opcode::SMSG_ENABLE_BARBER_SHOP: // Sent by server when player sits in barber chair — triggers barber shop UI - // No payload; we don't have barber shop UI yet, so just log LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); + barberShopOpen_ = true; + if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_OPEN", {}); break; + case Opcode::MSG_CORPSE_QUERY: { + // Server response: uint8 found + (if found) uint32 mapId + float x + float y + float z + uint32 corpseMapId + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t found = packet.readUInt8(); + if (found && packet.getSize() - packet.getReadPos() >= 20) { + /*uint32_t mapId =*/ packet.readUInt32(); + float cx = packet.readFloat(); + float cy = packet.readFloat(); + float cz = packet.readFloat(); + uint32_t corpseMapId = packet.readUInt32(); + // Server coords: x=west, y=north (opposite of canonical) + corpseX_ = cx; + corpseY_ = cy; + corpseZ_ = cz; + corpseMapId_ = corpseMapId; + LOG_INFO("MSG_CORPSE_QUERY: corpse at (", cx, ",", cy, ",", cz, ") map=", corpseMapId); + } + break; + } case Opcode::SMSG_FEIGN_DEATH_RESISTED: + addUIError("Your Feign Death was resisted."); addSystemChatMessage("Your Feign Death attempt was resisted."); LOG_DEBUG("SMSG_FEIGN_DEATH_RESISTED"); break; @@ -2131,11 +2839,27 @@ void GameHandler::handlePacket(network::Packet& packet) { // Time bias — consume without processing packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_ACHIEVEMENT_DELETED: - case Opcode::SMSG_CRITERIA_DELETED: - // Consume achievement/criteria removal notifications + case Opcode::SMSG_ACHIEVEMENT_DELETED: { + // uint32 achievementId — remove from local earned set + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t achId = packet.readUInt32(); + earnedAchievements_.erase(achId); + achievementDates_.erase(achId); + LOG_DEBUG("SMSG_ACHIEVEMENT_DELETED: id=", achId); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_CRITERIA_DELETED: { + // uint32 criteriaId — remove from local criteria progress + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t critId = packet.readUInt32(); + criteriaProgress_.erase(critId); + LOG_DEBUG("SMSG_CRITERIA_DELETED: id=", critId); + } + packet.setReadPos(packet.getSize()); + break; + } // ---- Combat clearing ---- case Opcode::SMSG_ATTACKSWING_DEADTARGET: @@ -2145,24 +2869,54 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_THREAT_CLEAR: // All threat dropped on the local player (e.g. Vanish, Feign Death) - // No local state to clear — informational + threatLists_.clear(); LOG_DEBUG("SMSG_THREAT_CLEAR: threat wiped"); + if (addonEventCallback_) addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {}); break; case Opcode::SMSG_THREAT_REMOVE: { // packed_guid (unit) + packed_guid (victim whose threat was removed) - if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 1) { - (void)UpdateObjectParser::readPackedGuid(packet); - } + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); + auto it = threatLists_.find(unitGuid); + if (it != threatLists_.end()) { + auto& list = it->second; + list.erase(std::remove_if(list.begin(), list.end(), + [victimGuid](const ThreatEntry& e){ return e.victimGuid == victimGuid; }), + list.end()); + if (list.empty()) threatLists_.erase(it); } break; } - case Opcode::SMSG_HIGHEST_THREAT_UPDATE: { - // packed_guid (tank) + packed_guid (new highest threat unit) + uint32 count - // + count × (packed_guid victim + uint32 threat) - // Informational — no threat UI yet; consume to suppress warnings - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_HIGHEST_THREAT_UPDATE: + case Opcode::SMSG_THREAT_UPDATE: { + // Both packets share the same format: + // packed_guid (unit) + packed_guid (highest-threat target or target, unused here) + // + uint32 count + count × (packed_guid victim + uint32 threat) + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t unitGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); // highest-threat / current target + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t cnt = packet.readUInt32(); + if (cnt > 100) { packet.setReadPos(packet.getSize()); break; } // sanity + std::vector list; + list.reserve(cnt); + for (uint32_t i = 0; i < cnt; ++i) { + if (packet.getSize() - packet.getReadPos() < 1) break; + ThreatEntry entry; + entry.victimGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + entry.threat = packet.readUInt32(); + list.push_back(entry); + } + // Sort descending by threat so highest is first + std::sort(list.begin(), list.end(), + [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); + threatLists_[unitGuid] = std::move(list); + if (addonEventCallback_) + addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {}); break; } @@ -2203,7 +2957,9 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); if (result != 4) { const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", "Too far away to mount.", "Already mounted." }; - addSystemChatMessage(result < 4 ? msgs[result] : "Cannot mount."); + std::string mountErr = result < 4 ? msgs[result] : "Cannot mount."; + addUIError(mountErr); + addSystemChatMessage(mountErr); } break; } @@ -2211,22 +2967,28 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 result: 0=ok, others=error if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t result = packet.readUInt32(); - if (result != 0) addSystemChatMessage("Cannot dismount here."); + if (result != 0) { addUIError("Cannot dismount here."); addSystemChatMessage("Cannot dismount here."); } break; } // ---- Loot notifications ---- case Opcode::SMSG_LOOT_ALL_PASSED: { - // uint64 objectGuid + uint32 slot + uint32 itemId + uint32 randSuffix + uint32 randPropId - if (packet.getSize() - packet.getReadPos() < 24) break; + // WotLK 3.3.5a: uint64 objectGuid + uint32 slot + uint32 itemId + uint32 randSuffix + uint32 randPropId (24 bytes) + // Classic/TBC: uint64 objectGuid + uint32 slot + uint32 itemId (16 bytes) + const bool isWotLK = isActiveExpansion("wotlk"); + const size_t minSize = isWotLK ? 24u : 16u; + if (packet.getSize() - packet.getReadPos() < minSize) break; /*uint64_t objGuid =*/ packet.readUInt64(); /*uint32_t slot =*/ packet.readUInt32(); uint32_t itemId = packet.readUInt32(); + if (isWotLK) { + /*uint32_t randSuffix =*/ packet.readUInt32(); + /*uint32_t randProp =*/ packet.readUInt32(); + } auto* info = getItemInfo(itemId); - char buf[256]; - std::snprintf(buf, sizeof(buf), "Everyone passed on [%s].", - info ? info->name.c_str() : std::to_string(itemId).c_str()); - addSystemChatMessage(buf); + std::string allPassName = info && !info->name.empty() ? info->name : std::to_string(itemId); + uint32_t allPassQuality = info ? info->quality : 1u; + addSystemChatMessage("Everyone passed on " + buildItemLink(itemId, allPassQuality, allPassName) + "."); pendingLootRollActive_ = false; break; } @@ -2246,61 +3008,120 @@ void GameHandler::handlePacket(network::Packet& packet) { if (!looterName.empty()) { queryItemInfo(itemId, 0); std::string itemName = "item #" + std::to_string(itemId); + uint32_t notifyQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemName = info->name; + notifyQuality = info->quality; } - char buf[256]; - if (count > 1) - std::snprintf(buf, sizeof(buf), "%s loots %s x%u.", looterName.c_str(), itemName.c_str(), count); - else - std::snprintf(buf, sizeof(buf), "%s loots %s.", looterName.c_str(), itemName.c_str()); - addSystemChatMessage(buf); + std::string itemLink2 = buildItemLink(itemId, notifyQuality, itemName); + std::string lootMsg = looterName + " loots " + itemLink2; + if (count > 1) lootMsg += " x" + std::to_string(count); + lootMsg += "."; + addSystemChatMessage(lootMsg); } } break; } - case Opcode::SMSG_LOOT_SLOT_CHANGED: - // uint64 objectGuid + uint32 slot + ... — consume - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_SLOT_CHANGED: { + // uint8 slotIndex — another player took the item from this slot in group loot + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t slotIndex = packet.readUInt8(); + for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) { + if (it->slotIndex == slotIndex) { + currentLoot.items.erase(it); + break; + } + } + } break; + } // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { - // WotLK: packed_guid caster + packed_guid target + uint8 isCrit + uint32 count - // TBC/Classic: full uint64 caster + full uint64 target + uint8 isCrit + uint32 count - // + count × (uint64 victimGuid + uint8 missInfo) - const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + // All expansions: uint32 spellId first. + // WotLK/Classic: spellId(4) + packed_guid caster + uint8 unk + uint32 count + // + count × (packed_guid victim + uint8 missInfo) + // TBC: spellId(4) + uint64 caster + uint8 unk + uint32 count + // + count × (uint64 victim + uint8 missInfo) + // All expansions append uint32 reflectSpellId + uint8 reflectResult when + // missInfo==11 (REFLECT). + const bool spellMissUsesFullGuid = isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { - if (spellMissTbcLike) + if (spellMissUsesFullGuid) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + // spellId prefix present in all expansions + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t spellId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 8u : 1u) + || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } uint64_t casterGuid = readSpellMissGuid(); - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; - /*uint64_t targetGuidLog =*/ readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; - /*uint8_t isCrit =*/ packet.readUInt8(); - uint32_t count = packet.readUInt32(); - count = std::min(count, 32u); - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 9; ++i) { - /*uint64_t victimGuid =*/ packet.readUInt64(); - uint8_t missInfo = packet.readUInt8(); - // Show combat text only for local player's spell misses + /*uint8_t unk =*/ packet.readUInt8(); + const uint32_t rawCount = packet.readUInt32(); + if (rawCount > 128) { + LOG_WARNING("SMSG_SPELLLOGMISS: miss count capped (requested=", rawCount, ")"); + } + const uint32_t storedLimit = std::min(rawCount, 128u); + + struct SpellMissLogEntry { + uint64_t victimGuid = 0; + uint8_t missInfo = 0; + uint32_t reflectSpellId = 0; // Only valid when missInfo==11 (REFLECT) + }; + std::vector parsedMisses; + parsedMisses.reserve(storedLimit); + + bool truncated = false; + for (uint32_t i = 0; i < rawCount; ++i) { + if (packet.getSize() - packet.getReadPos() < (spellMissUsesFullGuid ? 9u : 2u) + || (!spellMissUsesFullGuid && !hasFullPackedGuid(packet))) { + truncated = true; + break; + } + const uint64_t victimGuid = readSpellMissGuid(); + if (packet.getSize() - packet.getReadPos() < 1) { + truncated = true; + break; + } + const uint8_t missInfo = packet.readUInt8(); + // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult + uint32_t reflectSpellId = 0; + if (missInfo == 11) { + if (packet.getSize() - packet.getReadPos() >= 5) { + reflectSpellId = packet.readUInt32(); + /*uint8_t reflectResult =*/ packet.readUInt8(); + } else { + truncated = true; + break; + } + } + if (i < storedLimit) { + parsedMisses.push_back({victimGuid, missInfo, reflectSpellId}); + } + } + + if (truncated) { + packet.setReadPos(packet.getSize()); + break; + } + + for (const auto& miss : parsedMisses) { + const uint64_t victimGuid = miss.victimGuid; + const uint8_t missInfo = miss.missInfo; + CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(missInfo); + // For REFLECT, use the reflected spell ID so combat text shows the spell name + uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0) + ? miss.reflectSpellId : spellId; if (casterGuid == playerGuid) { - static const CombatTextEntry::Type missTypes[] = { - CombatTextEntry::MISS, // 0=MISS - CombatTextEntry::DODGE, // 1=DODGE - CombatTextEntry::PARRY, // 2=PARRY - CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE → show as MISS - CombatTextEntry::MISS, // 5=IMMUNE → show as MISS - CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::MISS, // 7=ABSORB - CombatTextEntry::MISS, // 8=RESIST - }; - CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; - addCombatText(ct, 0, 0, true); + // We cast a spell and it missed the target + addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid); + } else if (victimGuid == playerGuid) { + // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) + addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid); } } break; @@ -2313,10 +3134,16 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t damage = packet.readUInt32(); - /*uint32_t absorb =*/ packet.readUInt32(); - /*uint32_t resist =*/ packet.readUInt32(); - if (victimGuid == playerGuid && damage > 0) { - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + uint32_t absorb = packet.readUInt32(); + uint32_t resist = packet.readUInt32(); + if (victimGuid == playerGuid) { + // Environmental damage: no caster GUID, victim = player + if (damage > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false, 0, 0, victimGuid); + if (absorb > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false, 0, 0, victimGuid); + if (resist > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false, 0, 0, victimGuid); } break; } @@ -2333,24 +3160,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: { @@ -2359,9 +3202,13 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t guid = UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; float speed = packet.readFloat(); - if (guid == playerGuid && std::isfinite(speed) && speed > 0.1f && speed < 100.0f && - *logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) { - serverRunSpeed_ = speed; + if (guid == playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) { + if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_SPEED) + serverRunSpeed_ = speed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED) + serverRunBackSpeed_ = speed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_SPEED) + serverSwimSpeed_ = speed; } break; } @@ -2413,10 +3260,12 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::CAN_FLY), false); break; case Opcode::SMSG_MOVE_FEATHER_FALL: - handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, 0, true); + handleForceMoveFlagChange(packet, "FEATHER_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, + static_cast(MovementFlags::FEATHER_FALL), true); break; case Opcode::SMSG_MOVE_WATER_WALK: - handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, 0, true); + handleForceMoveFlagChange(packet, "WATER_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, + static_cast(MovementFlags::WATER_WALK), true); break; case Opcode::SMSG_MOVE_SET_HOVER: handleForceMoveFlagChange(packet, "SET_HOVER", Opcode::CMSG_MOVE_HOVER_ACK, @@ -2432,6 +3281,25 @@ void GameHandler::handlePacket(network::Packet& packet) { handleMoveKnockBack(packet); break; + case Opcode::SMSG_CAMERA_SHAKE: { + // uint32 shakeID (CameraShakes.dbc), uint32 shakeType + // We don't parse CameraShakes.dbc; apply a hardcoded moderate shake. + if (packet.getSize() - packet.getReadPos() >= 8) { + uint32_t shakeId = packet.readUInt32(); + uint32_t shakeType = packet.readUInt32(); + (void)shakeType; + // Map shakeId ranges to approximate magnitudes: + // IDs < 50: minor environmental (0.04), others: larger boss effects (0.08) + float magnitude = (shakeId < 50) ? 0.04f : 0.08f; + if (cameraShakeCallback_) { + cameraShakeCallback_(magnitude, 18.0f, 0.5f); + } + LOG_DEBUG("SMSG_CAMERA_SHAKE: id=", shakeId, " type=", shakeType, + " magnitude=", magnitude); + } + break; + } + case Opcode::SMSG_CLIENT_CONTROL_UPDATE: { // Minimal parse: PackedGuid + uint8 allowMovement. if (packet.getSize() - packet.getReadPos() < 2) { @@ -2472,8 +3340,10 @@ void GameHandler::handlePacket(network::Packet& packet) { sendMovement(Opcode::MSG_MOVE_STOP_TURN); sendMovement(Opcode::MSG_MOVE_STOP_SWIM); addSystemChatMessage("Movement disabled by server."); + if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_LOST", {}); } else if (changed && allowMovement) { addSystemChatMessage("Movement re-enabled."); + if (addonEventCallback_) addonEventCallback_("PLAYER_CONTROL_GAINED", {}); } } break; @@ -2492,21 +3362,6 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("Target is too far away."); autoAttackRangeWarnCooldown_ = 1.25f; } - if (autoAttackRequested_ && autoAttackTarget != 0 && socket) { - // Avoid blind immediate resend loops when target is clearly out of melee range. - bool likelyInRange = true; - if (auto target = entityManager.getEntity(autoAttackTarget)) { - float dx = movementInfo.x - target->getLatestX(); - float dy = movementInfo.y - target->getLatestY(); - float dz = movementInfo.z - target->getLatestZ(); - float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); - likelyInRange = (dist3d <= 7.5f); - } - if (likelyInRange) { - auto pkt = AttackSwingPacket::build(autoAttackTarget); - socket->send(pkt); - } - } break; case Opcode::SMSG_ATTACKSWING_BADFACING: if (autoAttackRequested_ && autoAttackTarget != 0) { @@ -2517,19 +3372,26 @@ void GameHandler::handlePacket(network::Packet& packet) { if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) { movementInfo.orientation = std::atan2(-toTargetY, toTargetX); sendMovement(Opcode::MSG_MOVE_SET_FACING); - sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } } - if (socket) { - auto pkt = AttackSwingPacket::build(autoAttackTarget); - socket->send(pkt); - } } break; case Opcode::SMSG_ATTACKSWING_NOTSTANDING: - case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: autoAttackOutOfRange_ = false; autoAttackOutOfRangeTime_ = 0.0f; + if (autoAttackRangeWarnCooldown_ <= 0.0f) { + addSystemChatMessage("You need to stand up to fight."); + autoAttackRangeWarnCooldown_ = 1.25f; + } + break; + case Opcode::SMSG_ATTACKSWING_CANT_ATTACK: + // Target is permanently non-attackable (critter, civilian, already dead, etc.). + // Stop the auto-attack loop so the client doesn't spam the server. + stopAutoAttack(); + if (autoAttackRangeWarnCooldown_ <= 0.0f) { + addSystemChatMessage("You can't attack that."); + autoAttackRangeWarnCooldown_ = 1.25f; + } break; case Opcode::SMSG_ATTACKERSTATEUPDATE: handleAttackerStateUpdate(packet); @@ -2552,10 +3414,24 @@ void GameHandler::handlePacket(network::Packet& packet) { handleSpellDamageLog(packet); break; case Opcode::SMSG_PLAY_SPELL_VISUAL: { - // Minimal parse: uint64 casterGuid, uint32 visualId + // uint64 casterGuid + uint32 visualId if (packet.getSize() - packet.getReadPos() < 12) break; - packet.readUInt64(); - packet.readUInt32(); + uint64_t casterGuid = packet.readUInt64(); + uint32_t visualId = packet.readUInt32(); + if (visualId == 0) break; + // Resolve caster world position and spawn the effect + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (casterGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(casterGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(visualId, spawnPos); break; } case Opcode::SMSG_SPELLHEALLOG: @@ -2577,23 +3453,79 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_SPELL_FAILURE: { // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 failReason - // TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 failReason - const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc"); - uint64_t failGuid = tbcOrClassic + // TBC: full uint64 + uint8 castCount + uint32 spellId + uint8 failReason + // Classic: full uint64 + uint32 spellId + uint8 failReason (NO castCount) + const bool isClassic = isClassicLikeExpansion(); + const bool isTbc = isActiveExpansion("tbc"); + uint64_t failGuid = (isClassic || isTbc) ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); + // Classic omits the castCount byte; TBC and WotLK include it + const size_t remainingFields = isClassic ? 5u : 6u; // spellId(4)+reason(1) [+castCount(1)] + if (packet.getSize() - packet.getReadPos() >= remainingFields) { + if (!isClassic) /*uint8_t castCount =*/ packet.readUInt8(); + uint32_t failSpellId = packet.readUInt32(); + uint8_t rawFailReason = packet.readUInt8(); + // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table + uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; + if (failGuid == playerGuid && failReason != 0) { + // Show interruption/failure reason in chat and error overlay for player + int pt = -1; + if (auto pe = entityManager.getEntity(playerGuid)) + if (auto pu = std::dynamic_pointer_cast(pe)) + pt = static_cast(pu->getPowerType()); + const char* reason = getSpellCastResultString(failReason, pt); + if (reason) { + // Prefix with spell name for context, e.g. "Fireball: Not in range" + const std::string& sName = getSpellName(failSpellId); + std::string fullMsg = sName.empty() ? reason + : sName + ": " + reason; + addUIError(fullMsg); + MessageChatData emsg; + emsg.type = ChatType::SYSTEM; + emsg.language = ChatLanguage::UNIVERSAL; + emsg.message = std::move(fullMsg); + addLocalChatMessage(emsg); + } + } + } + // Fire UNIT_SPELLCAST_INTERRUPTED for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (failGuid == playerGuid || failGuid == 0) unitId = "player"; + else if (failGuid == targetGuid) unitId = "target"; + else if (failGuid == focusGuid) unitId = "focus"; + else if (failGuid == petGuid_) unitId = "pet"; + if (!unitId.empty()) { + addonEventCallback_("UNIT_SPELLCAST_INTERRUPTED", {unitId}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {unitId}); + } + } if (failGuid == playerGuid || failGuid == 0) { - // Player's own cast failed + // Player's own cast failed — clear gather-node loot target so the + // next timed cast doesn't try to loot a stale interrupted gather node. casting = false; + castIsChannel = false; currentCastSpellId = 0; + lastInteractedGoGuid_ = 0; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { ssm->stopPrecast(); } } + 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; } @@ -2641,24 +3573,45 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAchievementEarned(packet); break; case Opcode::SMSG_ALL_ACHIEVEMENT_DATA: - // Initial data burst on login — ignored for now (no achievement tracker UI). + handleAllAchievementData(packet); break; case Opcode::SMSG_ITEM_COOLDOWN: { // uint64 itemGuid + uint32 spellId + uint32 cooldownMs size_t rem = packet.getSize() - packet.getReadPos(); if (rem >= 16) { - /*uint64_t itemGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); - uint32_t cdMs = packet.readUInt32(); + uint64_t itemGuid = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + uint32_t cdMs = packet.readUInt32(); float cdSec = cdMs / 1000.0f; - if (spellId != 0 && cdSec > 0.0f) { - spellCooldowns[spellId] = cdSec; - for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { - slot.cooldownRemaining = cdSec; + if (cdSec > 0.0f) { + if (spellId != 0) { + auto it = spellCooldowns.find(spellId); + if (it == spellCooldowns.end()) { + spellCooldowns[spellId] = cdSec; + } else { + it->second = mergeCooldownSeconds(it->second, cdSec); } } - LOG_DEBUG("SMSG_ITEM_COOLDOWN: spellId=", spellId, " cd=", cdSec, "s"); + // Resolve itemId from the GUID so item-type slots are also updated + uint32_t itemId = 0; + auto iit = onlineItems_.find(itemGuid); + if (iit != onlineItems_.end()) itemId = iit->second.entry; + for (auto& slot : actionBar) { + bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId) + || (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId); + if (match) { + float prevRemaining = slot.cooldownRemaining; + float merged = mergeCooldownSeconds(slot.cooldownRemaining, cdSec); + slot.cooldownRemaining = merged; + if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) { + slot.cooldownTotal = cdSec; + } else { + slot.cooldownTotal = std::max(slot.cooldownTotal, merged); + } + } + } + LOG_DEBUG("SMSG_ITEM_COOLDOWN: itemGuid=0x", std::hex, itemGuid, std::dec, + " spellId=", spellId, " itemId=", itemId, " cd=", cdSec, "s"); } } break; @@ -2685,16 +3638,25 @@ void GameHandler::handlePacket(network::Packet& packet) { ping.wowY = pingX; // canonical WoW Y = west = server's posX ping.age = 0.0f; minimapPings_.push_back(ping); + // Play ping sound for other players' pings (not our own) + if (senderGuid != playerGuid) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playMinimapPing(); + } + } break; } case Opcode::SMSG_ZONE_UNDER_ATTACK: { // uint32 areaId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t areaId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "A zone is under attack! (area %u)", areaId); - addSystemChatMessage(buf); + std::string areaName = getAreaName(areaId); + std::string msg = areaName.empty() + ? std::string("A zone is under attack!") + : (areaName + " is under attack!"); + addUIError(msg); + addSystemChatMessage(msg); } break; } @@ -2707,26 +3669,64 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAuraUpdate(packet, true); break; case Opcode::SMSG_DISPEL_FAILED: { - // casterGuid(8) + victimGuid(8) + spellId(4) [+ failing spellId(4)...] - if (packet.getSize() - packet.getReadPos() >= 20) { - /*uint64_t casterGuid =*/ packet.readUInt64(); - /*uint64_t victimGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); + // WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim + // [+ count × uint32 failedSpellId] + // Classic: uint32 dispelSpellId + packed_guid caster + packed_guid victim + // [+ count × uint32 failedSpellId] + // TBC: uint64 caster + uint64 victim + uint32 spellId + // [+ count × uint32 failedSpellId] + const bool dispelUsesFullGuid = isActiveExpansion("tbc"); + uint32_t dispelSpellId = 0; + uint64_t dispelCasterGuid = 0; + if (dispelUsesFullGuid) { + if (packet.getSize() - packet.getReadPos() < 20) break; + dispelCasterGuid = packet.readUInt64(); + /*uint64_t victim =*/ packet.readUInt64(); + dispelSpellId = packet.readUInt32(); + } else { + if (packet.getSize() - packet.getReadPos() < 4) break; + dispelSpellId = packet.readUInt32(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + dispelCasterGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); + } + // Only show failure to the player who attempted the dispel + if (dispelCasterGuid == playerGuid) { + loadSpellNameCache(); + auto it = spellNameCache_.find(dispelSpellId); char buf[128]; - std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", spellId); + if (it != spellNameCache_.end() && !it->second.name.empty()) + std::snprintf(buf, sizeof(buf), "%s failed to dispel.", it->second.name.c_str()); + else + std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", dispelSpellId); addSystemChatMessage(buf); } break; } case Opcode::SMSG_TOTEM_CREATED: { - // uint8 slot + uint64 guid + uint32 duration + uint32 spellId - if (packet.getSize() - packet.getReadPos() >= 17) { - uint8_t slot = packet.readUInt8(); + // WotLK: uint8 slot + packed_guid + uint32 duration + uint32 spellId + // TBC/Classic: uint8 slot + uint64 guid + uint32 duration + uint32 spellId + const bool totemTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (totemTbcLike ? 17u : 9u)) break; + uint8_t slot = packet.readUInt8(); + if (totemTbcLike) /*uint64_t guid =*/ packet.readUInt64(); - uint32_t duration = packet.readUInt32(); - uint32_t spellId = packet.readUInt32(); - LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, - " spellId=", spellId, " duration=", duration, "ms"); + else + /*uint64_t guid =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t duration = packet.readUInt32(); + uint32_t spellId = packet.readUInt32(); + LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, + " spellId=", spellId, " duration=", duration, "ms"); + if (slot < NUM_TOTEM_SLOTS) { + activeTotemSlots_[slot].spellId = spellId; + activeTotemSlots_[slot].durationMs = duration; + activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now(); } break; } @@ -2746,8 +3746,12 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DURABILITY_DAMAGE_DEATH: { // uint32 percent (how much durability was lost due to death) if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t pct =*/ packet.readUInt32(); - addSystemChatMessage("You have lost 10% of your gear's durability due to death."); + uint32_t pct = packet.readUInt32(); + char buf[80]; + std::snprintf(buf, sizeof(buf), + "You have lost %u%% of your gear's durability due to death.", pct); + addUIError(buf); + addSystemChatMessage(buf); } break; } @@ -2784,8 +3788,13 @@ void GameHandler::handlePacket(network::Packet& packet) { partyData.members.clear(); partyData.memberCount = 0; partyData.leaderGuid = 0; + addUIError("Your party has been disbanded."); addSystemChatMessage("Your party has been disbanded."); LOG_INFO("SMSG_GROUP_DESTROYED: party cleared"); + if (addonEventCallback_) { + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + } break; case Opcode::SMSG_GROUP_CANCEL: // Group invite was cancelled before being accepted. @@ -2811,6 +3820,7 @@ void GameHandler::handlePacket(network::Packet& packet) { readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; readyCheckInitiator_.clear(); + readyCheckResults_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); auto entity = entityManager.getEntity(initiatorGuid); @@ -2828,6 +3838,8 @@ void GameHandler::handlePacket(network::Packet& packet) { ? "Ready check initiated!" : readyCheckInitiator_ + " initiated a ready check!"); LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_); + if (addonEventCallback_) + addonEventCallback_("READY_CHECK", {readyCheckInitiator_}); break; } case Opcode::MSG_RAID_READY_CHECK_CONFIRM: { @@ -2844,11 +3856,23 @@ void GameHandler::handlePacket(network::Packet& packet) { auto ent = entityManager.getEntity(respGuid); if (ent) rname = std::static_pointer_cast(ent)->getName(); } + // Track per-player result for live popup display if (!rname.empty()) { + bool found = false; + for (auto& r : readyCheckResults_) { + if (r.name == rname) { r.ready = (isReady != 0); found = true; break; } + } + if (!found) readyCheckResults_.push_back({ rname, isReady != 0 }); + char rbuf[128]; std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); addSystemChatMessage(rbuf); } + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)respGuid); + addonEventCallback_("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"}); + } break; } case Opcode::MSG_RAID_READY_CHECK_FINISHED: { @@ -2860,6 +3884,9 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingReadyCheck_ = false; readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; + readyCheckResults_.clear(); + if (addonEventCallback_) + addonEventCallback_("READY_CHECK_FINISHED", {}); break; } case Opcode::SMSG_RAID_INSTANCE_INFO: @@ -2875,14 +3902,22 @@ void GameHandler::handlePacket(network::Packet& packet) { handleDuelWinner(packet); break; case Opcode::SMSG_DUEL_OUTOFBOUNDS: + addUIError("You are out of the duel area!"); addSystemChatMessage("You are out of the duel area!"); break; case Opcode::SMSG_DUEL_INBOUNDS: // Re-entered the duel area; no special action needed. break; - case Opcode::SMSG_DUEL_COUNTDOWN: - // Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update. + case Opcode::SMSG_DUEL_COUNTDOWN: { + // uint32 countdown in milliseconds (typically 3000 ms) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t ms = packet.readUInt32(); + duelCountdownMs_ = (ms > 0 && ms <= 30000) ? ms : 3000; + duelCountdownStartedAt_ = std::chrono::steady_clock::now(); + LOG_INFO("SMSG_DUEL_COUNTDOWN: ", duelCountdownMs_, " ms"); + } break; + } case Opcode::SMSG_PARTYKILLLOG: { // uint64 killerGuid + uint64 victimGuid if (packet.getSize() - packet.getReadPos() < 16) break; @@ -2966,19 +4001,30 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("Summon cancelled."); break; case Opcode::SMSG_TRADE_STATUS: - case Opcode::SMSG_TRADE_STATUS_EXTENDED: handleTradeStatus(packet); break; + case Opcode::SMSG_TRADE_STATUS_EXTENDED: + handleTradeStatusExtended(packet); + break; case Opcode::SMSG_LOOT_ROLL: handleLootRoll(packet); break; case Opcode::SMSG_LOOT_ROLL_WON: handleLootRollWon(packet); break; - case Opcode::SMSG_LOOT_MASTER_LIST: - // Master looter list — no UI yet; consume to avoid unhandled warning. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_MASTER_LIST: { + // uint8 count + count * uint64 guid — eligible recipients for master looter + masterLootCandidates_.clear(); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t mlCount = packet.readUInt8(); + masterLootCandidates_.reserve(mlCount); + for (uint8_t i = 0; i < mlCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 8) break; + masterLootCandidates_.push_back(packet.readUInt64()); + } + LOG_INFO("SMSG_LOOT_MASTER_LIST: ", (int)masterLootCandidates_.size(), " candidates"); break; + } case Opcode::SMSG_GOSSIP_MESSAGE: handleGossipMessage(packet); break; @@ -2996,12 +4042,18 @@ void GameHandler::handlePacket(network::Packet& packet) { bool wasSet = hasHomeBind_; hasHomeBind_ = true; homeBindMapId_ = data.mapId; + homeBindZoneId_ = data.zoneId; homeBindPos_ = canonical; if (bindPointCallback_) { bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); } if (wasSet) { - addSystemChatMessage("Your home has been set."); + std::string bindMsg = "Your home has been set"; + std::string zoneName = getAreaName(data.zoneId); + if (!zoneName.empty()) + bindMsg += " to " + zoneName; + bindMsg += '.'; + addSystemChatMessage(bindMsg); } } else { LOG_WARNING("Failed to parse SMSG_BINDPOINTUPDATE"); @@ -3049,6 +4101,8 @@ void GameHandler::handlePacket(network::Packet& packet) { resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : ""; } resurrectRequestPending_ = true; + if (addonEventCallback_) + addonEventCallback_("RESURRECT_REQUEST", {resurrectCasterName_}); } break; } @@ -3090,6 +4144,14 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("You have learned " + name + "."); else addSystemChatMessage("Spell learned."); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playQuestActivate(); + } + if (addonEventCallback_) { + addonEventCallback_("TRAINER_UPDATE", {}); + addonEventCallback_("SPELLS_CHANGED", {}); + } break; } case Opcode::SMSG_TRAINER_BUY_FAILED: { @@ -3115,19 +4177,43 @@ void GameHandler::handlePacket(network::Packet& packet) { else if (errorCode == 2) msg += " (already known)"; else if (errorCode != 0) msg += " (error " + std::to_string(errorCode) + ")"; + addUIError(msg); addSystemChatMessage(msg); + // Play error sound so the player notices the failure + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } break; } // Silently ignore common packets we don't handle yet case Opcode::SMSG_INIT_WORLD_STATES: { - // Minimal parse: uint32 mapId, uint32 zoneId, uint16 count, repeated (uint32 key, uint32 val) + // WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val) + // Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(uint32 key, uint32 val) if (packet.getSize() - packet.getReadPos() < 10) { LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes"); break; } worldStateMapId_ = packet.readUInt32(); - worldStateZoneId_ = packet.readUInt32(); + { + uint32_t newZoneId = packet.readUInt32(); + if (newZoneId != worldStateZoneId_ && newZoneId != 0) { + worldStateZoneId_ = newZoneId; + if (addonEventCallback_) { + addonEventCallback_("ZONE_CHANGED_NEW_AREA", {}); + addonEventCallback_("ZONE_CHANGED", {}); + } + } else { + worldStateZoneId_ = newZoneId; + } + } + // WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format + size_t remaining = packet.getSize() - packet.getReadPos(); + bool isWotLKFormat = isActiveExpansion("wotlk"); + if (isWotLKFormat && remaining >= 6) { + packet.readUInt32(); // areaId (WotLK only) + } uint16_t count = packet.readUInt16(); size_t needed = static_cast(count) * 8; size_t available = packet.getSize() - packet.getReadPos(); @@ -3203,23 +4289,75 @@ void GameHandler::handlePacket(network::Packet& packet) { delta > 0 ? "increased" : "decreased", std::abs(delta)); addSystemChatMessage(buf); + watchedFactionId_ = factionId; + if (repChangeCallback_) repChangeCallback_(name, delta, standing); + if (addonEventCallback_) { + addonEventCallback_("UPDATE_FACTION", {}); + addonEventCallback_("CHAT_MSG_COMBAT_FACTION_CHANGE", {std::string(buf)}); + } } LOG_DEBUG("SMSG_SET_FACTION_STANDING: faction=", factionId, " standing=", standing); } break; } - case Opcode::SMSG_SET_FACTION_ATWAR: - case Opcode::SMSG_SET_FACTION_VISIBLE: - // uint32 factionId [+ uint8 flags for ATWAR] — consume; hostility is tracked via update fields + case Opcode::SMSG_SET_FACTION_ATWAR: { + // uint32 repListId + uint8 set (1=set at-war, 0=clear at-war) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t repListId = packet.readUInt32(); + uint8_t setAtWar = packet.readUInt8(); + if (repListId < initialFactions_.size()) { + if (setAtWar) + initialFactions_[repListId].flags |= FACTION_FLAG_AT_WAR; + else + initialFactions_[repListId].flags &= ~FACTION_FLAG_AT_WAR; + LOG_DEBUG("SMSG_SET_FACTION_ATWAR: repListId=", repListId, + " atWar=", (int)setAtWar); + } + break; + } + case Opcode::SMSG_SET_FACTION_VISIBLE: { + // uint32 repListId + uint8 visible (1=show, 0=hide) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t repListId = packet.readUInt32(); + uint8_t visible = packet.readUInt8(); + if (repListId < initialFactions_.size()) { + if (visible) + initialFactions_[repListId].flags |= FACTION_FLAG_VISIBLE; + else + initialFactions_[repListId].flags &= ~FACTION_FLAG_VISIBLE; + LOG_DEBUG("SMSG_SET_FACTION_VISIBLE: repListId=", repListId, + " visible=", (int)visible); + } + break; + } + + case Opcode::SMSG_FEATURE_SYSTEM_STATUS: packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: - case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: - // Different formats than SMSG_SPELL_DELAYED — consume and ignore + case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: { + // WotLK format: one or more (uint8 groupIndex, uint8 modOp, int32 value) tuples + // Each tuple is 6 bytes; iterate until packet is consumed. + const bool isFlat = (*logicalOp == Opcode::SMSG_SET_FLAT_SPELL_MODIFIER); + auto& modMap = isFlat ? spellFlatMods_ : spellPctMods_; + while (packet.getSize() - packet.getReadPos() >= 6) { + uint8_t groupIndex = packet.readUInt8(); + uint8_t modOpRaw = packet.readUInt8(); + int32_t value = static_cast(packet.readUInt32()); + if (groupIndex > 5 || modOpRaw >= SPELL_MOD_OP_COUNT) continue; + SpellModKey key{ static_cast(modOpRaw), groupIndex }; + modMap[key] = value; + LOG_DEBUG(isFlat ? "SMSG_SET_FLAT_SPELL_MODIFIER" : "SMSG_SET_PCT_SPELL_MODIFIER", + ": group=", (int)groupIndex, " op=", (int)modOpRaw, " value=", value); + } packet.setReadPos(packet.getSize()); break; + } case Opcode::SMSG_SPELL_DELAYED: { // WotLK: packed_guid (caster) + uint32 delayMs @@ -3234,7 +4372,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (delayMs == 0) break; float delaySec = delayMs / 1000.0f; if (caster == playerGuid) { - if (casting) castTimeRemaining += delaySec; + if (casting) { + castTimeRemaining += delaySec; + castTimeTotal += delaySec; // keep progress percentage correct + } } else { auto it = unitCastStates_.find(caster); if (it != unitCastStates_.end() && it->second.casting) { @@ -3244,10 +4385,60 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_EQUIPMENT_SET_SAVED: + case Opcode::SMSG_EQUIPMENT_SET_SAVED: { // uint32 setIndex + uint64 guid — equipment set was successfully saved - LOG_DEBUG("Equipment set saved"); + std::string setName; + if (packet.getSize() - packet.getReadPos() >= 12) { + uint32_t setIndex = packet.readUInt32(); + uint64_t setGuid = packet.readUInt64(); + // Update the local set's GUID so subsequent "Update" calls + // use the server-assigned GUID instead of 0 (which would + // create a duplicate instead of updating). + bool found = false; + for (auto& es : equipmentSets_) { + if (es.setGuid == setGuid || es.setId == setIndex) { + es.setGuid = setGuid; + setName = es.name; + found = true; + break; + } + } + // Also update public-facing info + for (auto& info : equipmentSetInfo_) { + if (info.setGuid == setGuid || info.setId == setIndex) { + info.setGuid = setGuid; + break; + } + } + // If the set doesn't exist locally yet (new save), add a + // placeholder entry so it shows up in the UI immediately. + if (!found && setGuid != 0) { + EquipmentSet newEs; + newEs.setGuid = setGuid; + newEs.setId = setIndex; + newEs.name = pendingSaveSetName_; + newEs.iconName = pendingSaveSetIcon_; + for (int s = 0; s < 19; ++s) + newEs.itemGuids[s] = getEquipSlotGuid(s); + equipmentSets_.push_back(std::move(newEs)); + EquipmentSetInfo newInfo; + newInfo.setGuid = setGuid; + newInfo.setId = setIndex; + newInfo.name = pendingSaveSetName_; + newInfo.iconName = pendingSaveSetIcon_; + equipmentSetInfo_.push_back(std::move(newInfo)); + setName = pendingSaveSetName_; + } + pendingSaveSetName_.clear(); + pendingSaveSetIcon_.clear(); + LOG_INFO("SMSG_EQUIPMENT_SET_SAVED: index=", setIndex, + " guid=", setGuid, " name=", setName); + } + addSystemChatMessage(setName.empty() + ? std::string("Equipment set saved.") + : "Equipment set \"" + setName + "\" saved."); break; + } case Opcode::SMSG_PERIODICAURALOG: { // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects @@ -3272,22 +4463,75 @@ void GameHandler::handlePacket(network::Packet& packet) { for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 1; ++i) { uint8_t auraType = packet.readUInt8(); if (auraType == 3 || auraType == 89) { - // PERIODIC_DAMAGE / PERIODIC_DAMAGE_PERCENT: damage+school+absorbed+resisted - if (packet.getSize() - packet.getReadPos() < 16) break; + // Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes + // WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4)+isCrit(1) = 21 bytes + const bool periodicWotlk = isActiveExpansion("wotlk"); + const size_t dotSz = periodicWotlk ? 21u : 16u; + if (packet.getSize() - packet.getReadPos() < dotSz) break; uint32_t dmg = packet.readUInt32(); + if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32(); /*uint32_t school=*/ packet.readUInt32(); - /*uint32_t abs=*/ packet.readUInt32(); - /*uint32_t res=*/ packet.readUInt32(); - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), - spellId, isPlayerCaster); + uint32_t abs = packet.readUInt32(); + uint32_t res = packet.readUInt32(); + bool dotCrit = false; + if (periodicWotlk) dotCrit = (packet.readUInt8() != 0); + if (dmg > 0) + addCombatText(dotCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::PERIODIC_DAMAGE, + static_cast(dmg), + spellId, isPlayerCaster, 0, casterGuid, victimGuid); + if (abs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(abs), + spellId, isPlayerCaster, 0, casterGuid, victimGuid); + if (res > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(res), + spellId, isPlayerCaster, 0, casterGuid, victimGuid); } else if (auraType == 8 || auraType == 124 || auraType == 45) { - // PERIODIC_HEAL / PERIODIC_HEAL_PCT / OBS_MOD_HEALTH: heal+maxHeal+overHeal - if (packet.getSize() - packet.getReadPos() < 12) break; + // Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes + // WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes + const bool healWotlk = isActiveExpansion("wotlk"); + const size_t hotSz = healWotlk ? 17u : 12u; + if (packet.getSize() - packet.getReadPos() < hotSz) break; uint32_t heal = packet.readUInt32(); /*uint32_t max=*/ packet.readUInt32(); /*uint32_t over=*/ packet.readUInt32(); - addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), - spellId, isPlayerCaster); + uint32_t hotAbs = 0; + bool hotCrit = false; + if (healWotlk) { + hotAbs = packet.readUInt32(); + hotCrit = (packet.readUInt8() != 0); + } + addCombatText(hotCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::PERIODIC_HEAL, + static_cast(heal), + spellId, isPlayerCaster, 0, casterGuid, victimGuid); + if (hotAbs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), + spellId, isPlayerCaster, 0, casterGuid, victimGuid); + } else if (auraType == 46 || auraType == 91) { + // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount + // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. + if (packet.getSize() - packet.getReadPos() < 8) break; + uint8_t periodicPowerType = static_cast(packet.readUInt32()); + uint32_t amount = packet.readUInt32(); + if ((isPlayerVictim || isPlayerCaster) && amount > 0) + addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), + spellId, isPlayerCaster, periodicPowerType, casterGuid, victimGuid); + } else if (auraType == 98) { + // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier + if (packet.getSize() - packet.getReadPos() < 12) break; + uint8_t powerType = static_cast(packet.readUInt32()); + uint32_t amount = packet.readUInt32(); + float multiplier = packet.readFloat(); + if (isPlayerVictim && amount > 0) + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(amount), + spellId, false, powerType, casterGuid, victimGuid); + if (isPlayerCaster && amount > 0 && multiplier > 0.0f && std::isfinite(multiplier)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(amount) * static_cast(multiplier))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), + spellId, true, powerType, casterGuid, casterGuid); + } + } } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -3302,34 +4546,52 @@ void GameHandler::handlePacket(network::Packet& packet) { // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount // Classic/Vanilla: packed_guid (same as WotLK) const bool energizeTbc = isActiveExpansion("tbc"); - size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = energizeTbc - ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - rem = packet.getSize() - packet.getReadPos(); - if (rem < 6) { packet.setReadPos(packet.getSize()); break; } - uint32_t spellId = packet.readUInt32(); - /*uint8_t powerType =*/ packet.readUInt8(); - int32_t amount = static_cast(packet.readUInt32()); + auto readEnergizeGuid = [&]() -> uint64_t { + if (energizeTbc) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u) + || (!energizeTbc && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < (energizeTbc ? 8u : 1u) + || (!energizeTbc && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = readEnergizeGuid(); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t spellId = packet.readUInt32(); + uint8_t energizePowerType = packet.readUInt8(); + int32_t amount = static_cast(packet.readUInt32()); bool isPlayerVictim = (victimGuid == playerGuid); bool isPlayerCaster = (casterGuid == playerGuid); if ((isPlayerVictim || isPlayerCaster) && amount > 0) - addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster); + addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType, casterGuid, victimGuid); packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_ENVIRONMENTAL_DAMAGE_LOG: { // uint64 victimGuid + uint8 envDmgType + uint32 damage + uint32 absorbed + uint32 resisted - // envDmgType: 1=Exhausted(fatigue), 2=Drowning, 3=Fall, 4=Lava, 5=Slime, 6=Fire + // envDmgType: 0=Exhausted(fatigue), 1=Drowning, 2=Fall, 3=Lava, 4=Slime, 5=Fire if (packet.getSize() - packet.getReadPos() < 21) { packet.setReadPos(packet.getSize()); break; } uint64_t victimGuid = packet.readUInt64(); - /*uint8_t envType =*/ packet.readUInt8(); + uint8_t envType = packet.readUInt8(); uint32_t dmg = packet.readUInt32(); - /*uint32_t abs =*/ packet.readUInt32(); - if (victimGuid == playerGuid && dmg > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + uint32_t envAbs = packet.readUInt32(); + uint32_t envRes = packet.readUInt32(); + if (victimGuid == playerGuid) { + // Environmental damage: pass envType via powerType field for display differentiation + if (dmg > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, envType, 0, victimGuid); + if (envAbs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false, 0, 0, victimGuid); + if (envRes > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false, 0, 0, victimGuid); + } packet.setReadPos(packet.getSize()); break; } @@ -3349,15 +4611,31 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_ACTION_BUTTONS: { - // uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons - // packed: bits 0-23 = actionId, bits 24-31 = type - // 0x00 = spell (when id != 0), 0x80 = item, 0x40 = macro (skip) + // Slot encoding differs by expansion: + // Classic/Turtle: uint16 actionId + uint8 type + uint8 misc + // type: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint32 packed = actionId | (type << 24) + // type: 0x00=spell, 0x80=item, 0x40=macro + // Format differences: + // Classic 1.12: no mode byte, 120 slots (480 bytes) + // TBC 2.4.3: no mode byte, 132 slots (528 bytes) + // WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes) size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 1) break; - /*uint8_t mode =*/ packet.readUInt8(); - rem--; - constexpr int SERVER_BAR_SLOTS = 144; - for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { + const bool hasModeByteExp = isActiveExpansion("wotlk"); + int serverBarSlots; + if (isClassicLikeExpansion()) { + serverBarSlots = 120; + } else if (isActiveExpansion("tbc")) { + serverBarSlots = 132; + } else { + serverBarSlots = 144; + } + if (hasModeByteExp) { + if (rem < 1) break; + /*uint8_t mode =*/ packet.readUInt8(); + rem--; + } + for (int i = 0; i < serverBarSlots; ++i) { if (rem < 4) break; uint32_t packed = packet.readUInt32(); rem -= 4; @@ -3367,18 +4645,55 @@ void GameHandler::handlePacket(network::Packet& packet) { // so we don't wipe hardcoded fallbacks when the server sends zeros. continue; } - uint8_t type = static_cast((packed >> 24) & 0xFF); - uint32_t id = packed & 0x00FFFFFFu; + uint8_t type = 0; + uint32_t id = 0; + if (isClassicLikeExpansion()) { + id = packed & 0x0000FFFFu; + type = static_cast((packed >> 16) & 0xFF); + } else { + type = static_cast((packed >> 24) & 0xFF); + id = packed & 0x00FFFFFFu; + } if (id == 0) continue; ActionBarSlot slot; switch (type) { case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; - case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; - default: continue; // macro or unknown — leave as-is + case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item + case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item + case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions) + default: continue; // unknown — leave as-is } actionBar[i] = slot; } + // Apply any pending cooldowns from spellCooldowns to newly populated slots. + // SMSG_SPELL_COOLDOWN often arrives before SMSG_ACTION_BUTTONS during login, + // so the per-slot cooldownRemaining would be 0 without this sync. + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { + auto cdIt = spellCooldowns.find(slot.id); + if (cdIt != spellCooldowns.end() && cdIt->second > 0.0f) { + slot.cooldownRemaining = cdIt->second; + slot.cooldownTotal = cdIt->second; + } + } else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) { + // Items (potions, trinkets): look up the item's on-use spell + // and check if that spell has a pending cooldown. + const auto* qi = getItemInfo(slot.id); + if (qi && qi->valid) { + for (const auto& sp : qi->spells) { + if (sp.spellId == 0) continue; + auto cdIt = spellCooldowns.find(sp.spellId); + if (cdIt != spellCooldowns.end() && cdIt->second > 0.0f) { + slot.cooldownRemaining = cdIt->second; + slot.cooldownTotal = cdIt->second; + break; + } + } + } + } + } LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); + if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); packet.setReadPos(packet.getSize()); break; } @@ -3386,10 +4701,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LEVELUP_INFO: case Opcode::SMSG_LEVELUP_INFO_ALT: { // Server-authoritative level-up event. - // First field is always the new level in Classic/TBC/WotLK-era layouts. + // WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t newLevel = packet.readUInt32(); if (newLevel > 0) { + // Parse stat deltas (WotLK layout has 7 more uint32s) + lastLevelUpDeltas_ = {}; + if (packet.getSize() - packet.getReadPos() >= 28) { + lastLevelUpDeltas_.hp = packet.readUInt32(); + lastLevelUpDeltas_.mana = packet.readUInt32(); + lastLevelUpDeltas_.str = packet.readUInt32(); + lastLevelUpDeltas_.agi = packet.readUInt32(); + lastLevelUpDeltas_.sta = packet.readUInt32(); + lastLevelUpDeltas_.intel = packet.readUInt32(); + lastLevelUpDeltas_.spi = packet.readUInt32(); + } uint32_t oldLevel = serverPlayerLevel_; serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel); for (auto& ch : characters) { @@ -3398,12 +4724,17 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } } - if (newLevel > oldLevel && levelUpCallback_) { - levelUpCallback_(newLevel); + if (newLevel > oldLevel) { + addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLevelUp(); + } + if (levelUpCallback_) levelUpCallback_(newLevel); + if (addonEventCallback_) addonEventCallback_("PLAYER_LEVEL_UP", {std::to_string(newLevel)}); } } } - // Remaining payload (hp/mana/stat deltas) is optional for our client. packet.setReadPos(packet.getSize()); break; } @@ -3417,11 +4748,22 @@ void GameHandler::handlePacket(network::Packet& packet) { break; case Opcode::SMSG_SERVER_MESSAGE: { - // uint32 type, string message + // uint32 type + string message + // Types: 1=shutdown_time, 2=restart_time, 3=string, 4=shutdown_cancelled, 5=restart_cancelled if (packet.getSize() - packet.getReadPos() >= 4) { - /*uint32_t msgType =*/ packet.readUInt32(); + uint32_t msgType = packet.readUInt32(); std::string msg = packet.readString(); - if (!msg.empty()) addSystemChatMessage("[Server] " + msg); + if (!msg.empty()) { + std::string prefix; + switch (msgType) { + case 1: prefix = "[Shutdown] "; addUIError("Server shutdown: " + msg); break; + case 2: prefix = "[Restart] "; addUIError("Server restart: " + msg); break; + case 4: prefix = "[Shutdown cancelled] "; break; + case 5: prefix = "[Restart cancelled] "; break; + default: prefix = "[Server] "; break; + } + addSystemChatMessage(prefix + msg); + } } break; } @@ -3439,15 +4781,24 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { /*uint32_t len =*/ packet.readUInt32(); std::string msg = packet.readString(); - if (!msg.empty()) addSystemChatMessage(msg); + if (!msg.empty()) { + addUIError(msg); + addSystemChatMessage(msg); + areaTriggerMsgs_.push_back(msg); + } } break; } - case Opcode::SMSG_TRIGGER_CINEMATIC: - // uint32 cinematicId — we don't play cinematics; consume and skip. + case Opcode::SMSG_TRIGGER_CINEMATIC: { + // uint32 cinematicId — we don't play cinematics; acknowledge immediately. packet.setReadPos(packet.getSize()); - LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped"); + // Send CMSG_NEXT_CINEMATIC_CAMERA to signal cinematic completion; + // servers may block further packets until this is received. + network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA)); + socket->send(ack); + LOG_DEBUG("SMSG_TRIGGER_CINEMATIC: skipped, sent CMSG_NEXT_CINEMATIC_CAMERA"); break; + } case Opcode::SMSG_LOOT_MONEY_NOTIFY: { // Format: uint32 money + uint8 soleLooter @@ -3486,6 +4837,7 @@ void GameHandler::handlePacket(network::Packet& packet) { recentLootMoneyAnnounceCooldowns_[notifyGuid] = 1.5f; } } + if (addonEventCallback_) addonEventCallback_("PLAYER_MONEY", {}); } break; } @@ -3503,6 +4855,14 @@ void GameHandler::handlePacket(network::Packet& packet) { " result=", static_cast(result)); if (result == 0) { pendingSellToBuyback_.erase(itemGuid); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playDropOnGround(); + } + if (addonEventCallback_) { + addonEventCallback_("BAG_UPDATE", {}); + addonEventCallback_("PLAYER_MONEY", {}); + } } else { bool removedPending = false; auto it = pendingSellToBuyback_.find(itemGuid); @@ -3538,7 +4898,12 @@ void GameHandler::handlePacket(network::Packet& packet) { "Unknown error", "Only empty bag" }; const char* msg = (result < 7) ? sellErrors[result] : "Unknown sell error"; + addUIError(std::string("Sell failed: ") + msg); addSystemChatMessage(std::string("Sell failed: ") + msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } LOG_WARNING("SMSG_SELL_ITEM error: ", (int)result, " (", msg, ")"); } } @@ -3549,10 +4914,31 @@ void GameHandler::handlePacket(network::Packet& packet) { uint8_t error = packet.readUInt8(); if (error != 0) { LOG_WARNING("SMSG_INVENTORY_CHANGE_FAILURE: error=", (int)error); + // After error byte: item_guid1(8) + item_guid2(8) + bag_slot(1) = 17 bytes + uint32_t requiredLevel = 0; + if (packet.getSize() - packet.getReadPos() >= 17) { + packet.readUInt64(); // item_guid1 + packet.readUInt64(); // item_guid2 + packet.readUInt8(); // bag_slot + // Error 1 = EQUIP_ERR_LEVEL_REQ: server appends required level as uint32 + if (error == 1 && packet.getSize() - packet.getReadPos() >= 4) + requiredLevel = packet.readUInt32(); + } // InventoryResult enum (AzerothCore 3.3.5a) const char* errMsg = nullptr; + char levelBuf[64]; switch (error) { - case 1: errMsg = "You must reach level %d to use that item."; break; + case 1: + if (requiredLevel > 0) { + std::snprintf(levelBuf, sizeof(levelBuf), + "You must reach level %u to use that item.", requiredLevel); + addUIError(levelBuf); + addSystemChatMessage(levelBuf); + } else { + addUIError("You must reach a higher level to use that item."); + addSystemChatMessage("You must reach a higher level to use that item."); + } + break; case 2: errMsg = "You don't have the required skill."; break; case 3: errMsg = "That item doesn't go in that slot."; break; case 4: errMsg = "That bag is full."; break; @@ -3612,7 +4998,12 @@ void GameHandler::handlePacket(network::Packet& packet) { default: break; } std::string msg = errMsg ? errMsg : "Inventory error (" + std::to_string(error) + ")."; + addUIError(msg); addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } } } break; @@ -3672,7 +5063,12 @@ void GameHandler::handlePacket(network::Packet& packet) { case 6: msg = "You can't carry any more items."; break; default: break; } + addUIError(msg); addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } } break; } @@ -3701,32 +5097,59 @@ void GameHandler::handlePacket(network::Packet& packet) { } } LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); + if (addonEventCallback_) + addonEventCallback_("RAID_TARGET_UPDATE", {}); 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. if (packet.getSize() - packet.getReadPos() >= 20) { - uint64_t vendorGuid = packet.readUInt64(); - uint32_t vendorSlot = packet.readUInt32(); - int32_t newCount = static_cast(packet.readUInt32()); + /*uint64_t vendorGuid =*/ packet.readUInt64(); + /*uint32_t vendorSlot =*/ packet.readUInt32(); + /*int32_t newCount =*/ static_cast(packet.readUInt32()); uint32_t itemCount = packet.readUInt32(); - LOG_DEBUG("SMSG_BUY_ITEM: vendorGuid=0x", std::hex, vendorGuid, std::dec, - " slot=", vendorSlot, " newCount=", newCount, " bought=", itemCount); + // Show purchase confirmation with item name if available + if (pendingBuyItemId_ != 0) { + std::string itemLabel; + uint32_t buyQuality = 1; + if (const ItemQueryResponseData* info = getItemInfo(pendingBuyItemId_)) { + if (!info->name.empty()) itemLabel = info->name; + buyQuality = info->quality; + } + if (itemLabel.empty()) itemLabel = "item #" + std::to_string(pendingBuyItemId_); + std::string msg = "Purchased: " + buildItemLink(pendingBuyItemId_, buyQuality, itemLabel); + if (itemCount > 1) msg += " x" + std::to_string(itemCount); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playPickupBag(); + } + } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; + if (addonEventCallback_) { + addonEventCallback_("MERCHANT_UPDATE", {}); + addonEventCallback_("BAG_UPDATE", {}); + } } break; } case Opcode::SMSG_CRITERIA_UPDATE: { // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime - // Achievement criteria progress (informational — no criteria UI yet). if (packet.getSize() - packet.getReadPos() >= 20) { uint32_t criteriaId = packet.readUInt32(); uint64_t progress = packet.readUInt64(); - /*uint32_t elapsedTime =*/ packet.readUInt32(); - /*uint32_t createTime =*/ packet.readUInt32(); + packet.readUInt32(); // elapsedTime + packet.readUInt32(); // creationTime + uint64_t oldProgress = 0; + auto cpit = criteriaProgress_.find(criteriaId); + if (cpit != criteriaProgress_.end()) oldProgress = cpit->second; + criteriaProgress_[criteriaId] = progress; LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); + // Fire addon event for achievement tracking addons + if (addonEventCallback_ && progress != oldProgress) + addonEventCallback_("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)}); } break; } @@ -3736,11 +5159,14 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t result = packet.readUInt32(); if (result == 0) { addSystemChatMessage("Hairstyle changed."); + barberShopOpen_ = false; + if (addonEventCallback_) addonEventCallback_("BARBER_SHOP_CLOSE", {}); } else { const char* msg = (result == 1) ? "Not enough money for new hairstyle." : (result == 2) ? "You are not at a barber shop." : (result == 3) ? "You must stand up to use the barber shop." : "Barber shop unavailable."; + addUIError(msg); addSystemChatMessage(msg); } LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); @@ -3761,15 +5187,38 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_WEATHER: { - // Format: uint32 weatherType, float intensity, uint8 isAbrupt - if (packet.getSize() - packet.getReadPos() >= 9) { + // Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt) + // TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes) + if (packet.getSize() - packet.getReadPos() >= 8) { uint32_t wType = packet.readUInt32(); float wIntensity = packet.readFloat(); - /*uint8_t isAbrupt =*/ packet.readUInt8(); + if (packet.getSize() - packet.getReadPos() >= 1) + /*uint8_t isAbrupt =*/ packet.readUInt8(); + uint32_t prevWeatherType = weatherType_; weatherType_ = wType; weatherIntensity_ = wIntensity; const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); + // Announce weather changes (including initial zone weather) + if (wType != prevWeatherType) { + const char* weatherMsg = nullptr; + if (wIntensity < 0.05f || wType == 0) { + if (prevWeatherType != 0) + weatherMsg = "The weather clears."; + } else if (wType == 1) { + weatherMsg = "It begins to rain."; + } else if (wType == 2) { + weatherMsg = "It begins to snow."; + } else if (wType == 3) { + weatherMsg = "A storm rolls in."; + } + if (weatherMsg) addSystemChatMessage(weatherMsg); + } + // Storm transition: trigger a low-frequency thunder rumble shake + if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { + float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units + cameraShakeCallback_(mag, 6.0f, 0.6f); + } } break; } @@ -3785,12 +5234,27 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_ENCHANTMENTLOG: { // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType if (packet.getSize() - packet.getReadPos() >= 28) { - /*uint64_t targetGuid =*/ packet.readUInt64(); - /*uint64_t casterGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); + uint64_t enchTargetGuid = packet.readUInt64(); + uint64_t enchCasterGuid = packet.readUInt64(); + uint32_t enchSpellId = packet.readUInt32(); /*uint32_t displayId =*/ packet.readUInt32(); /*uint32_t animType =*/ packet.readUInt32(); - LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", spellId); + LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", enchSpellId); + // Show enchant message if the player is involved + if (enchTargetGuid == playerGuid || enchCasterGuid == playerGuid) { + const std::string& enchName = getSpellName(enchSpellId); + std::string casterName = lookupName(enchCasterGuid); + if (!enchName.empty()) { + std::string msg; + if (enchCasterGuid == playerGuid) + msg = "You enchant with " + enchName + "."; + else if (!casterName.empty()) + msg = casterName + " enchants your item with " + enchName + "."; + else + msg = "Your item has been enchanted with " + enchName + "."; + addSystemChatMessage(msg); + } + } } break; } @@ -3802,6 +5266,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (result == 0) { addSystemChatMessage("Gems socketed successfully."); } else { + addUIError("Failed to socket gems."); addSystemChatMessage("Failed to socket gems."); } LOG_DEBUG("SMSG_SOCKET_GEMS_RESULT: result=", result); @@ -3838,6 +5303,7 @@ void GameHandler::handlePacket(network::Packet& packet) { const char* msg = (reason == 1) ? "The target cannot be resurrected right now." : (reason == 2) ? "Cannot resurrect in this area." : "Resurrection failed."; + addUIError(msg); addSystemChatMessage(msg); LOG_DEBUG("SMSG_RESURRECT_FAILED: reason=", reason); } @@ -3856,6 +5322,26 @@ void GameHandler::handlePacket(network::Packet& packet) { if (gameObjectCustomAnimCallback_) { gameObjectCustomAnimCallback_(guid, animId); } + // animId == 0 is the fishing bobber splash ("fish hooked"). + // Detect by GO type 17 (FISHINGNODE) and notify the player so they + // know to click the bobber before the fish escapes. + if (animId == 0) { + auto goEnt = entityManager.getEntity(guid); + if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(goEnt); + auto* info = getCachedGameObjectInfo(go->getEntry()); + if (info && info->type == 17) { // GO_TYPE_FISHINGNODE + addUIError("A fish is on your line!"); + addSystemChatMessage("A fish is on your line!"); + // Play a distinctive UI sound to alert the player + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) { + sfx->playQuestUpdate(); // Distinct "ping" sound + } + } + } + } + } } break; } @@ -3946,12 +5432,25 @@ void GameHandler::handlePacket(network::Packet& packet) { } for (auto it = questLog_.begin(); it != questLog_.end(); ++it) { if (it->questId == questId) { + // Fire toast callback before erasing + if (questCompleteCallback_) { + questCompleteCallback_(questId, it->title); + } + // Play quest-complete sound + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playQuestComplete(); + } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); + if (addonEventCallback_) + addonEventCallback_("QUEST_TURNED_IN", {std::to_string(questId)}); break; } } } + if (addonEventCallback_) + addonEventCallback_("QUEST_LOG_UPDATE", {}); // Re-query all nearby quest giver NPCs so markers refresh if (socket) { for (const auto& [guid, entity] : entityManager.getEntities()) { @@ -3990,15 +5489,40 @@ void GameHandler::handlePacket(network::Packet& packet) { if (reqCount == 0) { auto it = quest.killCounts.find(entry); if (it != quest.killCounts.end()) reqCount = it->second.second; - if (reqCount == 0) reqCount = count; } + // Fall back to killObjectives (parsed from SMSG_QUEST_QUERY_RESPONSE). + // Note: npcOrGoId < 0 means game object; server always sends entry as uint32 + // in QUESTUPDATE_ADD_KILL regardless of type, so match by absolute value. + if (reqCount == 0) { + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId == 0 || obj.required == 0) continue; + uint32_t objEntry = static_cast( + obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); + if (objEntry == entry) { + reqCount = obj.required; + break; + } + } + } + if (reqCount == 0) reqCount = count; // last-resort: avoid 0/0 display 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); + if (questProgressCallback_) { + questProgressCallback_(quest.title, creatureName, count, reqCount); + } + if (addonEventCallback_) { + addonEventCallback_("QUEST_WATCH_UPDATE", {std::to_string(questId)}); + addonEventCallback_("QUEST_LOG_UPDATE", {}); + } + LOG_INFO("Updated kill count for quest ", questId, ": ", count, "/", reqCount); break; @@ -4027,21 +5551,58 @@ void GameHandler::handlePacket(network::Packet& packet) { queryItemInfo(itemId, 0); std::string itemLabel = "item #" + std::to_string(itemId); + uint32_t questItemQuality = 1; if (const ItemQueryResponseData* info = getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; + questItemQuality = info->quality; } bool updatedAny = false; for (auto& quest : questLog_) { if (quest.complete) continue; - const bool tracksItem = + bool tracksItem = quest.requiredItemCounts.count(itemId) > 0 || quest.itemCounts.count(itemId) > 0; + // Also check itemObjectives parsed from SMSG_QUEST_QUERY_RESPONSE in case + // requiredItemCounts hasn't been populated yet (race during quest accept). + if (!tracksItem) { + for (const auto& obj : quest.itemObjectives) { + if (obj.itemId == itemId && obj.required > 0) { + quest.requiredItemCounts.emplace(itemId, obj.required); + tracksItem = true; + break; + } + } + } if (!tracksItem) continue; quest.itemCounts[itemId] = count; updatedAny = true; } - addSystemChatMessage("Quest item: " + itemLabel + " (" + std::to_string(count) + ")"); + addSystemChatMessage("Quest item: " + buildItemLink(itemId, questItemQuality, itemLabel) + " (" + std::to_string(count) + ")"); + + if (questProgressCallback_ && updatedAny) { + // Find the quest that tracks this item to get title and required count + for (const auto& quest : questLog_) { + if (quest.complete) continue; + if (quest.itemCounts.count(itemId) == 0) continue; + uint32_t required = 0; + auto rIt = quest.requiredItemCounts.find(itemId); + if (rIt != quest.requiredItemCounts.end()) required = rIt->second; + if (required == 0) { + for (const auto& obj : quest.itemObjectives) { + if (obj.itemId == itemId) { required = obj.required; break; } + } + } + if (required == 0) required = count; + questProgressCallback_(quest.title, itemLabel, count, required); + break; + } + } + + if (addonEventCallback_ && updatedAny) { + addonEventCallback_("QUEST_WATCH_UPDATE", {}); + addonEventCallback_("QUEST_LOG_UPDATE", {}); + } LOG_INFO("Quest item update: itemId=", itemId, " count=", count, " trackedQuestsUpdated=", updatedAny); } @@ -4086,12 +5647,33 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_QUEST_FORCE_REMOVE: { - // Minimal parse: uint32 questId + // This opcode is aliased to SMSG_SET_REST_START in the opcode table + // because both share opcode 0x21E in WotLK 3.3.5a. + // In WotLK: payload = uint32 areaId (entering rest) or 0 (leaving rest). + // In Classic/TBC: payload = uint32 questId (force-remove a quest). if (packet.getSize() - packet.getReadPos() < 4) { - LOG_WARNING("SMSG_QUEST_FORCE_REMOVE too short"); + LOG_WARNING("SMSG_QUEST_FORCE_REMOVE/SET_REST_START too short"); break; } - uint32_t questId = packet.readUInt32(); + uint32_t value = packet.readUInt32(); + + // WotLK uses this opcode as SMSG_SET_REST_START: non-zero = entering + // a rest area (inn/city), zero = leaving. Classic/TBC use it for quest removal. + if (!isClassicLikeExpansion() && !isActiveExpansion("tbc")) { + // WotLK: treat as SET_REST_START + bool nowResting = (value != 0); + if (nowResting != isResting_) { + isResting_ = nowResting; + addSystemChatMessage(isResting_ ? "You are now resting." + : "You are no longer resting."); + if (addonEventCallback_) + addonEventCallback_("PLAYER_UPDATE_RESTING", {}); + } + break; + } + + // Classic/TBC: treat as QUEST_FORCE_REMOVE (uint32 questId) + uint32_t questId = value; clearPendingQuestAccept(questId); pendingQuestQueryIds_.erase(questId); if (questId == 0) { @@ -4112,6 +5694,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } if (currentQuestDetails.questId == questId) { questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; currentQuestDetails = QuestDetailsData{}; removed = true; } @@ -4143,8 +5726,12 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t questId = packet.readUInt32(); packet.readUInt32(); // questMethod - const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3; + // Classic/Turtle = stride 3, TBC = stride 4 — all use 40 fixed fields + 4 strings. + // WotLK = stride 5, uses 55 fixed fields + 5 strings. + const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); + const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); + const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout); for (auto& q : questLog_) { if (q.questId != questId) continue; @@ -4168,6 +5755,53 @@ void GameHandler::handlePacket(network::Packet& packet) { (q.objectives.empty() || q.objectives.size() < 16)) { q.objectives = parsed.objectives; } + + // Store structured kill/item objectives for later kill-count restoration. + if (objs.valid) { + for (int i = 0; i < 4; ++i) { + q.killObjectives[i].npcOrGoId = objs.kills[i].npcOrGoId; + q.killObjectives[i].required = objs.kills[i].required; + } + for (int i = 0; i < 6; ++i) { + q.itemObjectives[i].itemId = objs.items[i].itemId; + q.itemObjectives[i].required = objs.items[i].required; + } + // Now that we have the objective creature IDs, apply any packed kill + // counts from the player update fields that arrived at login. + applyPackedKillCountsFromFields(q); + // Pre-fetch creature/GO names and item info so objective display is + // populated by the time the player opens the quest log. + for (int i = 0; i < 4; ++i) { + int32_t id = objs.kills[i].npcOrGoId; + if (id == 0 || objs.kills[i].required == 0) continue; + if (id > 0) queryCreatureInfo(static_cast(id), 0); + else queryGameObjectInfo(static_cast(-id), 0); + } + for (int i = 0; i < 6; ++i) { + if (objs.items[i].itemId != 0 && objs.items[i].required != 0) + queryItemInfo(objs.items[i].itemId, 0); + } + LOG_DEBUG("Quest ", questId, " objectives parsed: kills=[", + objs.kills[0].npcOrGoId, "/", objs.kills[0].required, ", ", + objs.kills[1].npcOrGoId, "/", objs.kills[1].required, ", ", + objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ", + objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]"); + } + + // Store reward data and pre-fetch item info for icons. + if (rwds.valid) { + q.rewardMoney = rwds.rewardMoney; + for (int i = 0; i < 4; ++i) { + q.rewardItems[i].itemId = rwds.itemId[i]; + q.rewardItems[i].count = (rwds.itemId[i] != 0) ? rwds.itemCount[i] : 0; + if (rwds.itemId[i] != 0) queryItemInfo(rwds.itemId[i], 0); + } + for (int i = 0; i < 6; ++i) { + q.rewardChoiceItems[i].itemId = rwds.choiceItemId[i]; + q.rewardChoiceItems[i].count = (rwds.choiceItemId[i] != 0) ? rwds.choiceItemCount[i] : 0; + if (rwds.choiceItemId[i] != 0) queryItemInfo(rwds.choiceItemId[i], 0); + } + } break; } @@ -4176,6 +5810,7 @@ void GameHandler::handlePacket(network::Packet& packet) { } case Opcode::SMSG_QUESTLOG_FULL: // Zero-payload notification: the player's quest log is full (25 quests). + addUIError("Your quest log is full."); addSystemChatMessage("Your quest log is full."); LOG_INFO("SMSG_QUESTLOG_FULL: quest log is at capacity"); break; @@ -4185,11 +5820,30 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_QUESTGIVER_OFFER_REWARD: handleQuestOfferReward(packet); break; - case Opcode::SMSG_GROUP_SET_LEADER: - LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); + case Opcode::SMSG_GROUP_SET_LEADER: { + // SMSG_GROUP_SET_LEADER: string leaderName (null-terminated) + if (packet.getSize() > packet.getReadPos()) { + std::string leaderName = packet.readString(); + // Update leaderGuid by name lookup in party members + for (const auto& m : partyData.members) { + if (m.name == leaderName) { + partyData.leaderGuid = m.guid; + break; + } + } + if (!leaderName.empty()) + addSystemChatMessage(leaderName + " is now the group leader."); + LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName); + if (addonEventCallback_) { + addonEventCallback_("PARTY_LEADER_CHANGED", {}); + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + } + } break; + } // ---- Teleport / Transfer ---- + case Opcode::MSG_MOVE_TELEPORT: case Opcode::MSG_MOVE_TELEPORT_ACK: handleTeleportAck(packet); break; @@ -4212,7 +5866,22 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t mapId = packet.readUInt32(); uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason); - addSystemChatMessage("Transfer aborted."); + // Provide reason-specific feedback (WotLK TRANSFER_ABORT_* codes) + const char* abortMsg = nullptr; + switch (reason) { + case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break; + case 0x02: abortMsg = "Transfer aborted: expansion required."; break; + case 0x03: abortMsg = "Transfer aborted: instance not found."; break; + case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break; + case 0x06: abortMsg = "Transfer aborted: instance is full."; break; + case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break; + case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break; + case 0x09: abortMsg = "Transfer aborted: not enough players."; break; + case 0x0C: abortMsg = "Transfer aborted."; break; + default: abortMsg = "Transfer aborted."; break; + } + addUIError(abortMsg); + addSystemChatMessage(abortMsg); break; } @@ -4230,6 +5899,9 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Stand state updated: ", static_cast(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: @@ -4243,15 +5915,28 @@ void GameHandler::handlePacket(network::Packet& packet) { handleBattlefieldStatus(packet); break; case Opcode::SMSG_BATTLEFIELD_LIST: - LOG_INFO("Received SMSG_BATTLEFIELD_LIST"); + handleBattlefieldList(packet); break; case Opcode::SMSG_BATTLEFIELD_PORT_DENIED: + addUIError("Battlefield port denied."); addSystemChatMessage("Battlefield port denied."); break; - case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: - // Optional map position updates for BG objectives/players. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS: { + bgPlayerPositions_.clear(); + for (int grp = 0; grp < 2; ++grp) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t count = packet.readUInt32(); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 16; ++i) { + BgPlayerPosition pos; + pos.guid = packet.readUInt64(); + pos.wowX = packet.readFloat(); + pos.wowY = packet.readFloat(); + pos.group = grp; + bgPlayerPositions_.push_back(pos); + } + } break; + } case Opcode::SMSG_REMOVED_FROM_PVP_QUEUE: addSystemChatMessage("You have been removed from the PvP queue."); break; @@ -4261,13 +5946,32 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE: addSystemChatMessage("You have joined the battleground queue."); break; - case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: - LOG_INFO("Battleground player joined"); + case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: { + // SMSG_BATTLEGROUND_PLAYER_JOINED: uint64 guid + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + auto it = playerNameCache.find(guid); + std::string name = (it != playerNameCache.end()) ? it->second : ""; + if (!name.empty()) + addSystemChatMessage(name + " has entered the battleground."); + LOG_INFO("SMSG_BATTLEGROUND_PLAYER_JOINED: guid=0x", std::hex, guid, std::dec); + } break; - case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: - LOG_INFO("Battleground player left"); + } + case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: { + // SMSG_BATTLEGROUND_PLAYER_LEFT: uint64 guid + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + auto it = playerNameCache.find(guid); + std::string name = (it != playerNameCache.end()) ? it->second : ""; + if (!name.empty()) + addSystemChatMessage(name + " has left the battleground."); + LOG_INFO("SMSG_BATTLEGROUND_PLAYER_LEFT: guid=0x", std::hex, guid, std::dec); + } break; + } case Opcode::SMSG_INSTANCE_DIFFICULTY: + case Opcode::MSG_SET_DUNGEON_DIFFICULTY: handleInstanceDifficulty(packet); break; case Opcode::SMSG_INSTANCE_SAVE_CREATED: @@ -4280,17 +5984,18 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t msgType = packet.readUInt32(); uint32_t mapId = packet.readUInt32(); /*uint32_t diff =*/ packet.readUInt32(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); // type: 1=warning(time left), 2=saved, 3=welcome if (msgType == 1 && packet.getSize() - packet.getReadPos() >= 4) { uint32_t timeLeft = packet.readUInt32(); uint32_t minutes = timeLeft / 60; - std::string msg = "Instance " + std::to_string(mapId) + - " will reset in " + std::to_string(minutes) + " minute(s)."; - addSystemChatMessage(msg); + addSystemChatMessage(mapLabel + " will reset in " + + std::to_string(minutes) + " minute(s)."); } else if (msgType == 2) { - addSystemChatMessage("You have been saved to instance " + std::to_string(mapId) + "."); + addSystemChatMessage("You have been saved to " + mapLabel + "."); } else if (msgType == 3) { - addSystemChatMessage("Welcome to instance " + std::to_string(mapId) + "."); + addSystemChatMessage("Welcome to " + mapLabel + "."); } LOG_INFO("SMSG_RAID_INSTANCE_MESSAGE: type=", msgType, " map=", mapId); } @@ -4303,7 +6008,9 @@ void GameHandler::handlePacket(network::Packet& packet) { auto it = std::remove_if(instanceLockouts_.begin(), instanceLockouts_.end(), [mapId](const InstanceLockout& lo){ return lo.mapId == mapId; }); instanceLockouts_.erase(it, instanceLockouts_.end()); - addSystemChatMessage("Instance " + std::to_string(mapId) + " has been reset."); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addSystemChatMessage(mapLabel + " has been reset."); LOG_INFO("SMSG_INSTANCE_RESET: mapId=", mapId); } break; @@ -4316,8 +6023,11 @@ void GameHandler::handlePacket(network::Packet& packet) { "Not max level.", "Offline party members.", "Party members inside.", "Party members changing zone.", "Heroic difficulty only." }; - const char* msg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; - addSystemChatMessage("Cannot reset instance " + std::to_string(mapId) + ": " + msg); + const char* reasonMsg = (reason < 5) ? resetFailReasons[reason] : "Unknown reason."; + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "instance #" + std::to_string(mapId); + addUIError("Cannot reset " + mapLabel + ": " + reasonMsg); + addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); LOG_INFO("SMSG_INSTANCE_RESET_FAILED: mapId=", mapId, " reason=", reason); } break; @@ -4326,16 +6036,30 @@ void GameHandler::handlePacket(network::Packet& packet) { // Server asks player to confirm entering a saved instance. // We auto-confirm with CMSG_INSTANCE_LOCK_RESPONSE. if (socket && packet.getSize() - packet.getReadPos() >= 17) { - /*uint32_t mapId =*/ packet.readUInt32(); - /*uint32_t diff =*/ packet.readUInt32(); - /*uint32_t timeLeft =*/ packet.readUInt32(); + uint32_t ilMapId = packet.readUInt32(); + uint32_t ilDiff = packet.readUInt32(); + uint32_t ilTimeLeft = packet.readUInt32(); packet.readUInt32(); // unk - /*uint8_t locked =*/ packet.readUInt8(); + uint8_t ilLocked = packet.readUInt8(); + // Notify player which instance is being entered/resumed + std::string ilName = getMapName(ilMapId); + if (ilName.empty()) ilName = "instance #" + std::to_string(ilMapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + std::string ilMsg = "Entering " + ilName; + if (ilDiff < 4) ilMsg += std::string(" (") + kDiff[ilDiff] + ")"; + if (ilLocked && ilTimeLeft > 0) { + uint32_t ilMins = ilTimeLeft / 60; + ilMsg += " — " + std::to_string(ilMins) + " min remaining."; + } else { + ilMsg += "."; + } + addSystemChatMessage(ilMsg); // Send acceptance network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE)); resp.writeUInt8(1); // 1=accept socket->send(resp); - LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted"); + LOG_INFO("SMSG_INSTANCE_LOCK_WARNING_QUERY: auto-accepted mapId=", ilMapId, + " diff=", ilDiff, " timeLeft=", ilTimeLeft); } break; } @@ -4373,15 +6097,43 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_LFG_OFFER_CONTINUE: addSystemChatMessage("Dungeon Finder: You may continue your dungeon."); break; - case Opcode::SMSG_LFG_ROLE_CHOSEN: + case Opcode::SMSG_LFG_ROLE_CHOSEN: { + // uint64 guid + uint8 ready + uint32 roles + if (packet.getSize() - packet.getReadPos() >= 13) { + uint64_t roleGuid = packet.readUInt64(); + uint8_t ready = packet.readUInt8(); + uint32_t roles = packet.readUInt32(); + // Build a descriptive message for group chat + std::string roleName; + if (roles & 0x02) roleName += "Tank "; + if (roles & 0x04) roleName += "Healer "; + if (roles & 0x08) roleName += "DPS "; + if (roleName.empty()) roleName = "None"; + // Find player name + std::string pName = "A player"; + if (auto e = entityManager.getEntity(roleGuid)) + if (auto u = std::dynamic_pointer_cast(e)) + pName = u->getName(); + if (ready) + addSystemChatMessage(pName + " has chosen: " + roleName); + LOG_DEBUG("SMSG_LFG_ROLE_CHOSEN: guid=", roleGuid, + " ready=", (int)ready, " roles=", roles); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_LFG_UPDATE_SEARCH: case Opcode::SMSG_UPDATE_LFG_LIST: case Opcode::SMSG_LFG_PLAYER_INFO: case Opcode::SMSG_LFG_PARTY_INFO: - case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: // Informational LFG packets not yet surfaced in UI — consume silently. packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: + // Server requests client to open the dungeon finder UI + packet.setReadPos(packet.getSize()); // consume any payload + if (openLfgCallback_) openLfgCallback_(); + break; case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT: handleArenaTeamCommandResult(packet); @@ -4390,7 +6142,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaTeamQueryResponse(packet); break; case Opcode::SMSG_ARENA_TEAM_ROSTER: - LOG_INFO("Received SMSG_ARENA_TEAM_ROSTER"); + handleArenaTeamRoster(packet); break; case Opcode::SMSG_ARENA_TEAM_INVITE: handleArenaTeamInvite(packet); @@ -4399,21 +6151,61 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaTeamEvent(packet); break; case Opcode::SMSG_ARENA_TEAM_STATS: - LOG_INFO("Received SMSG_ARENA_TEAM_STATS"); + handleArenaTeamStats(packet); break; case Opcode::SMSG_ARENA_ERROR: handleArenaError(packet); break; case Opcode::MSG_PVP_LOG_DATA: - LOG_INFO("Received MSG_PVP_LOG_DATA"); + handlePvpLogData(packet); break; - case Opcode::MSG_INSPECT_ARENA_TEAMS: - LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); + case Opcode::MSG_INSPECT_ARENA_TEAMS: { + // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); + break; + } + uint64_t inspGuid = packet.readUInt64(); + uint8_t teamCount = packet.readUInt8(); + if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5 + if (inspGuid == inspectResult_.guid || inspectResult_.guid == 0) { + inspectResult_.guid = inspGuid; + inspectResult_.arenaTeams.clear(); + for (uint8_t t = 0; t < teamCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 21) break; + InspectArenaTeam team; + team.teamId = packet.readUInt32(); + team.type = packet.readUInt8(); + team.weekGames = packet.readUInt32(); + team.weekWins = packet.readUInt32(); + team.seasonGames = packet.readUInt32(); + team.seasonWins = packet.readUInt32(); + team.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 4) break; + team.personalRating = packet.readUInt32(); + inspectResult_.arenaTeams.push_back(std::move(team)); + } + } + LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec, + " teams=", (int)teamCount); break; - case Opcode::MSG_TALENT_WIPE_CONFIRM: - // Talent reset confirmation payload is not needed client-side right now. - packet.setReadPos(packet.getSize()); + } + case Opcode::MSG_TALENT_WIPE_CONFIRM: { + // Server sends: uint64 npcGuid + uint32 cost + // Client must respond with the same opcode containing uint64 npcGuid to confirm. + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); + break; + } + talentWipeNpcGuid_ = packet.readUInt64(); + talentWipeCost_ = packet.readUInt32(); + talentWipePending_ = true; + LOG_INFO("MSG_TALENT_WIPE_CONFIRM: npc=0x", std::hex, talentWipeNpcGuid_, + std::dec, " cost=", talentWipeCost_); + if (addonEventCallback_) + addonEventCallback_("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)}); break; + } // ---- MSG_MOVE_* opcodes (server relays other players' movement) ---- case Opcode::MSG_MOVE_START_FORWARD: @@ -4431,11 +6223,41 @@ 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: + case Opcode::MSG_MOVE_START_DESCEND: + case Opcode::MSG_MOVE_SET_PITCH: + case Opcode::MSG_MOVE_GRAVITY_CHNG: + case Opcode::MSG_MOVE_UPDATE_CAN_FLY: + case Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + case Opcode::MSG_MOVE_ROOT: + case Opcode::MSG_MOVE_UNROOT: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } break; + // ---- Broadcast speed changes (server→client, no ACK) ---- + // Format: PackedGuid (mover) + MovementInfo (variable) + float speed + // MovementInfo is complex (optional transport/fall/spline blocks based on flags). + // We consume the packet to suppress "Unhandled world opcode" warnings. + case Opcode::MSG_MOVE_SET_RUN_SPEED: + case Opcode::MSG_MOVE_SET_RUN_BACK_SPEED: + case Opcode::MSG_MOVE_SET_WALK_SPEED: + case Opcode::MSG_MOVE_SET_SWIM_SPEED: + case Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED: + case Opcode::MSG_MOVE_SET_FLIGHT_SPEED: + case Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED: + if (state == WorldState::IN_WORLD) { + handleMoveSetSpeed(packet); + } + break; + // ---- Mail ---- case Opcode::SMSG_SHOW_MAILBOX: handleShowMailbox(packet); @@ -4452,10 +6274,39 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_QUERY_NEXT_MAIL_TIME: handleQueryNextMailTime(packet); break; - case Opcode::SMSG_CHANNEL_LIST: - // Channel member listing currently not rendered in UI. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_CHANNEL_LIST: { + // string channelName + uint8 flags + uint32 count + count×(uint64 guid + uint8 memberFlags) + std::string chanName = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint8_t chanFlags =*/ packet.readUInt8(); + uint32_t memberCount = packet.readUInt32(); + memberCount = std::min(memberCount, 200u); + addSystemChatMessage(chanName + " has " + std::to_string(memberCount) + " member(s):"); + for (uint32_t i = 0; i < memberCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 9) break; + uint64_t memberGuid = packet.readUInt64(); + uint8_t memberFlags = packet.readUInt8(); + // Look up the name: entity manager > playerNameCache + auto entity = entityManager.getEntity(memberGuid); + std::string name; + if (entity) { + auto player = std::dynamic_pointer_cast(entity); + if (player && !player->getName().empty()) name = player->getName(); + } + if (name.empty()) { + auto nit = playerNameCache.find(memberGuid); + if (nit != playerNameCache.end()) name = nit->second; + } + if (name.empty()) name = "(unknown)"; + std::string entry = " " + name; + if (memberFlags & 0x01) entry += " [Moderator]"; + if (memberFlags & 0x02) entry += " [Muted]"; + addSystemChatMessage(entry); + LOG_DEBUG(" channel member: 0x", std::hex, memberGuid, std::dec, + " flags=", (int)memberFlags, " name=", name); + } break; + } case Opcode::SMSG_INSPECT_RESULTS_UPDATE: handleInspectResults(packet); break; @@ -4490,31 +6341,54 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAuctionCommandResult(packet); break; case Opcode::SMSG_AUCTION_OWNER_NOTIFICATION: { - // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + ... + // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ... + // action: 0=sold/won, 1=expired, 2=bid placed on your auction if (packet.getSize() - packet.getReadPos() >= 16) { - uint32_t auctionId = packet.readUInt32(); - uint32_t action = packet.readUInt32(); - uint32_t error = packet.readUInt32(); + /*uint32_t auctionId =*/ packet.readUInt32(); + uint32_t action = packet.readUInt32(); + /*uint32_t error =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - (void)auctionId; (void)action; (void)error; + int32_t ownerRandProp = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + ownerRandProp = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); - std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); - addSystemChatMessage("Your auction of " + itemName + " has sold!"); + std::string rawName = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + if (ownerRandProp != 0) { + std::string suffix = getRandomPropertyName(ownerRandProp); + if (!suffix.empty()) rawName += " " + suffix; + } + uint32_t aucQuality = info ? info->quality : 1u; + std::string itemLink = buildItemLink(itemEntry, aucQuality, rawName); + if (action == 1) + addSystemChatMessage("Your auction of " + itemLink + " has expired."); + else if (action == 2) + addSystemChatMessage("A bid has been placed on your auction of " + itemLink + "."); + else + addSystemChatMessage("Your auction of " + itemLink + " has sold!"); } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_AUCTION_BIDDER_NOTIFICATION: { - // auctionId(u32) + itemEntry(u32) + ... + // auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32) if (packet.getSize() - packet.getReadPos() >= 8) { - uint32_t auctionId = packet.readUInt32(); + /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - (void)auctionId; + int32_t bidRandProp = 0; + // Try to read randomPropertyId if enough data remains + if (packet.getSize() - packet.getReadPos() >= 4) + bidRandProp = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); - std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); - addSystemChatMessage("You have been outbid on " + itemName + "."); + std::string rawName2 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + if (bidRandProp != 0) { + std::string suffix = getRandomPropertyName(bidRandProp); + if (!suffix.empty()) rawName2 += " " + suffix; + } + uint32_t bidQuality = info ? info->quality : 1u; + std::string bidLink = buildItemLink(itemEntry, bidQuality, rawName2); + addSystemChatMessage("You have been outbid on " + bidLink + "."); } packet.setReadPos(packet.getSize()); break; @@ -4524,11 +6398,17 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 12) { /*uint32_t auctionId =*/ packet.readUInt32(); uint32_t itemEntry = packet.readUInt32(); - /*uint32_t itemRandom =*/ packet.readUInt32(); + int32_t itemRandom = static_cast(packet.readUInt32()); ensureItemInfo(itemEntry); auto* info = getItemInfo(itemEntry); - std::string itemName = info ? info->name : ("Item #" + std::to_string(itemEntry)); - addSystemChatMessage("Your auction of " + itemName + " has expired."); + std::string rawName3 = info && !info->name.empty() ? info->name : ("Item #" + std::to_string(itemEntry)); + if (itemRandom != 0) { + std::string suffix = getRandomPropertyName(itemRandom); + if (!suffix.empty()) rawName3 += " " + suffix; + } + uint32_t remQuality = info ? info->quality : 1u; + std::string remLink = buildItemLink(itemEntry, remQuality, rawName3); + addSystemChatMessage("Your auction of " + remLink + " has expired."); } packet.setReadPos(packet.getSize()); break; @@ -4546,10 +6426,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // GM ticket status (new/updated); no ticket UI yet packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_PLAYER_VEHICLE_DATA: - // Vehicle data update for player in vehicle; no vehicle UI yet - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PLAYER_VEHICLE_DATA: { + // PackedGuid (player guid) + uint32 vehicleId + // vehicleId == 0 means the player left the vehicle + if (packet.getSize() - packet.getReadPos() >= 1) { + (void)UpdateObjectParser::readPackedGuid(packet); // player guid (unused) + } + if (packet.getSize() - packet.getReadPos() >= 4) { + vehicleId_ = packet.readUInt32(); + } else { + vehicleId_ = 0; + } break; + } case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: packet.setReadPos(packet.getSize()); break; @@ -4576,6 +6465,7 @@ void GameHandler::handlePacket(network::Packet& packet) { std::vector* auraList = nullptr; if (auraTargetGuid == playerGuid) auraList = &playerAuras; else if (auraTargetGuid == targetGuid) auraList = &targetAuras; + else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid]; if (auraList && isInit) auraList->clear(); @@ -4583,19 +6473,21 @@ void GameHandler::handlePacket(network::Packet& packet) { std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); - for (uint8_t i = 0; i < count && remaining() >= 13; i++) { - uint8_t slot = packet.readUInt8(); - uint32_t spellId = packet.readUInt32(); - (void) packet.readUInt8(); // effectIndex (unused for slot display) - uint8_t flags = packet.readUInt8(); - uint32_t durationMs = packet.readUInt32(); - uint32_t maxDurMs = packet.readUInt32(); + for (uint8_t i = 0; i < count && remaining() >= 15; i++) { + uint8_t slot = packet.readUInt8(); // 1 byte + uint32_t spellId = packet.readUInt32(); // 4 bytes + (void) packet.readUInt8(); // effectIndex: 1 byte (unused for slot display) + uint8_t flags = packet.readUInt8(); // 1 byte + uint32_t durationMs = packet.readUInt32(); // 4 bytes + uint32_t maxDurMs = packet.readUInt32(); // 4 bytes — total 15 bytes per entry if (auraList) { while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); AuraSlot& a = (*auraList)[slot]; a.spellId = spellId; - a.flags = flags; + // TBC uses same flag convention as Classic: 0x02=harmful, 0x04=beneficial. + // Normalize to WotLK SMSG_AURA_UPDATE convention: 0x80=debuff, 0=buff. + a.flags = (flags & 0x02) ? 0x80u : 0u; a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast(durationMs); a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast(maxDurMs); a.receivedAtMs = nowMs; @@ -4630,6 +6522,10 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Talents involuntarily reset ---- case Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET: + // Clear cached talent data so the talent screen reflects the reset. + learnedTalents_[0].clear(); + learnedTalents_[1].clear(); + addUIError("Your talents have been reset by the server."); addSystemChatMessage("Your talents have been reset by the server."); packet.setReadPos(packet.getSize()); break; @@ -4644,8 +6540,11 @@ 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."); + if (addonEventCallback_) + addonEventCallback_("PLAYER_UPDATE_RESTING", {}); } break; } @@ -4712,9 +6611,19 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Movie trigger ---- - case Opcode::SMSG_TRIGGER_MOVIE: + case Opcode::SMSG_TRIGGER_MOVIE: { + // uint32 movieId — we don't play movies; acknowledge immediately. packet.setReadPos(packet.getSize()); + // WotLK servers expect CMSG_COMPLETE_MOVIE after the movie finishes; + // without it, the server may hang or disconnect the client. + uint16_t wire = wireOpcode(Opcode::CMSG_COMPLETE_MOVIE); + if (wire != 0xFFFF) { + network::Packet ack(wire); + socket->send(ack); + LOG_DEBUG("SMSG_TRIGGER_MOVIE: skipped, sent CMSG_COMPLETE_MOVIE"); + } break; + } // ---- Equipment sets ---- case Opcode::SMSG_EQUIPMENT_SET_LIST: @@ -4723,7 +6632,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_EQUIPMENT_SET_USE_RESULT: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t result = packet.readUInt8(); - if (result != 0) addSystemChatMessage("Failed to equip item set."); + if (result != 0) { addUIError("Failed to equip item set."); addSystemChatMessage("Failed to equip item set."); } } break; } @@ -4739,6 +6648,118 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- LFG error/timeout states ---- + case Opcode::SMSG_LFG_TIMEDOUT: + // Server-side LFG invite timed out (no response within time limit) + addSystemChatMessage("Dungeon Finder: Invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_OTHER_TIMEDOUT: + // Another party member failed to respond to a LFG role-check in time + addSystemChatMessage("Dungeon Finder: Another player's invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_AUTOJOIN_FAILED: { + // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t result = packet.readUInt32(); + (void)result; + } + addUIError("Dungeon Finder: Auto-join failed."); + addSystemChatMessage("Dungeon Finder: Auto-join failed."); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: + // No eligible players found for auto-join + addUIError("Dungeon Finder: No players available for auto-join."); + addSystemChatMessage("Dungeon Finder: No players available for auto-join."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_LEADER_IS_LFM: + // Party leader is currently set to Looking for More (LFM) mode + addSystemChatMessage("Your party leader is currently Looking for More."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Meeting stone (Classic/TBC group-finding via summon stone) ---- + case Opcode::SMSG_MEETINGSTONE_SETQUEUE: { + // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone + if (packet.getSize() - packet.getReadPos() >= 6) { + uint32_t zoneId = packet.readUInt32(); + uint8_t levelMin = packet.readUInt8(); + uint8_t levelMax = packet.readUInt8(); + char buf[128]; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for %s (levels %u-%u).", + zoneName.c_str(), levelMin, levelMax); + else + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", + zoneId, levelMin, levelMax); + addSystemChatMessage(buf); + LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, + " levels=", (int)levelMin, "-", (int)levelMax); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_MEETINGSTONE_COMPLETE: + // Server confirms group found and teleport summon is ready + addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon."); + LOG_INFO("SMSG_MEETINGSTONE_COMPLETE"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_MEETINGSTONE_IN_PROGRESS: + // Meeting stone search is still ongoing + addSystemChatMessage("Meeting Stone: Searching for group members..."); + LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED: { + // uint64 memberGuid — a player was added to your group via meeting stone + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t memberGuid = packet.readUInt64(); + auto nit = playerNameCache.find(memberGuid); + if (nit != playerNameCache.end() && !nit->second.empty()) { + addSystemChatMessage("Meeting Stone: " + nit->second + + " has been added to your group."); + } else { + addSystemChatMessage("Meeting Stone: A new player has been added to your group."); + } + LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec); + } + break; + } + case Opcode::SMSG_MEETINGSTONE_JOINFAILED: { + // uint8 reason — failed to join group via meeting stone + // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available + static const char* kMeetingstoneErrors[] = { + "Target player is not using the Meeting Stone.", + "Target player is already in a group.", + "You are not in a valid zone for that Meeting Stone.", + "Target player is not available.", + }; + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t reason = packet.readUInt8(); + const char* msg = (reason < 4) ? kMeetingstoneErrors[reason] + : "Meeting Stone: Could not join group."; + addSystemChatMessage(msg); + LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", (int)reason); + } + break; + } + case Opcode::SMSG_MEETINGSTONE_LEAVE: + // Player was removed from the meeting stone queue (left, or group disbanded) + addSystemChatMessage("You have left the Meeting Stone queue."); + LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE"); + packet.setReadPos(packet.getSize()); + break; + // ---- GM Ticket responses ---- case Opcode::SMSG_GMTICKET_CREATE: { if (packet.getSize() - packet.getReadPos() >= 1) { @@ -4764,10 +6785,70 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_GMTICKET_GETTICKET: - case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: + case Opcode::SMSG_GMTICKET_GETTICKET: { + // WotLK 3.3.5a format: + // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended + // If status == 6 (GMTICKET_STATUS_HASTEXT): + // cstring ticketText + // uint32 ticketAge (seconds old) + // uint32 daysUntilOld (days remaining before escalation) + // float waitTimeHours (estimated GM wait time) + if (packet.getSize() - packet.getReadPos() < 1) { packet.setReadPos(packet.getSize()); break; } + uint8_t gmStatus = packet.readUInt8(); + // Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text + if (gmStatus == 6 && packet.getSize() - packet.getReadPos() >= 1) { + gmTicketText_ = packet.readString(); + uint32_t ageSec = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; + /*uint32_t daysLeft =*/ (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; + gmTicketWaitHours_ = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readFloat() : 0.0f; + gmTicketActive_ = true; + char buf[256]; + if (ageSec < 60) { + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.", + ageSec, gmTicketWaitHours_); + } else { + uint32_t ageMin = ageSec / 60; + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.", + ageMin, gmTicketWaitHours_); + } + addSystemChatMessage(buf); + LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec, + "s wait=", gmTicketWaitHours_, "h"); + } else if (gmStatus == 3) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been closed."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed"); + } else if (gmStatus == 10) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been suspended."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended"); + } else { + // Status 1 = no open ticket (default/no ticket) + gmTicketActive_ = false; + gmTicketText_.clear(); + LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", (int)gmStatus, ")"); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: { + // uint32 status: 1 = GM support available, 0 = offline/unavailable + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t sysStatus = packet.readUInt32(); + gmSupportAvailable_ = (sysStatus != 0); + addSystemChatMessage(gmSupportAvailable_ + ? "GM support is currently available." + : "GM support is currently unavailable."); + LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_); + } + packet.setReadPos(packet.getSize()); + break; + } // ---- DK rune tracking ---- case Opcode::SMSG_CONVERT_RUNE: { @@ -4814,105 +6895,487 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Spell combat logs (consume) ---- - case Opcode::SMSG_AURACASTLOG: - case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { - // victimGuid(8) + casterGuid(8) + spellId(4) + damage(4) + schoolMask(4) - if (packet.getSize() - packet.getReadPos() < 24) { + // Classic: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + schoolMask(4) + // TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) + // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) + const bool shieldTbc = isActiveExpansion("tbc"); + const bool shieldWotlkLike = !isClassicLikeExpansion() && !shieldTbc; + const auto shieldRem = [&]() { return packet.getSize() - packet.getReadPos(); }; + const size_t shieldMinSz = shieldTbc ? 24u : 2u; + if (packet.getSize() - packet.getReadPos() < shieldMinSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = packet.readUInt64(); - uint64_t casterGuid = packet.readUInt64(); - /*uint32_t spellId =*/ packet.readUInt32(); - uint32_t damage = packet.readUInt32(); + if (!shieldTbc && (!hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = shieldTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (shieldTbc ? 8u : 1u) + || (!shieldTbc && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = shieldTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + const size_t shieldTailSize = shieldWotlkLike ? 16u : 12u; + if (shieldRem() < shieldTailSize) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t shieldSpellId = packet.readUInt32(); + uint32_t damage = packet.readUInt32(); + if (shieldWotlkLike) + /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect if (casterGuid == playerGuid) { // We have a damage shield that reflected damage - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, true); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, true, 0, casterGuid, victimGuid); } else if (victimGuid == playerGuid) { // A damage shield hit us (e.g. target's Thorns) - addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, false); + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, false, 0, casterGuid, victimGuid); } break; } + case Opcode::SMSG_AURACASTLOG: + case Opcode::SMSG_SPELLBREAKLOG: + // These packets are not damage-shield events. Consume them without + // synthesizing reflected damage entries or misattributing GUIDs. + packet.setReadPos(packet.getSize()); + break; case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { - // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType - // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 - const bool immuneTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - const size_t minSz = immuneTbcLike ? 21u : 2u; + // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType + // TBC: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 + const bool immuneUsesFullGuid = isActiveExpansion("tbc"); + const size_t minSz = immuneUsesFullGuid ? 21u : 2u; if (packet.getSize() - packet.getReadPos() < minSz) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = immuneTbcLike + if (!immuneUsesFullGuid && !hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (immuneTbcLike ? 8u : 2u)) break; - uint64_t victimGuid = immuneTbcLike + if (packet.getSize() - packet.getReadPos() < (immuneUsesFullGuid ? 8u : 2u) + || (!immuneUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = immuneUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; - /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t immuneSpellId = packet.readUInt32(); /*uint8_t saveType =*/ packet.readUInt8(); // Show IMMUNE text when the player is the caster (we hit an immune target) // or the victim (we are immune) if (casterGuid == playerGuid || victimGuid == playerGuid) { - addCombatText(CombatTextEntry::IMMUNE, 0, 0, - casterGuid == playerGuid); + addCombatText(CombatTextEntry::IMMUNE, 0, immuneSpellId, + casterGuid == playerGuid, 0, casterGuid, victimGuid); } break; } case Opcode::SMSG_SPELLDISPELLOG: { - // WotLK: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen - // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + ... + // WotLK/Classic/Turtle: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // TBC: full uint64 casterGuid + full uint64 victimGuid + ... // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) - const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); - if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) { + const bool dispelUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) + || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { packet.setReadPos(packet.getSize()); break; } - uint64_t casterGuid = dispelTbcLike + uint64_t casterGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) break; - uint64_t victimGuid = dispelTbcLike + if (packet.getSize() - packet.getReadPos() < (dispelUsesFullGuid ? 8u : 1u) + || (!dispelUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = dispelUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 9) break; /*uint32_t dispelSpell =*/ packet.readUInt32(); uint8_t isStolen = packet.readUInt8(); uint32_t count = packet.readUInt32(); + // Preserve every dispelled aura in the combat log instead of collapsing + // multi-aura packets down to the first entry only. + const size_t dispelEntrySize = dispelUsesFullGuid ? 8u : 5u; + std::vector dispelledIds; + dispelledIds.reserve(count); + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= dispelEntrySize; ++i) { + uint32_t dispelledId = packet.readUInt32(); + if (dispelUsesFullGuid) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPositive =*/ packet.readUInt8(); + } + if (dispelledId != 0) { + dispelledIds.push_back(dispelledId); + } + } // Show system message if player was victim or caster if (victimGuid == playerGuid || casterGuid == playerGuid) { - const char* verb = isStolen ? "stolen" : "dispelled"; - // Collect first dispelled spell name for the message - std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { - uint32_t dispelledId = packet.readUInt32(); - /*uint32_t unk =*/ packet.readUInt32(); - if (i == 0) { - const std::string& nm = getSpellName(dispelledId); - firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + std::vector loggedIds; + if (isStolen) { + loggedIds.reserve(dispelledIds.size()); + for (uint32_t dispelledId : dispelledIds) { + if (shouldLogSpellstealAura(casterGuid, victimGuid, dispelledId)) + loggedIds.push_back(dispelledId); } + } else { + loggedIds = dispelledIds; } - if (!firstSpellName.empty()) { + + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { char buf[256]; - if (victimGuid == playerGuid && casterGuid != playerGuid) - std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); - else if (casterGuid == playerGuid) - std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); - else - std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); + const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were"; + if (isStolen) { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s stolen.", + displaySpellNames.c_str(), passiveVerb); + } else { + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s dispelled.", + displaySpellNames.c_str(), passiveVerb); + } addSystemChatMessage(buf); } + // Preserve stolen auras as spellsteal events so the log wording stays accurate. + if (!loggedIds.empty()) { + bool isPlayerCaster = (casterGuid == playerGuid); + for (uint32_t dispelledId : loggedIds) { + addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, + 0, dispelledId, isPlayerCaster, 0, + casterGuid, victimGuid); + } + } } packet.setReadPos(packet.getSize()); break; } case Opcode::SMSG_SPELLSTEALLOG: { - // Similar to SPELLDISPELLOG but always isStolen=true; same wire format - // Just consume — SPELLDISPELLOG handles the player-facing case above + // Sent to the CASTER (Mage) when Spellsteal succeeds. + // Wire format mirrors SPELLDISPELLOG: + // WotLK/Classic/Turtle: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count + // + count × (uint32 stolenSpellId + uint8 isPositive) + // TBC: full uint64 victim + full uint64 caster + same tail + const bool stealUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) + || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t stealVictim = stealUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (stealUsesFullGuid ? 8u : 1u) + || (!stealUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t stealCaster = stealUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + /*uint32_t stealSpellId =*/ packet.readUInt32(); + /*uint8_t isStolen =*/ packet.readUInt8(); + uint32_t stealCount = packet.readUInt32(); + // Preserve every stolen aura in the combat log instead of only the first. + const size_t stealEntrySize = stealUsesFullGuid ? 8u : 5u; + std::vector stolenIds; + stolenIds.reserve(stealCount); + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= stealEntrySize; ++i) { + uint32_t stolenId = packet.readUInt32(); + if (stealUsesFullGuid) { + /*uint32_t unk =*/ packet.readUInt32(); + } else { + /*uint8_t isPos =*/ packet.readUInt8(); + } + if (stolenId != 0) { + stolenIds.push_back(stolenId); + } + } + if (stealCaster == playerGuid || stealVictim == playerGuid) { + std::vector loggedIds; + loggedIds.reserve(stolenIds.size()); + for (uint32_t stolenId : stolenIds) { + if (shouldLogSpellstealAura(stealCaster, stealVictim, stolenId)) + loggedIds.push_back(stolenId); + } + + const std::string displaySpellNames = formatSpellNameList(*this, loggedIds); + if (!displaySpellNames.empty()) { + char buf[256]; + if (stealCaster == playerGuid) + std::snprintf(buf, sizeof(buf), "You stole %s.", displaySpellNames.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), + loggedIds.size() == 1 ? "was" : "were"); + addSystemChatMessage(buf); + } + // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG + // for the same aura. Keep the first event and suppress the duplicate. + if (!loggedIds.empty()) { + bool isPlayerCaster = (stealCaster == playerGuid); + for (uint32_t stolenId : loggedIds) { + addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, + stealCaster, stealVictim); + } + } + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: { + // WotLK/Classic/Turtle: packed_guid target + packed_guid caster + uint32 spellId + ... + // TBC: uint64 target + uint64 caster + uint32 spellId + ... + const bool procChanceUsesFullGuid = isActiveExpansion("tbc"); + auto readProcChanceGuid = [&]() -> uint64_t { + if (procChanceUsesFullGuid) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) + || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t procTargetGuid = readProcChanceGuid(); + if (packet.getSize() - packet.getReadPos() < (procChanceUsesFullGuid ? 8u : 1u) + || (!procChanceUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t procCasterGuid = readProcChanceGuid(); + if (packet.getSize() - packet.getReadPos() < 4) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t procSpellId = packet.readUInt32(); + // Show a "PROC!" floating text when the player triggers the proc + if (procCasterGuid == playerGuid && procSpellId > 0) + addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0, + procCasterGuid, procTargetGuid); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELLINSTAKILLLOG: { + // Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.) + // WotLK/Classic/Turtle: packed_guid caster + packed_guid victim + uint32 spellId + // TBC: full uint64 caster + full uint64 victim + uint32 spellId + const bool ikUsesFullGuid = isActiveExpansion("tbc"); + auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) + || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t ikCaster = ikUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (ik_rem() < (ikUsesFullGuid ? 8u : 1u) + || (!ikUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t ikVictim = ikUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (ik_rem() < 4) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t ikSpell = packet.readUInt32(); + // Show kill/death feedback for the local player + if (ikCaster == playerGuid) { + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); + } else if (ikVictim == playerGuid) { + addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); + addUIError("You were killed by an instant-kill effect."); + addSystemChatMessage("You were killed by an instant-kill effect."); + } + LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, + " victim=0x", ikVictim, std::dec, " spell=", ikSpell); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELLLOGEXECUTE: { + // WotLK/Classic/Turtle: packed_guid caster + uint32 spellId + uint32 effectCount + // TBC: uint64 caster + uint32 spellId + uint32 effectCount + // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data + // Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier + // Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier + // Effect 24 = CREATE_ITEM: uint32 itemEntry + // Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id + // Effect 49 = FEED_PET: uint32 itemEntry + // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) + const bool exeUsesFullGuid = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + if (!exeUsesFullGuid && !hasFullPackedGuid(packet)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t exeCaster = exeUsesFullGuid + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t exeSpellId = packet.readUInt32(); + uint32_t exeEffectCount = packet.readUInt32(); + exeEffectCount = std::min(exeEffectCount, 32u); // sanity + + const bool isPlayerCaster = (exeCaster == playerGuid); + for (uint32_t ei = 0; ei < exeEffectCount; ++ei) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint8_t effectType = packet.readUInt8(); + uint32_t effectLogCount = packet.readUInt32(); + effectLogCount = std::min(effectLogCount, 64u); // sanity + if (effectType == 10) { + // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t drainTarget = exeUsesFullGuid + ? packet.readUInt64() + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } + uint32_t drainAmount = packet.readUInt32(); + uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic + float drainMult = packet.readFloat(); + if (drainAmount > 0) { + if (drainTarget == playerGuid) + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, + static_cast(drainPower), + exeCaster, drainTarget); + if (isPlayerCaster) { + if (drainTarget != playerGuid) { + addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, drainTarget); + } + if (drainMult > 0.0f && std::isfinite(drainMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(drainAmount) * static_cast(drainMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::ENERGIZE, static_cast(gainedAmount), exeSpellId, true, + static_cast(drainPower), exeCaster, exeCaster); + } + } + } + } + LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, + " power=", drainPower, " amount=", drainAmount, + " multiplier=", drainMult); + } + } else if (effectType == 11) { + // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t leechTarget = exeUsesFullGuid + ? packet.readUInt64() + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } + uint32_t leechAmount = packet.readUInt32(); + float leechMult = packet.readFloat(); + if (leechAmount > 0) { + if (leechTarget == playerGuid) { + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, + exeCaster, leechTarget); + } else if (isPlayerCaster) { + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, true, 0, + exeCaster, leechTarget); + } + if (isPlayerCaster && leechMult > 0.0f && std::isfinite(leechMult)) { + const uint32_t gainedAmount = static_cast( + std::lround(static_cast(leechAmount) * static_cast(leechMult))); + if (gainedAmount > 0) { + addCombatText(CombatTextEntry::HEAL, static_cast(gainedAmount), exeSpellId, true, 0, + exeCaster, exeCaster); + } + } + } + LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, + " amount=", leechAmount, " multiplier=", leechMult); + } + } else if (effectType == 24 || effectType == 114) { + // SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t itemEntry = packet.readUInt32(); + if (isPlayerCaster && itemEntry != 0) { + ensureItemInfo(itemEntry); + const ItemQueryResponseData* info = getItemInfo(itemEntry); + std::string itemName = info && !info->name.empty() + ? info->name : ("item #" + std::to_string(itemEntry)); + loadSpellNameCache(); + auto spellIt = spellNameCache_.find(exeSpellId); + std::string spellName = (spellIt != spellNameCache_.end() && !spellIt->second.name.empty()) + ? spellIt->second.name : ""; + std::string msg = spellName.empty() + ? ("You create: " + itemName + ".") + : ("You create " + itemName + " using " + spellName + "."); + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId, + " item=", itemEntry, " name=", itemName); + + // Repeat-craft queue: re-cast if more crafts remaining + if (craftQueueRemaining_ > 0 && craftQueueSpellId_ == exeSpellId) { + --craftQueueRemaining_; + if (craftQueueRemaining_ > 0) { + castSpell(craftQueueSpellId_, 0); + } else { + craftQueueSpellId_ = 0; + } + } + } + } + } else if (effectType == 26) { + // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < (exeUsesFullGuid ? 8u : 1u) + || (!exeUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t icTarget = exeUsesFullGuid + ? packet.readUInt64() + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } + uint32_t icSpellId = packet.readUInt32(); + // Clear the interrupted unit's cast bar immediately + unitCastStates_.erase(icTarget); + // Record interrupt in combat log when player is involved + if (isPlayerCaster || icTarget == playerGuid) + addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0, + exeCaster, icTarget); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, + " interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec); + } + } else if (effectType == 49) { + // SPELL_EFFECT_FEED_PET: uint32 itemEntry per log entry + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t feedItem = packet.readUInt32(); + if (isPlayerCaster && feedItem != 0) { + ensureItemInfo(feedItem); + const ItemQueryResponseData* info = getItemInfo(feedItem); + std::string itemName = info && !info->name.empty() + ? info->name : ("item #" + std::to_string(feedItem)); + uint32_t feedQuality = info ? info->quality : 1u; + addSystemChatMessage("You feed your pet " + buildItemLink(feedItem, feedQuality, itemName) + "."); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName); + } + } + } else { + // Unknown effect type — stop parsing to avoid misalignment + packet.setReadPos(packet.getSize()); + break; + } + } packet.setReadPos(packet.getSize()); break; } - case Opcode::SMSG_SPELLINSTAKILLLOG: - case Opcode::SMSG_SPELLLOGEXECUTE: - case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); @@ -4936,11 +7399,69 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Misc consume ---- - case Opcode::SMSG_COMPLAIN_RESULT: + case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: { + // Format: uint64 itemGuid + uint32 slot + uint32 durationSec + uint64 playerGuid + // slot: 0=main-hand, 1=off-hand, 2=ranged + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t itemGuid =*/ packet.readUInt64(); + uint32_t enchSlot = packet.readUInt32(); + uint32_t durationSec = packet.readUInt32(); + /*uint64_t playerGuid =*/ packet.readUInt64(); + + // Clamp to known slots (0-2) + if (enchSlot > 2) { break; } + + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + if (durationSec == 0) { + // Enchant expired / removed — erase the slot entry + tempEnchantTimers_.erase( + std::remove_if(tempEnchantTimers_.begin(), tempEnchantTimers_.end(), + [enchSlot](const TempEnchantTimer& t) { return t.slot == enchSlot; }), + tempEnchantTimers_.end()); + } else { + uint64_t expireMs = nowMs + static_cast(durationSec) * 1000u; + bool found = false; + for (auto& t : tempEnchantTimers_) { + if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; } + } + if (!found) tempEnchantTimers_.push_back({enchSlot, expireMs}); + + // Warn at important thresholds + if (durationSec <= 60 && durationSec > 55) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 1 minute!", slotName); + addSystemChatMessage(buf); + } else if (durationSec <= 300 && durationSec > 295) { + const char* slotName = (enchSlot < 3) ? kTempEnchantSlotNames[enchSlot] : "weapon"; + char buf[80]; + std::snprintf(buf, sizeof(buf), "Weapon enchant (%s) expires in 5 minutes.", slotName); + addSystemChatMessage(buf); + } + } + LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); + break; + } + case Opcode::SMSG_COMPLAIN_RESULT: { + // uint8 result: 0=success, 1=failed, 2=disabled + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("Your complaint has been submitted."); + else if (result == 2) + addUIError("Report a Player is currently disabled."); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: - case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: - // Consume — not yet processed + // Consume silently — informational, no UI action needed packet.setReadPos(packet.getSize()); break; @@ -4962,6 +7483,7 @@ void GameHandler::handlePacket(network::Packet& packet) { if (totalMs > 0) { if (caster == playerGuid) { casting = true; + castIsChannel = false; currentCastSpellId = spellId; castTimeTotal = totalMs / 1000.0f; castTimeRemaining = remainMs / 1000.0f; @@ -4990,18 +7512,31 @@ void GameHandler::handlePacket(network::Packet& packet) { if (chanTotalMs > 0 && chanCaster != 0) { if (chanCaster == playerGuid) { casting = true; + castIsChannel = true; currentCastSpellId = chanSpellId; castTimeTotal = chanTotalMs / 1000.0f; castTimeRemaining = castTimeTotal; } else { auto& s = unitCastStates_[chanCaster]; - s.casting = true; - s.spellId = chanSpellId; - s.timeTotal = chanTotalMs / 1000.0f; - s.timeRemaining = s.timeTotal; + s.casting = true; + s.isChannel = true; + s.spellId = chanSpellId; + s.timeTotal = chanTotalMs / 1000.0f; + s.timeRemaining = s.timeTotal; + s.interruptible = isSpellInterruptible(chanSpellId); } LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, " spell=", chanSpellId, " total=", chanTotalMs, "ms"); + // Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (chanCaster == playerGuid) unitId = "player"; + else if (chanCaster == targetGuid) unitId = "target"; + else if (chanCaster == focusGuid) unitId = "focus"; + else if (chanCaster == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)}); + } } break; } @@ -5017,6 +7552,7 @@ void GameHandler::handlePacket(network::Packet& packet) { castTimeRemaining = chanRemainMs / 1000.0f; if (chanRemainMs == 0) { casting = false; + castIsChannel = false; currentCastSpellId = 0; } } else if (chanCaster2 != 0) { @@ -5028,25 +7564,19 @@ void GameHandler::handlePacket(network::Packet& packet) { } LOG_DEBUG("MSG_CHANNEL_UPDATE: caster=0x", std::hex, chanCaster2, std::dec, " remaining=", chanRemainMs, "ms"); - break; - } - - case Opcode::SMSG_THREAT_UPDATE: { - // packed_guid (unit) + packed_guid (target) + uint32 count - // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings - if (packet.getSize() - packet.getReadPos() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 1) break; - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 4) break; - uint32_t cnt = packet.readUInt32(); - for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) { - (void)UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() >= 4) - packet.readUInt32(); + // Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends + if (chanRemainMs == 0 && addonEventCallback_) { + std::string unitId; + if (chanCaster2 == playerGuid) unitId = "player"; + else if (chanCaster2 == targetGuid) unitId = "target"; + else if (chanCaster2 == focusGuid) unitId = "focus"; + else if (chanCaster2 == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_CHANNEL_STOP", {unitId}); } break; } + case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: { // uint32 slot + packed_guid unit (0 packed = clear slot) if (packet.getSize() - packet.getReadPos() < 5) { @@ -5108,35 +7638,70 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_WALK_SPEED: case Opcode::SMSG_SPLINE_SET_TURN_RATE: - case Opcode::SMSG_SPLINE_SET_PITCH_RATE: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SPLINE_SET_PITCH_RATE: { + // Minimal parse: PackedGuid + float speed + if (packet.getSize() - packet.getReadPos() < 5) break; + uint64_t sGuid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + float sSpeed = packet.readFloat(); + if (sGuid == playerGuid && std::isfinite(sSpeed) && sSpeed > 0.01f && sSpeed < 200.0f) { + if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_SPEED) + serverFlightSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_FLIGHT_BACK_SPEED) + serverFlightBackSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_SWIM_BACK_SPEED) + serverSwimBackSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_WALK_SPEED) + serverWalkSpeed_ = sSpeed; + else if (*logicalOp == Opcode::SMSG_SPLINE_SET_TURN_RATE) + serverTurnRate_ = sSpeed; // rad/s + } break; + } // ---- Spline move flag changes for other units ---- case Opcode::SMSG_SPLINE_MOVE_UNROOT: - case Opcode::SMSG_SPLINE_MOVE_UNSET_FLYING: case Opcode::SMSG_SPLINE_MOVE_UNSET_HOVER: - case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SPLINE_MOVE_WATER_WALK: { + // 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_UNSET_FLYING: { + // PackedGuid + synthesised move-flags=0 → clears flying animation. + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; + unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY + break; + } // ---- Quest failure notification ---- case Opcode::SMSG_QUESTGIVER_QUEST_FAILED: { // uint32 questId + uint32 reason if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t questId =*/ packet.readUInt32(); + uint32_t questId = packet.readUInt32(); uint32_t reason = packet.readUInt32(); - const char* reasonStr = "Unknown reason"; + std::string questTitle; + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) { questTitle = q.title; break; } + const char* reasonStr = nullptr; switch (reason) { - case 1: reasonStr = "Quest failed: failed conditions"; break; - case 2: reasonStr = "Quest failed: inventory full"; break; - case 3: reasonStr = "Quest failed: too far away"; break; - case 4: reasonStr = "Quest failed: another quest is blocking"; break; - case 5: reasonStr = "Quest failed: wrong time of day"; break; - case 6: reasonStr = "Quest failed: wrong race"; break; - case 7: reasonStr = "Quest failed: wrong class"; break; + case 1: reasonStr = "failed conditions"; break; + case 2: reasonStr = "inventory full"; break; + case 3: reasonStr = "too far away"; break; + case 4: reasonStr = "another quest is blocking"; break; + case 5: reasonStr = "wrong time of day"; break; + case 6: reasonStr = "wrong race"; break; + case 7: reasonStr = "wrong class"; break; } - addSystemChatMessage(reasonStr); + std::string msg = questTitle.empty() ? "Quest" : ('"' + questTitle + '"'); + msg += " failed"; + if (reasonStr) msg += std::string(": ") + reasonStr; + msg += '.'; + addSystemChatMessage(msg); } break; } @@ -5156,8 +7721,15 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Pre-resurrect state ---- case Opcode::SMSG_PRE_RESURRECT: { - // packed GUID of the player to enter pre-resurrect - (void)UpdateObjectParser::readPackedGuid(packet); + // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. + // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), + // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. + uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (targetGuid == playerGuid || targetGuid == 0) { + selfResAvailable_ = true; + LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x", + std::hex, targetGuid, std::dec, ")"); + } break; } @@ -5165,16 +7737,20 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PLAYERBINDERROR: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t error = packet.readUInt32(); - if (error == 0) + if (error == 0) { + addUIError("Your hearthstone is not bound."); addSystemChatMessage("Your hearthstone is not bound."); - else + } else { + addUIError("Hearthstone bind failed."); addSystemChatMessage("Hearthstone bind failed."); + } } break; } // ---- Instance/raid errors ---- case Opcode::SMSG_RAID_GROUP_ONLY: { + addUIError("You must be in a raid group to enter this instance."); addSystemChatMessage("You must be in a raid group to enter this instance."); packet.setReadPos(packet.getSize()); break; @@ -5182,13 +7758,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_RAID_READY_CHECK_ERROR: { if (packet.getSize() - packet.getReadPos() >= 1) { uint8_t err = packet.readUInt8(); - if (err == 0) addSystemChatMessage("Ready check failed: not in a group."); - else if (err == 1) addSystemChatMessage("Ready check failed: in instance."); - else addSystemChatMessage("Ready check failed."); + if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); } + else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); } + else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); } } break; } case Opcode::SMSG_RESET_FAILED_NOTIFY: { + addUIError("Cannot reset instance: another player is still inside."); addSystemChatMessage("Cannot reset instance: another player is still inside."); packet.setReadPos(packet.getSize()); break; @@ -5212,10 +7789,39 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } - // ---- Real group update (status flags) ---- - case Opcode::SMSG_REAL_GROUP_UPDATE: - packet.setReadPos(packet.getSize()); + // ---- Real group update (group type, local player flags, leader) ---- + // Sent when the player's group configuration changes: group type, + // role/flags (assistant/MT/MA), or leader changes. + // Format: uint8 groupType | uint32 memberFlags | uint64 leaderGuid + case Opcode::SMSG_REAL_GROUP_UPDATE: { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) break; + uint8_t newGroupType = packet.readUInt8(); + if (rem() < 4) break; + uint32_t newMemberFlags = packet.readUInt32(); + if (rem() < 8) break; + uint64_t newLeaderGuid = packet.readUInt64(); + + partyData.groupType = newGroupType; + partyData.leaderGuid = newLeaderGuid; + + // Update local player's flags in the member list + uint64_t localGuid = playerGuid; + for (auto& m : partyData.members) { + if (m.guid == localGuid) { + m.flags = static_cast(newMemberFlags & 0xFF); + break; + } + } + LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast(newGroupType), + " memberFlags=0x", std::hex, newMemberFlags, std::dec, + " leaderGuid=", newLeaderGuid); + if (addonEventCallback_) { + addonEventCallback_("PARTY_LEADER_CHANGED", {}); + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + } break; + } // ---- Play music (WotLK standard opcode) ---- case Opcode::SMSG_PLAY_MUSIC: { @@ -5228,12 +7834,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Play object/spell sounds ---- case Opcode::SMSG_PLAY_OBJECT_SOUND: - case Opcode::SMSG_PLAY_SPELL_IMPACT: if (packet.getSize() - packet.getReadPos() >= 12) { // uint32 soundId + uint64 sourceGuid - uint32_t soundId = packet.readUInt32(); - uint64_t srcGuid = packet.readUInt64(); - LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND/SPELL_IMPACT id=", soundId, " src=0x", std::hex, srcGuid, std::dec); + uint32_t soundId = packet.readUInt32(); + uint64_t srcGuid = packet.readUInt64(); + LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec); if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); else if (playSoundCallback_) playSoundCallback_(soundId); } else if (packet.getSize() - packet.getReadPos() >= 4) { @@ -5242,31 +7847,66 @@ void GameHandler::handlePacket(network::Packet& packet) { } packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PLAY_SPELL_IMPACT: { + // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t impTargetGuid = packet.readUInt64(); + uint32_t impVisualId = packet.readUInt32(); + if (impVisualId == 0) break; + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) break; + glm::vec3 spawnPos; + if (impTargetGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityManager.getEntity(impTargetGuid); + if (!entity) break; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true); + break; + } // ---- Resistance/combat log ---- case Opcode::SMSG_RESISTLOG: { - // WotLK: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId - // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... - // TBC/Classic: same but full uint64 GUIDs + // WotLK/Classic/Turtle: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId + // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... + // TBC: same layout but full uint64 GUIDs // Show RESIST combat text when player resists an incoming spell. - const bool rlTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const bool rlUsesFullGuid = isActiveExpansion("tbc"); auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } /*uint32_t hitInfo =*/ packet.readUInt32(); - if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t attackerGuid = rlTbcLike + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) + || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t attackerGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); - if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = rlTbcLike + if (rl_rem() < (rlUsesFullGuid ? 8u : 1u) + || (!rlUsesFullGuid && !hasFullPackedGuid(packet))) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = rlUsesFullGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); - (void)attackerGuid; - // Show RESIST when player is the victim; show as caster-side MISS when player is attacker - if (victimGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, false); - } else if (attackerGuid == playerGuid) { - addCombatText(CombatTextEntry::MISS, 0, spellId, true); + // Resist payload includes: + // float resistFactor + uint32 targetResistance + uint32 resistedValue. + // Require the full payload so truncated packets cannot synthesize + // zero-value resist events. + if (rl_rem() < 12) { packet.setReadPos(packet.getSize()); break; } + /*float resistFactor =*/ packet.readFloat(); + /*uint32_t targetRes =*/ packet.readUInt32(); + int32_t resistedAmount = static_cast(packet.readUInt32()); + // Show RESIST when the player is involved on either side. + if (resistedAmount > 0 && victimGuid == playerGuid) { + addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, false, 0, attackerGuid, victimGuid); + } else if (resistedAmount > 0 && attackerGuid == playerGuid) { + addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, true, 0, attackerGuid, victimGuid); } packet.setReadPos(packet.getSize()); break; @@ -5274,10 +7914,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: - addSystemChatMessage("You read the item."); + bookPages_.clear(); // fresh book for this item read packet.setReadPos(packet.getSize()); break; case Opcode::SMSG_READ_ITEM_FAILED: + addUIError("You cannot read this item."); addSystemChatMessage("You cannot read this item."); packet.setReadPos(packet.getSize()); break; @@ -5301,21 +7942,51 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- PVP quest kill update ---- case Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL: { - // uint64 guid + uint32 questId + uint32 killCount + // WotLK 3.3.5a format: uint64 guid + uint32 questId + uint32 count + uint32 reqCount + // Classic format: uint64 guid + uint32 questId + uint32 count (no reqCount) if (packet.getSize() - packet.getReadPos() >= 16) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t questId = packet.readUInt32(); uint32_t count = packet.readUInt32(); - char buf[64]; - std::snprintf(buf, sizeof(buf), "PVP kill counted for quest #%u (%u).", - questId, count); - addSystemChatMessage(buf); + uint32_t reqCount = 0; + if (packet.getSize() - packet.getReadPos() >= 4) { + reqCount = packet.readUInt32(); + } + + // Update quest log kill counts (PvP kills use entry=0 as the key + // since there's no specific creature entry — one slot per quest). + constexpr uint32_t PVP_KILL_ENTRY = 0u; + for (auto& quest : questLog_) { + if (quest.questId != questId) continue; + + if (reqCount == 0) { + auto it = quest.killCounts.find(PVP_KILL_ENTRY); + if (it != quest.killCounts.end()) reqCount = it->second.second; + } + if (reqCount == 0) { + // Pull required count from kill objectives (npcOrGoId == 0 slot, if any) + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId == 0 && obj.required > 0) { + reqCount = obj.required; + break; + } + } + } + if (reqCount == 0) reqCount = count; + quest.killCounts[PVP_KILL_ENTRY] = {count, reqCount}; + + std::string progressMsg = quest.title + ": PvP kills " + + std::to_string(count) + "/" + std::to_string(reqCount); + addSystemChatMessage(progressMsg); + break; + } } break; } // ---- NPC not responding ---- case Opcode::SMSG_NPC_WONT_TALK: + addUIError("That creature can't talk to you right now."); addSystemChatMessage("That creature can't talk to you right now."); packet.setReadPos(packet.getSize()); break; @@ -5331,9 +8002,13 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PETITION_QUERY_RESPONSE: + handlePetitionQueryResponse(packet); + break; case Opcode::SMSG_PETITION_SHOW_SIGNATURES: + handlePetitionShowSignatures(packet); + break; case Opcode::SMSG_PETITION_SIGN_RESULTS: - packet.setReadPos(packet.getSize()); + handlePetitionSignResults(packet); break; // ---- Pet system ---- @@ -5367,7 +8042,10 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t spellId = packet.readUInt32(); petSpellList_.push_back(spellId); + const std::string& sname = getSpellName(spellId); + addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + ".")); LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); + if (addonEventCallback_) addonEventCallback_("PET_BAR_UPDATE", {}); } packet.setReadPos(packet.getSize()); break; @@ -5385,13 +8063,27 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_PET_CAST_FAILED: { - if (packet.getSize() - packet.getReadPos() >= 5) { - uint8_t castCount = packet.readUInt8(); + // WotLK: castCount(1) + spellId(4) + reason(1) + // Classic/TBC: spellId(4) + reason(1) (no castCount) + const bool hasCount = isActiveExpansion("wotlk"); + const size_t minSize = hasCount ? 6u : 5u; + if (packet.getSize() - packet.getReadPos() >= minSize) { + if (hasCount) /*uint8_t castCount =*/ packet.readUInt8(); uint32_t spellId = packet.readUInt32(); - uint32_t reason = (packet.getSize() - packet.getReadPos() >= 4) - ? packet.readUInt32() : 0; + uint8_t reason = (packet.getSize() - packet.getReadPos() >= 1) + ? packet.readUInt8() : 0; LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, - " reason=", reason, " castCount=", (int)castCount); + " reason=", (int)reason); + if (reason != 0) { + const char* reasonStr = getSpellCastResultString(reason); + const std::string& sName = getSpellName(spellId); + std::string errMsg; + if (reasonStr && *reasonStr) + errMsg = sName.empty() ? reasonStr : (sName + ": " + reasonStr); + else + errMsg = sName.empty() ? "Pet spell failed." : (sName + ": Pet spell failed."); + addSystemChatMessage(errMsg); + } } packet.setReadPos(packet.getSize()); break; @@ -5399,17 +8091,87 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_PET_GUIDS: case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: - case Opcode::SMSG_PET_UNLEARN_CONFIRM: - case Opcode::SMSG_PET_NAME_INVALID: - case Opcode::SMSG_PET_RENAMEABLE: + case Opcode::SMSG_PET_UNLEARN_CONFIRM: { + // uint64 petGuid + uint32 cost (copper) + if (packet.getSize() - packet.getReadPos() >= 12) { + petUnlearnGuid_ = packet.readUInt64(); + petUnlearnCost_ = packet.readUInt32(); + petUnlearnPending_ = true; + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_PET_UPDATE_COMBO_POINTS: packet.setReadPos(packet.getSize()); break; - - // ---- Inspect (full character inspection) ---- - case Opcode::SMSG_INSPECT: + case Opcode::SMSG_PET_RENAMEABLE: + // Server signals that the pet can now be named (first tame) + petRenameablePending_ = true; packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PET_NAME_INVALID: + addUIError("That pet name is invalid. Please choose a different name."); + addSystemChatMessage("That pet name is invalid. Please choose a different name."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Inspect (Classic 1.12 gear inspection) ---- + case Opcode::SMSG_INSPECT: { + // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) + // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to + // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. + if (packet.getSize() - packet.getReadPos() < 2) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0) { packet.setReadPos(packet.getSize()); break; } + + constexpr int kGearSlots = 19; + size_t needed = kGearSlots * sizeof(uint32_t); + if (packet.getSize() - packet.getReadPos() < needed) { + packet.setReadPos(packet.getSize()); break; + } + + std::array items{}; + for (int s = 0; s < kGearSlots; ++s) + items[s] = packet.readUInt32(); + + // Resolve player name + auto ent = entityManager.getEntity(guid); + std::string playerName = "Target"; + if (ent) { + auto pl = std::dynamic_pointer_cast(ent); + if (pl && !pl->getName().empty()) playerName = pl->getName(); + } + + // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = 0; + inspectResult_.unspentTalents = 0; + inspectResult_.talentGroups = 0; + inspectResult_.activeTalentGroup = 0; + inspectResult_.itemEntries = items; + inspectResult_.enchantIds = {}; + + // Also cache for future talent-inspect cross-reference + inspectedPlayerItemEntries_[guid] = items; + + // Trigger item queries for non-empty slots + for (int s = 0; s < kGearSlots; ++s) { + if (items[s] != 0) queryItemInfo(items[s], 0); + } + + LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", + std::count_if(items.begin(), items.end(), + [](uint32_t e) { return e != 0; }), "/19 slots"); + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); + addonEventCallback_("INSPECT_READY", {guidBuf}); + } + break; + } // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: @@ -5426,6 +8188,7 @@ void GameHandler::handlePacket(network::Packet& packet) { size_t dataLen = pdata.size(); size_t pos = packet.getReadPos(); static uint32_t multiPktWarnCount = 0; + std::vector subPackets; while (pos + 4 <= dataLen) { uint16_t subSize = static_cast( (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); @@ -5442,32 +8205,115 @@ void GameHandler::handlePacket(network::Packet& packet) { (static_cast(pdata[pos + 3]) << 8); std::vector subPayload(pdata.begin() + pos + 4, pdata.begin() + pos + 4 + payloadLen); - network::Packet subPacket(subOpcode, std::move(subPayload)); - handlePacket(subPacket); + subPackets.emplace_back(subOpcode, std::move(subPayload)); pos += 4 + payloadLen; } + for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) { + enqueueIncomingPacketFront(std::move(*it)); + } packet.setReadPos(packet.getSize()); break; } - // ---- Misc consume ---- + // ---- Misc consume (no state change needed) ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: - case Opcode::SMSG_PROPOSE_LEVEL_GRANT: - case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: - case Opcode::SMSG_REFER_A_FRIEND_FAILURE: - case Opcode::SMSG_REPORT_PVP_AFK_RESULT: case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: - case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: - case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: - case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: - case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: - case Opcode::SMSG_PROFILEDATA_RESPONSE: - case Opcode::SMSG_PLAY_TIME_WARNING: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_PROPOSE_LEVEL_GRANT: { + // Recruit-A-Friend: a mentor is offering to grant you a level + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t mentorGuid = packet.readUInt64(); + std::string mentorName; + auto ent = entityManager.getEntity(mentorGuid); + if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); + if (mentorName.empty()) { + auto nit = playerNameCache.find(mentorGuid); + if (nit != playerNameCache.end()) mentorName = nit->second; + } + addSystemChatMessage(mentorName.empty() + ? "A player is offering to grant you a level." + : (mentorName + " is offering to grant you a level.")); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: + addSystemChatMessage("Your Recruit-A-Friend link has expired."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_REFER_A_FRIEND_FAILURE: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t reason = packet.readUInt32(); + static const char* kRafErrors[] = { + "Not eligible", // 0 + "Target not eligible", // 1 + "Too many referrals", // 2 + "Wrong faction", // 3 + "Not a recruit", // 4 + "Recruit requirements not met", // 5 + "Level above requirement", // 6 + "Friend needs account upgrade", // 7 + }; + const char* msg = (reason < 8) ? kRafErrors[reason] + : "Recruit-A-Friend failed."; + addSystemChatMessage(std::string("Recruit-A-Friend: ") + msg); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_REPORT_PVP_AFK_RESULT: { + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("AFK report submitted."); + else + addSystemChatMessage("Cannot report that player as AFK right now."); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: + handleRespondInspectAchievements(packet); + break; + case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: + handleQuestPoiQueryResponse(packet); + break; + case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: + vehicleId_ = 0; // Vehicle ride cancelled; clear UI + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: + case Opcode::SMSG_PROFILEDATA_RESPONSE: + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_PLAY_TIME_WARNING: { + // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t warnType = packet.readUInt32(); + uint32_t minutesPlayed = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readUInt32() : 0; + const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] "; + char buf[128]; + if (minutesPlayed > 0) { + uint32_t h = minutesPlayed / 60; + uint32_t m = minutesPlayed % 60; + if (h > 0) + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m); + else + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m); + } else { + std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity); + } + addSystemChatMessage(buf); + addUIError(buf); + } + break; + } // ---- Item query multiple (same format as single, re-use handler) ---- case Opcode::SMSG_ITEM_QUERY_MULTIPLE_RESPONSE: @@ -5481,16 +8327,566 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- Mirror image data (WotLK: Mage ability Mirror Image) ---- + case Opcode::SMSG_MIRRORIMAGE_DATA: { + // WotLK 3.3.5a format: + // uint64 mirrorGuid — GUID of the mirror image unit + // uint32 displayId — display ID to render the image with + // uint8 raceId — race of caster + // uint8 genderFlag — gender of caster + // uint8 classId — class of caster + // uint64 casterGuid — GUID of the player who cast the spell + // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 + // Purpose: tells client how to render the image (same appearance as caster). + // We parse the GUIDs so units render correctly via their existing display IDs. + if (packet.getSize() - packet.getReadPos() < 8) break; + uint64_t mirrorGuid = packet.readUInt64(); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t displayId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 3) break; + /*uint8_t raceId =*/ packet.readUInt8(); + /*uint8_t gender =*/ packet.readUInt8(); + /*uint8_t classId =*/ packet.readUInt8(); + // Apply display ID to the mirror image unit so it renders correctly + if (mirrorGuid != 0 && displayId != 0) { + auto entity = entityManager.getEntity(mirrorGuid); + if (entity) { + auto unit = std::dynamic_pointer_cast(entity); + if (unit && unit->getDisplayId() == 0) + unit->setDisplayId(displayId); + } + } + LOG_DEBUG("SMSG_MIRRORIMAGE_DATA: mirrorGuid=0x", std::hex, mirrorGuid, + " displayId=", std::dec, displayId); + packet.setReadPos(packet.getSize()); + break; + } + // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: + handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, + static_cast(MovementFlags::LEVITATING), true); + break; case Opcode::SMSG_MOVE_GRAVITY_ENABLE: + handleForceMoveFlagChange(packet, "GRAVITY_ENABLE", Opcode::CMSG_MOVE_GRAVITY_ENABLE_ACK, + static_cast(MovementFlags::LEVITATING), false); + break; case Opcode::SMSG_MOVE_LAND_WALK: + handleForceMoveFlagChange(packet, "LAND_WALK", Opcode::CMSG_MOVE_WATER_WALK_ACK, + static_cast(MovementFlags::WATER_WALK), false); + break; case Opcode::SMSG_MOVE_NORMAL_FALL: + handleForceMoveFlagChange(packet, "NORMAL_FALL", Opcode::CMSG_MOVE_FEATHER_FALL_ACK, + static_cast(MovementFlags::FEATHER_FALL), false); + break; case Opcode::SMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + handleForceMoveFlagChange(packet, "SET_CAN_TRANSITION_SWIM_FLY", + Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, true); + break; case Opcode::SMSG_MOVE_UNSET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY: + handleForceMoveFlagChange(packet, "UNSET_CAN_TRANSITION_SWIM_FLY", + Opcode::CMSG_MOVE_SET_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY_ACK, 0, false); + break; case Opcode::SMSG_MOVE_SET_COLLISION_HGT: + handleMoveSetCollisionHeight(packet); + break; case Opcode::SMSG_MOVE_SET_FLIGHT: + handleForceMoveFlagChange(packet, "SET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, + static_cast(MovementFlags::FLYING), true); + break; case Opcode::SMSG_MOVE_UNSET_FLIGHT: + handleForceMoveFlagChange(packet, "UNSET_FLIGHT", Opcode::CMSG_MOVE_FLIGHT_ACK, + static_cast(MovementFlags::FLYING), false); + break; + + // ---- Battlefield Manager (WotLK outdoor battlefields: Wintergrasp, Tol Barad) ---- + case Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: { + // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) + if (packet.getSize() - packet.getReadPos() < 20) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t bfGuid = packet.readUInt64(); + uint32_t bfZoneId = packet.readUInt32(); + uint64_t expireTime = packet.readUInt64(); + (void)bfGuid; (void)expireTime; + // Store the invitation so the UI can show a prompt + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfZoneId; + char buf[128]; + std::string bfZoneName = getAreaName(bfZoneId); + if (!bfZoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in %s. Click to enter.", + bfZoneName.c_str()); + else + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in zone %u. Click to enter.", + bfZoneId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_ENTERED: { + // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t bfGuid2 = packet.readUInt64(); + (void)bfGuid2; + uint8_t isSafe = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + uint8_t onQueue = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + bfMgrInvitePending_ = false; + bfMgrActive_ = true; + addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)." + : "You have entered the battlefield!"); + if (onQueue) addSystemChatMessage("You are in the battlefield queue."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", (int)isSafe, " onQueue=", (int)onQueue); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: { + // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime + if (packet.getSize() - packet.getReadPos() < 20) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t bfGuid3 = packet.readUInt64(); + uint32_t bfId = packet.readUInt32(); + uint64_t expTime = packet.readUInt64(); + (void)bfGuid3; (void)expTime; + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfId; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "A spot has opened in the battlefield queue (battlefield %u).", bfId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: { + // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result + // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, + // 4=in_cooldown, 5=queued_other_bf, 6=bf_full + if (packet.getSize() - packet.getReadPos() < 11) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t bfId2 = packet.readUInt32(); + /*uint32_t teamId =*/ packet.readUInt32(); + uint8_t accepted = packet.readUInt8(); + /*uint8_t logging =*/ packet.readUInt8(); + uint8_t result = packet.readUInt8(); + (void)bfId2; + if (accepted) { + addSystemChatMessage("You have joined the battlefield queue."); + } else { + static const char* kBfQueueErrors[] = { + "Queued for battlefield.", "Not in a group.", "Level too high.", + "Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.", + "Battlefield is full." + }; + const char* msg = (result < 7) ? kBfQueueErrors[result] + : "Battlefield queue request failed."; + addSystemChatMessage(std::string("Battlefield: ") + msg); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", (int)accepted, + " result=", (int)result); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING: { + // uint64 battlefieldGuid + uint8 remove + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t bfGuid4 = packet.readUInt64(); + uint8_t remove = packet.readUInt8(); + (void)bfGuid4; + if (remove) { + addSystemChatMessage("You will be removed from the battlefield shortly."); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", (int)remove); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_EJECTED: { + // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated + if (packet.getSize() - packet.getReadPos() >= 17) { + uint64_t bfGuid5 = packet.readUInt64(); + uint32_t reason = packet.readUInt32(); + /*uint32_t status =*/ packet.readUInt32(); + uint8_t relocated = packet.readUInt8(); + (void)bfGuid5; + static const char* kEjectReasons[] = { + "Removed from battlefield.", "Transported from battlefield.", + "Left battlefield voluntarily.", "Offline.", + }; + const char* msg = (reason < 4) ? kEjectReasons[reason] + : "You have been ejected from the battlefield."; + addSystemChatMessage(msg); + if (relocated) addSystemChatMessage("You have been relocated outside the battlefield."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", (int)relocated); + } + bfMgrActive_ = false; + bfMgrInvitePending_ = false; + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE: { + // uint32 oldState + uint32 newState + // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t oldState =*/ packet.readUInt32(); + uint32_t newState = packet.readUInt32(); + static const char* kBfStates[] = { + "waiting", "starting", "in progress", "ending", "in cooldown" + }; + const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state"; + char buf[128]; + std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState); + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- WotLK Calendar system (pending invites, event notifications, command results) ---- + case Opcode::SMSG_CALENDAR_SEND_NUM_PENDING: { + // uint32 numPending — number of unacknowledged calendar invites + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t numPending = packet.readUInt32(); + calendarPendingInvites_ = numPending; + if (numPending > 0) { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "You have %u pending calendar invite%s.", + numPending, numPending == 1 ? "" : "s"); + addSystemChatMessage(buf); + } + LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites"); + } + break; + } + case Opcode::SMSG_CALENDAR_COMMAND_RESULT: { + // uint32 command + uint8 result + cstring info + // result 0 = success; non-zero = error code + // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, + // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + /*uint32_t command =*/ packet.readUInt32(); + uint8_t result = packet.readUInt8(); + std::string info = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + if (result != 0) { + // Map common calendar error codes to friendly strings + static const char* kCalendarErrors[] = { + "", + "Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL + "Calendar: Guild event limit reached.",// 2 + "Calendar: Event limit reached.", // 3 + "Calendar: You cannot invite that player.", // 4 + "Calendar: No invites remaining.", // 5 + "Calendar: Invalid date.", // 6 + "Calendar: Cannot invite yourself.", // 7 + "Calendar: Cannot modify this event.", // 8 + "Calendar: Not invited.", // 9 + "Calendar: Already invited.", // 10 + "Calendar: Player not found.", // 11 + "Calendar: Not enough focus.", // 12 + "Calendar: Event locked.", // 13 + "Calendar: Event deleted.", // 14 + "Calendar: Not a moderator.", // 15 + }; + const char* errMsg = (result < 16) ? kCalendarErrors[result] + : "Calendar: Command failed."; + if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg); + else if (!info.empty()) addSystemChatMessage("Calendar: " + info); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT: { + // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + + // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + + // isGuildEvent(1) + inviterGuid(8) + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t eventId =*/ packet.readUInt64(); + std::string title = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + packet.setReadPos(packet.getSize()); // consume remaining fields + if (!title.empty()) { + addSystemChatMessage("Calendar invite: " + title); + } else { + addSystemChatMessage("You have a new calendar invite."); + } + if (calendarPendingInvites_ < 255) ++calendarPendingInvites_; + LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'"); + break; + } + // Remaining calendar informational packets — parse title where possible and consume + case Opcode::SMSG_CALENDAR_EVENT_STATUS: { + // Sent when an event invite's RSVP status changes for the local player + // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + + // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) + if (packet.getSize() - packet.getReadPos() < 31) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + /*uint8_t evType =*/ packet.readUInt8(); + /*uint32_t flags =*/ packet.readUInt32(); + /*uint64_t invTime =*/ packet.readUInt64(); + uint8_t status = packet.readUInt8(); + /*uint8_t rank =*/ packet.readUInt8(); + /*uint8_t isGuild =*/ packet.readUInt8(); + std::string evTitle = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + // status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative + static const char* kRsvpStatus[] = { + "invited", "accepted", "declined", "confirmed", + "out", "on standby", "signed up", "not signed up", "tentative" + }; + const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown"; + if (!evTitle.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.", + evTitle.c_str(), statusStr); + addSystemChatMessage(buf); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED: { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime + if (packet.getSize() - packet.getReadPos() >= 28) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + /*uint64_t resetTime =*/ packet.readUInt64(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout added for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + if (packet.getSize() - packet.getReadPos() >= 20) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout removed for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, + " difficulty=", difficulty); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED: { + // Same format as LOCKOUT_ADDED; consume + packet.setReadPos(packet.getSize()); + break; + } + // Remaining calendar opcodes: safe consume — data surfaced via SEND_CALENDAR/SEND_EVENT + case Opcode::SMSG_CALENDAR_SEND_CALENDAR: + case Opcode::SMSG_CALENDAR_SEND_EVENT: + case Opcode::SMSG_CALENDAR_ARENA_TEAM: + case Opcode::SMSG_CALENDAR_FILTER_GUILD: + case Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION: + case Opcode::SMSG_CALENDAR_EVENT_INVITE: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT: + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_SERVERTIME: { + // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t srvTime = packet.readUInt32(); + if (srvTime > 0) { + gameTime_ = static_cast(srvTime); + LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime); + } + } + break; + } + + case Opcode::SMSG_KICK_REASON: { + // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string + // kickReasonType: 0=other, 1=afk, 2=vote kick + if (!packetHasRemaining(packet, 12)) { + packet.setReadPos(packet.getSize()); + break; + } + uint64_t kickerGuid = packet.readUInt64(); + uint32_t reasonType = packet.readUInt32(); + std::string reason; + if (packet.getReadPos() < packet.getSize()) + reason = packet.readString(); + (void)kickerGuid; + (void)reasonType; + std::string msg = "You have been removed from the group."; + if (!reason.empty()) + msg = "You have been removed from the group: " + reason; + else if (reasonType == 1) + msg = "You have been removed from the group for being AFK."; + else if (reasonType == 2) + msg = "You have been removed from the group by vote."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, + " reason='", reason, "'"); + break; + } + + case Opcode::SMSG_GROUPACTION_THROTTLED: { + // uint32 throttleMs — rate-limited group action; notify the player + if (packetHasRemaining(packet, 4)) { + uint32_t throttleMs = packet.readUInt32(); + char buf[128]; + if (throttleMs > 0) { + std::snprintf(buf, sizeof(buf), + "Group action throttled. Please wait %.1f seconds.", + throttleMs / 1000.0f); + } else { + std::snprintf(buf, sizeof(buf), "Group action throttled."); + } + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs); + } + break; + } + + case Opcode::SMSG_GMRESPONSE_RECEIVED: { + // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count + // per count: string responseText + if (!packetHasRemaining(packet, 4)) { + packet.setReadPos(packet.getSize()); + break; + } + uint32_t ticketId = packet.readUInt32(); + std::string subject; + std::string body; + if (packet.getReadPos() < packet.getSize()) subject = packet.readString(); + if (packet.getReadPos() < packet.getSize()) body = packet.readString(); + uint32_t responseCount = 0; + if (packetHasRemaining(packet, 4)) + responseCount = packet.readUInt32(); + std::string responseText; + for (uint32_t i = 0; i < responseCount && i < 10; ++i) { + if (packet.getReadPos() < packet.getSize()) { + std::string t = packet.readString(); + if (i == 0) responseText = t; + } + } + (void)ticketId; + std::string msg; + if (!responseText.empty()) + msg = "[GM Response] " + responseText; + else if (!body.empty()) + msg = "[GM Response] " + body; + else if (!subject.empty()) + msg = "[GM Response] " + subject; + else + msg = "[GM Response] Your ticket has been answered."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId, + " subject='", subject, "'"); + break; + } + + case Opcode::SMSG_GMRESPONSE_STATUS_UPDATE: { + // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) + if (packet.getSize() - packet.getReadPos() >= 5) { + uint32_t ticketId = packet.readUInt32(); + uint8_t status = packet.readUInt8(); + const char* statusStr = (status == 1) ? "open" + : (status == 2) ? "answered" + : (status == 3) ? "needs more info" + : "updated"; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "[GM Ticket #%u] Status: %s.", ticketId, statusStr); + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId, + " status=", static_cast(status)); + } + break; + } + + // ---- Voice chat (WotLK built-in voice) — consume silently ---- + case Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE: + case Opcode::SMSG_VOICE_SESSION_LEAVE: + case Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY: + case Opcode::SMSG_VOICE_SET_TALKER_MUTED: + case Opcode::SMSG_VOICE_SESSION_ENABLE: + case Opcode::SMSG_VOICE_PARENTAL_CONTROLS: + case Opcode::SMSG_AVAILABLE_VOICE_CHANNEL: + case Opcode::SMSG_VOICE_CHAT_STATUS: + packet.setReadPos(packet.getSize()); + break; + + // ---- Dance / custom emote system (WotLK) — consume silently ---- + case Opcode::SMSG_NOTIFY_DANCE: + case Opcode::SMSG_PLAY_DANCE: + case Opcode::SMSG_STOP_DANCE: + case Opcode::SMSG_DANCE_QUERY_RESPONSE: + case Opcode::SMSG_INVALIDATE_DANCE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Commentator / spectator mode — consume silently ---- + case Opcode::SMSG_COMMENTATOR_STATE_CHANGED: + case Opcode::SMSG_COMMENTATOR_MAP_INFO: + case Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO: + case Opcode::SMSG_COMMENTATOR_PLAYER_INFO: + case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1: + case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2: + packet.setReadPos(packet.getSize()); + break; + + // ---- Debug / cheat / GM-only opcodes — consume silently ---- + case Opcode::SMSG_DBLOOKUP: + case Opcode::SMSG_CHECK_FOR_BOTS: + case Opcode::SMSG_GODMODE: + case Opcode::SMSG_PETGODMODE: + case Opcode::SMSG_DEBUG_AISTATE: + case Opcode::SMSG_DEBUGAURAPROC: + case Opcode::SMSG_TEST_DROP_RATE_RESULT: + case Opcode::SMSG_COOLDOWN_CHEAT: + case Opcode::SMSG_GM_PLAYER_INFO: + case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE: + case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE: + case Opcode::SMSG_CHEAT_PLAYER_LOOKUP: + case Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT: + case Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT: + case Opcode::SMSG_DEBUG_LIST_TARGETS: + case Opcode::SMSG_DEBUG_SERVER_GEO: + case Opcode::SMSG_DUMP_OBJECTS_DATA: + case Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE: + case Opcode::SMSG_FORCEACTIONSHOW: + case Opcode::SMSG_MOVE_CHARACTER_CHEAT: packet.setReadPos(packet.getSize()); break; @@ -5543,6 +8939,161 @@ void GameHandler::handlePacket(network::Packet& packet) { } } +void GameHandler::enqueueIncomingPacket(const network::Packet& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(), + " packets); dropping oldest packet to preserve responsiveness"); + pendingIncomingPackets_.pop_front(); + } + pendingIncomingPackets_.push_back(packet); + lastRxTime_ = std::chrono::steady_clock::now(); + rxSilenceLogged_ = false; +} + +void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(), + " packets); dropping newest queued packet to preserve ordering"); + pendingIncomingPackets_.pop_back(); + } + pendingIncomingPackets_.emplace_front(std::move(packet)); +} + +void GameHandler::enqueueUpdateObjectWork(UpdateObjectData&& data) { + pendingUpdateObjectWork_.push_back(PendingUpdateObjectWork{std::move(data)}); +} + +void GameHandler::processPendingUpdateObjectWork(const std::chrono::steady_clock::time_point& start, + float budgetMs) { + if (pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(state); + int processedBlocks = 0; + + while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + auto& work = pendingUpdateObjectWork_.front(); + if (!work.outOfRangeProcessed) { + auto outOfRangeStart = std::chrono::steady_clock::now(); + processOutOfRangeObjects(work.data.outOfRangeGuids); + float outOfRangeMs = std::chrono::duration( + std::chrono::steady_clock::now() - outOfRangeStart).count(); + if (outOfRangeMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object out-of-range handling: ", outOfRangeMs, + "ms guidCount=", work.data.outOfRangeGuids.size()); + } + work.outOfRangeProcessed = true; + } + + while (work.nextBlockIndex < work.data.blocks.size() && processedBlocks < maxBlocksThisUpdate) { + elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + const UpdateBlock& block = work.data.blocks[work.nextBlockIndex]; + auto blockStart = std::chrono::steady_clock::now(); + applyUpdateObjectBlock(block, work.newItemCreated); + float blockMs = std::chrono::duration( + std::chrono::steady_clock::now() - blockStart).count(); + if (blockMs > slowUpdateObjectBlockLogThresholdMs()) { + LOG_WARNING("SLOW update-object block apply: ", blockMs, + "ms index=", work.nextBlockIndex, + " type=", static_cast(block.updateType), + " guid=0x", std::hex, block.guid, std::dec, + " objectType=", static_cast(block.objectType), + " fieldCount=", block.fields.size(), + " hasMovement=", block.hasMovement ? 1 : 0); + } + ++work.nextBlockIndex; + ++processedBlocks; + } + + if (work.nextBlockIndex >= work.data.blocks.size()) { + finalizeUpdateObjectBatch(work.newItemCreated); + pendingUpdateObjectWork_.pop_front(); + continue; + } + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + const auto& work = pendingUpdateObjectWork_.front(); + LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", + pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, + "/", work.data.blocks.size(), ", state=", worldStateName(state), ")"); + } +} + +void GameHandler::processQueuedIncomingPackets() { + if (pendingIncomingPackets_.empty() && pendingUpdateObjectWork_.empty()) { + return; + } + + const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state); + const float budgetMs = incomingPacketBudgetMs(state); + const auto start = std::chrono::steady_clock::now(); + int processed = 0; + + while (processed < maxPacketsThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + if (!pendingUpdateObjectWork_.empty()) { + processPendingUpdateObjectWork(start, budgetMs); + if (!pendingUpdateObjectWork_.empty()) { + break; + } + continue; + } + + if (pendingIncomingPackets_.empty()) { + break; + } + + network::Packet packet = std::move(pendingIncomingPackets_.front()); + pendingIncomingPackets_.pop_front(); + const uint16_t wireOp = packet.getOpcode(); + const auto logicalOp = opcodeTable_.fromWire(wireOp); + auto packetHandleStart = std::chrono::steady_clock::now(); + handlePacket(packet); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetHandleStart).count(); + if (packetMs > slowPacketLogThresholdMs()) { + const char* logicalName = logicalOp + ? OpcodeTable::logicalToName(*logicalOp) + : "UNKNOWN"; + LOG_WARNING("SLOW packet handler: ", packetMs, + "ms wire=0x", std::hex, wireOp, std::dec, + " logical=", logicalName, + " size=", packet.getSize(), + " state=", worldStateName(state)); + } + ++processed; + } + + if (!pendingUpdateObjectWork_.empty()) { + return; + } + + if (!pendingIncomingPackets_.empty()) { + LOG_DEBUG("GameHandler packet budget reached (processed=", processed, + ", remaining=", pendingIncomingPackets_.size(), + ", state=", worldStateName(state), ")"); + } +} + void GameHandler::handleAuthChallenge(network::Packet& packet) { LOG_INFO("Handling SMSG_AUTH_CHALLENGE"); @@ -5904,19 +9455,40 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { pendingItemQueries_.clear(); equipSlotGuids_ = {}; backpackSlotGuids_ = {}; + keyringSlotGuids_ = {}; invSlotBase_ = -1; packSlotBase_ = -1; lastPlayerFields_.clear(); onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; + std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); + std::fill(std::begin(playerStats_), std::end(playerStats_), -1); + playerMeleeAP_ = -1; + playerRangedAP_ = -1; + std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1); + playerHealBonus_ = -1; + playerDodgePct_ = -1.0f; + playerParryPct_ = -1.0f; + playerBlockPct_ = -1.0f; + playerCritPct_ = -1.0f; + playerRangedCritPct_ = -1.0f; + std::fill(std::begin(playerSpellCritPct_), std::end(playerSpellCritPct_), -1.0f); + std::fill(std::begin(playerCombatRatings_), std::end(playerCombatRatings_), -1); knownSpells.clear(); spellCooldowns.clear(); + spellFlatMods_.clear(); + spellPctMods_.clear(); actionBar = {}; playerAuras.clear(); targetAuras.clear(); + unitAurasCache_.clear(); unitCastStates_.clear(); petGuid_ = 0; + stableWindowOpen_ = false; + stableMasterGuid_ = 0; + stableNumSlots_ = 0; + stabledPets_.clear(); playerXp_ = 0; playerNextLevelXp_ = 0; serverPlayerLevel_ = 1; @@ -5935,12 +9507,20 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { autoAttacking = false; autoAttackTarget = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; playerDead_ = false; releasedSpirit_ = false; + corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; targetGuid = 0; focusGuid = 0; lastTargetGuid = 0; @@ -5994,9 +9574,29 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { return; } + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); + const bool alreadyInWorld = (state == WorldState::IN_WORLD); + const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId); + const float dxCurrent = movementInfo.x - canonical.x; + const float dyCurrent = movementInfo.y - canonical.y; + const float dzCurrent = movementInfo.z - canonical.z; + const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent; + + // Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already + // in-world. Re-running full world-entry handling here can trigger an expensive + // same-map reload/reset path and starve networking for tens of seconds. + if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) { + LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=", + data.mapId, " dist=", std::sqrt(distSqCurrent)); + return; + } + // Successfully entered the world (or teleported) currentMapId_ = data.mapId; setState(WorldState::IN_WORLD); + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world"); + } LOG_INFO("========================================"); LOG_INFO(" SUCCESSFULLY ENTERED WORLD!"); @@ -6007,7 +9607,6 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { LOG_INFO("Player is now in the game world"); // Initialize movement info with world entry position (server → canonical) - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(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; @@ -6019,8 +9618,16 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { movementClockStart_ = std::chrono::steady_clock::now(); lastMovementTimestampMs_ = 0; movementInfo.time = nextMovementTimestampMs(); + isFalling_ = false; + fallStartMs_ = 0; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; resurrectPending_ = false; resurrectRequestPending_ = false; + selfResAvailable_ = false; onTaxiFlight_ = false; taxiMountActive_ = false; taxiActivatePending_ = false; @@ -6030,6 +9637,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { taxiStartGrace_ = 0.0f; currentMountDisplayId_ = 0; taxiMountDisplayId_ = 0; + vehicleId_ = 0; if (mountCallback_) { mountCallback_(0); } @@ -6044,29 +9652,24 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { areaTriggerCheckTimer_ = -5.0f; areaTriggerSuppressFirst_ = true; - // Send CMSG_SET_ACTIVE_MOVER (required by some servers) + // Notify application to load terrain for this map/position (online mode) + if (worldEntryCallback_) { + worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); + } + + // Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers. if (playerGuid != 0 && socket) { auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid); socket->send(activeMoverPacket); LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec); } - // Notify application to load terrain for this map/position (online mode) - if (worldEntryCallback_) { - worldEntryCallback_(data.mapId, data.x, data.y, data.z); - } - - // Auto-join default chat channels - autoJoinDefaultChannels(); - - // Auto-query guild info on login - const Character* activeChar = getActiveCharacter(); - if (activeChar && activeChar->hasGuild() && socket) { - auto gqPacket = GuildQueryPacket::build(activeChar->guildId); - socket->send(gqPacket); - auto grPacket = GuildRosterPacket::build(); - socket->send(grPacket); - LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + // Kick the first keepalive immediately on world entry. Classic-like realms + // can close the session before our default 30s ping cadence fires. + timeSinceLastPing = 0.0f; + if (socket) { + LOG_DEBUG("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD"); + sendPing(); } // If we disconnected mid-taxi, attempt to recover to destination after login. @@ -6084,6 +9687,33 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { } if (initialWorldEntry) { + // Clear inspect caches on world entry to avoid showing stale data. + inspectedPlayerAchievements_.clear(); + + // 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(); + learnedGlyphs_[0].fill(0); + learnedGlyphs_[1].fill(0); + unspentTalentPoints_[0] = 0; + unspentTalentPoints_[1] = 0; + activeTalentSpec_ = 0; + + // Auto-join default chat channels only on first world entry. + autoJoinDefaultChannels(); + + // Auto-query guild info on login. + const Character* activeChar = getActiveCharacter(); + if (activeChar && activeChar->hasGuild() && socket) { + auto gqPacket = GuildQueryPacket::build(activeChar->guildId); + socket->send(gqPacket); + auto grPacket = GuildRosterPacket::build(); + socket->send(grPacket); + LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + } + pendingQuestAcceptTimeouts_.clear(); pendingQuestAcceptNpcGuids_.clear(); pendingQuestQueryIds_.clear(); @@ -6092,11 +9722,39 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { completedQuests_.clear(); LOG_INFO("Queued quest log resync for login (from server quest slots)"); - // Request completed quest IDs from server (populates completedQuests_ when response arrives) + // Request completed quest IDs when the expansion supports it. Classic-like + // opcode tables do not define this packet, and sending 0xFFFF during world + // entry can desync the early session handshake. if (socket) { - network::Packet cqcPkt(wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED)); - socket->send(cqcPkt); - LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED); + if (queryCompletedWire != 0xFFFF) { + network::Packet cqcPkt(queryCompletedWire); + socket->send(cqcPkt); + LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + } else { + LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); + } + } + + // Auto-request played time on login so the character Stats tab is + // populated immediately without requiring /played. + if (socket) { + auto ptPkt = RequestPlayedTimePacket::build(false); // false = don't show in chat + socket->send(ptPkt); + LOG_INFO("Auto-requested played time on login"); + } + } + + // Fire PLAYER_ENTERING_WORLD — THE most important event for addon initialization. + // Fires on initial login, teleports, instance transitions, and zone changes. + if (addonEventCallback_) { + addonEventCallback_("PLAYER_ENTERING_WORLD", {initialWorldEntry ? "1" : "0"}); + // Also fire ZONE_CHANGED_NEW_AREA and UPDATE_WORLD_STATES so map/BG addons refresh + addonEventCallback_("ZONE_CHANGED_NEW_AREA", {}); + addonEventCallback_("UPDATE_WORLD_STATES", {}); + // PLAYER_LOGIN fires only on initial login (not teleports) + if (initialWorldEntry) { + addonEventCallback_("PLAYER_LOGIN", {}); } } } @@ -6174,7 +9832,7 @@ bool GameHandler::loadWardenCRFile(const std::string& moduleHashHex) { for (int i = 0; i < 9; i++) { char s[16]; snprintf(s, sizeof(s), "%s=0x%02X ", names[i], wardenCheckOpcodes_[i]); opcHex += s; } - LOG_DEBUG("Warden: Check opcodes: ", opcHex); + LOG_WARNING("Warden: Check opcodes: ", opcHex); } size_t entryCount = (static_cast(fileSize) - CR_HEADER_SIZE) / CR_ENTRY_SIZE; @@ -6373,6 +10031,18 @@ void GameHandler::handleWardenData(network::Packet& packet) { } std::vector seed(decrypted.begin() + 1, decrypted.begin() + 17); + auto applyWardenSeedRekey = [&](const std::vector& rekeySeed) { + // Derive new RC4 keys from the seed using SHA1Randx. + uint8_t newEncryptKey[16], newDecryptKey[16]; + WardenCrypto::sha1RandxGenerate(rekeySeed, newEncryptKey, newDecryptKey); + + std::vector ek(newEncryptKey, newEncryptKey + 16); + std::vector dk(newDecryptKey, newDecryptKey + 16); + wardenCrypto_->replaceKeys(ek, dk); + for (auto& b : newEncryptKey) b = 0; + for (auto& b : newDecryptKey) b = 0; + LOG_DEBUG("Warden: Derived and applied key update from seed"); + }; // --- Try CR lookup (pre-computed challenge/response entries) --- if (!wardenCREntries_.empty()) { @@ -6385,16 +10055,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { } if (match) { - LOG_DEBUG("Warden: Found matching CR entry for seed"); - - // Log the reply we're sending - { - std::string replyHex; - for (int i = 0; i < 20; i++) { - char s[4]; snprintf(s, 4, "%02x", match->reply[i]); replyHex += s; - } - LOG_DEBUG("Warden: Sending pre-computed reply=", replyHex); - } + LOG_WARNING("Warden: HASH_REQUEST — CR entry MATCHED, sending pre-computed reply"); // Send HASH_RESULT (opcode 0x04 + 20-byte reply) std::vector resp; @@ -6408,7 +10069,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { std::vector newDecryptKey(match->serverKey, match->serverKey + 16); wardenCrypto_->replaceKeys(newEncryptKey, newDecryptKey); - LOG_DEBUG("Warden: Switched to CR key set"); + LOG_WARNING("Warden: Switched to CR key set"); wardenState_ = WardenState::WAIT_CHECKS; break; @@ -6417,112 +10078,52 @@ void GameHandler::handleWardenData(network::Packet& packet) { } } - // --- Fallback: compute hash from loaded module --- - LOG_WARNING("Warden: No CR match, computing hash from loaded module"); - - if (!wardenLoadedModule_ || !wardenLoadedModule_->isLoaded()) { - LOG_ERROR("Warden: No loaded module and no CR match — cannot compute hash"); - wardenState_ = WardenState::WAIT_CHECKS; - break; - } - + // --- No CR match: decide strategy based on server strictness --- { - const uint8_t* moduleImage = static_cast(wardenLoadedModule_->getModuleMemory()); - size_t moduleImageSize = wardenLoadedModule_->getModuleSize(); - const auto& decompressedData = wardenLoadedModule_->getDecompressedData(); + std::string seedHex; + for (auto b : seed) { char s[4]; snprintf(s, 4, "%02x", b); seedHex += s; } - // --- Empirical test: try multiple SHA1 computations and check against first CR entry --- - if (!wardenCREntries_.empty()) { - const auto& firstCR = wardenCREntries_[0]; - std::string expectedHex; - for (int i = 0; i < 20; i++) { char s[4]; snprintf(s, 4, "%02x", firstCR.reply[i]); expectedHex += s; } - LOG_DEBUG("Warden: Empirical test — expected reply from CR[0]=", expectedHex); + bool isTurtle = isActiveExpansion("turtle"); + bool isClassic = (build <= 6005) && !isTurtle; - // Test 1: SHA1(moduleImage) - { - std::vector data(moduleImage, moduleImage + moduleImageSize); - auto h = auth::Crypto::sha1(data); - bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); - std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_DEBUG("Warden: SHA1(moduleImage)=", hex, match ? " MATCH!" : ""); - } - // Test 2: SHA1(seed || moduleImage) - { - std::vector data; - data.insert(data.end(), seed.begin(), seed.end()); - data.insert(data.end(), moduleImage, moduleImage + moduleImageSize); - auto h = auth::Crypto::sha1(data); - bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); - std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_DEBUG("Warden: SHA1(seed||image)=", hex, match ? " MATCH!" : ""); - } - // Test 3: SHA1(moduleImage || seed) - { - std::vector data(moduleImage, moduleImage + moduleImageSize); - data.insert(data.end(), seed.begin(), seed.end()); - auto h = auth::Crypto::sha1(data); - bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); - std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_DEBUG("Warden: SHA1(image||seed)=", hex, match ? " MATCH!" : ""); - } - // Test 4: SHA1(decompressedData) - { - auto h = auth::Crypto::sha1(decompressedData); - bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); - std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_DEBUG("Warden: SHA1(decompressed)=", hex, match ? " MATCH!" : ""); - } - // Test 5: SHA1(rawModuleData) - { - auto h = auth::Crypto::sha1(wardenModuleData_); - bool match = (std::memcmp(h.data(), firstCR.reply, 20) == 0); - std::string hex; for (auto b : h) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_DEBUG("Warden: SHA1(rawModule)=", hex, match ? " MATCH!" : ""); - } - // Test 6: Check if all CR replies are the same (constant hash) - { - bool allSame = true; - for (size_t i = 1; i < wardenCREntries_.size(); i++) { - if (std::memcmp(wardenCREntries_[i].reply, firstCR.reply, 20) != 0) { - allSame = false; - break; - } - } - LOG_DEBUG("Warden: All ", wardenCREntries_.size(), " CR replies identical? ", allSame ? "YES" : "NO"); - } + if (!isTurtle && !isClassic) { + // WotLK/TBC (AzerothCore, etc.): strict servers BAN for wrong HASH_RESULT. + // Without a matching CR entry we cannot compute the correct hash + // (requires executing the module's native init function). + // Safest action: don't respond. Server will time-out and kick (not ban). + LOG_WARNING("Warden: HASH_REQUEST seed=", seedHex, + " — no CR match, SKIPPING response to avoid account ban"); + LOG_WARNING("Warden: To fix, provide a .cr file with the correct seed→reply entry for this module"); + // Stay in WAIT_HASH_REQUEST — server will eventually kick. + break; } - // --- Compute the hash: SHA1(moduleImage) is the most likely candidate --- - // The module's hash response is typically SHA1 of the loaded module image. - // This is a constant per module (seed is not used in the hash, only for key derivation). - std::vector imageData(moduleImage, moduleImage + moduleImageSize); - auto reply = auth::Crypto::sha1(imageData); + // Turtle/Classic: lenient servers (log-only penalties, no bans). + // Send a best-effort fallback hash so we can continue the handshake. + LOG_WARNING("Warden: No CR match (seed=", seedHex, + "), sending fallback hash (lenient server)"); - { - std::string hex; - for (auto b : reply) { char s[4]; snprintf(s, 4, "%02x", b); hex += s; } - LOG_DEBUG("Warden: Sending SHA1(moduleImage)=", hex); + std::vector fallbackReply; + if (wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { + const uint8_t* moduleImage = static_cast(wardenLoadedModule_->getModuleMemory()); + size_t moduleImageSize = wardenLoadedModule_->getModuleSize(); + if (moduleImage && moduleImageSize > 0) { + std::vector imageData(moduleImage, moduleImage + moduleImageSize); + fallbackReply = auth::Crypto::sha1(imageData); + } + } + if (fallbackReply.empty()) { + if (!wardenModuleData_.empty()) + fallbackReply = auth::Crypto::sha1(wardenModuleData_); + else + fallbackReply.assign(20, 0); } - // Send HASH_RESULT (opcode 0x04 + 20-byte hash) std::vector resp; - resp.push_back(0x04); - resp.insert(resp.end(), reply.begin(), reply.end()); + resp.push_back(0x04); // WARDEN_CMSG_HASH_RESULT + resp.insert(resp.end(), fallbackReply.begin(), fallbackReply.end()); sendWardenResponse(resp); - - // Derive new RC4 keys from the seed using SHA1Randx - std::vector seedVec(seed.begin(), seed.end()); - // Pad seed to at least 2 bytes for SHA1Randx split - // SHA1Randx splits input in half: first_half and second_half - uint8_t newEncryptKey[16], newDecryptKey[16]; - WardenCrypto::sha1RandxGenerate(seedVec, newEncryptKey, newDecryptKey); - - std::vector ek(newEncryptKey, newEncryptKey + 16); - std::vector dk(newDecryptKey, newDecryptKey + 16); - wardenCrypto_->replaceKeys(ek, dk); - for (auto& b : newEncryptKey) b = 0; - for (auto& b : newDecryptKey) b = 0; - LOG_DEBUG("Warden: Derived and applied key update from seed"); + applyWardenSeedRekey(seed); } wardenState_ = WardenState::WAIT_CHECKS; @@ -6557,6 +10158,319 @@ void GameHandler::handleWardenData(network::Packet& packet) { uint8_t xorByte = decrypted.back(); LOG_DEBUG("Warden: XOR byte = 0x", [&]{ char s[4]; snprintf(s,4,"%02x",xorByte); return std::string(s); }()); + // Quick-scan for PAGE_A/PAGE_B checks (these trigger 5-second brute-force searches) + { + bool hasSlowChecks = false; + for (size_t i = pos; i < decrypted.size() - 1; i++) { + uint8_t d = decrypted[i] ^ xorByte; + if (d == wardenCheckOpcodes_[2] || d == wardenCheckOpcodes_[3]) { + hasSlowChecks = true; + break; + } + } + if (hasSlowChecks && !wardenResponsePending_) { + LOG_WARNING("Warden: PAGE_A/PAGE_B detected — building response async to avoid main-loop stall"); + // Ensure wardenMemory_ is loaded on main thread before launching async task + if (!wardenMemory_) { + wardenMemory_ = std::make_unique(); + if (!wardenMemory_->load(static_cast(build), isActiveExpansion("turtle"))) { + LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); + } + } + // Capture state by value (decrypted, strings) and launch async. + // The async task returns plaintext response bytes; main thread encrypts+sends in update(). + size_t capturedPos = pos; + wardenPendingEncrypted_ = std::async(std::launch::async, + [this, decrypted, strings, xorByte, capturedPos]() -> std::vector { + // This runs on a background thread — same logic as the synchronous path below. + // BEGIN: duplicated check processing (kept in sync with synchronous path) + enum CheckType { CT_MEM=0, CT_PAGE_A=1, CT_PAGE_B=2, CT_MPQ=3, CT_LUA=4, + CT_DRIVER=5, CT_TIMING=6, CT_PROC=7, CT_MODULE=8, CT_UNKNOWN=9 }; + size_t checkEnd = decrypted.size() - 1; + size_t pos = capturedPos; + + auto decodeCheckType = [&](uint8_t raw) -> CheckType { + uint8_t decoded = raw ^ xorByte; + if (decoded == wardenCheckOpcodes_[0]) return CT_MEM; + if (decoded == wardenCheckOpcodes_[1]) return CT_MODULE; + if (decoded == wardenCheckOpcodes_[2]) return CT_PAGE_A; + if (decoded == wardenCheckOpcodes_[3]) return CT_PAGE_B; + if (decoded == wardenCheckOpcodes_[4]) return CT_MPQ; + if (decoded == wardenCheckOpcodes_[5]) return CT_LUA; + if (decoded == wardenCheckOpcodes_[6]) return CT_PROC; + if (decoded == wardenCheckOpcodes_[7]) return CT_DRIVER; + if (decoded == wardenCheckOpcodes_[8]) return CT_TIMING; + return CT_UNKNOWN; + }; + auto resolveString = [&](uint8_t idx) -> std::string { + if (idx == 0) return {}; + size_t i = idx - 1; + return i < strings.size() ? strings[i] : std::string(); + }; + auto isKnownWantedCodeScan = [&](const uint8_t seed[4], const uint8_t hash[20], + uint32_t off, uint8_t len) -> bool { + auto tryMatch = [&](const uint8_t* pat, size_t patLen) { + uint8_t out[SHA_DIGEST_LENGTH]; unsigned int outLen = 0; + HMAC(EVP_sha1(), seed, 4, pat, patLen, out, &outLen); + return outLen == SHA_DIGEST_LENGTH && !std::memcmp(out, hash, SHA_DIGEST_LENGTH); + }; + static const uint8_t p1[] = {0x33,0xD2,0x33,0xC9,0xE8,0x87,0x07,0x1B,0x00,0xE8}; + if (off == 13856 && len == sizeof(p1) && tryMatch(p1, sizeof(p1))) return true; + static const uint8_t p2[] = {0x56,0x57,0xFC,0x8B,0x54,0x24,0x14,0x8B, + 0x74,0x24,0x10,0x8B,0x44,0x24,0x0C,0x8B,0xCA,0x8B,0xF8,0xC1, + 0xE9,0x02,0x74,0x02,0xF3,0xA5,0xB1,0x03,0x23,0xCA,0x74,0x02, + 0xF3,0xA4,0x5F,0x5E,0xC3}; + if (len == sizeof(p2) && tryMatch(p2, sizeof(p2))) return true; + return false; + }; + + std::vector resultData; + int checkCount = 0; + int checkTypeCounts[10] = {}; + + #define WARDEN_ASYNC_HANDLER 1 + // The check processing loop is identical to the synchronous path. + // See the synchronous case 0x02 below for the canonical version. + while (pos < checkEnd) { + CheckType ct = decodeCheckType(decrypted[pos]); + pos++; + checkCount++; + if (ct <= CT_UNKNOWN) checkTypeCounts[ct]++; + + switch (ct) { + case CT_TIMING: { + // Result byte: 0x01 = timing check ran successfully, + // 0x00 = timing check failed (Wine/VM — server skips anti-AFK). + // We return 0x01 so the server validates normally; our + // LastHardwareAction (now-2000) ensures a clean 2s delta. + resultData.push_back(0x01); + uint32_t ticks = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + resultData.push_back(ticks & 0xFF); + resultData.push_back((ticks >> 8) & 0xFF); + resultData.push_back((ticks >> 16) & 0xFF); + resultData.push_back((ticks >> 24) & 0xFF); + break; + } + case CT_MEM: { + if (pos + 6 > checkEnd) { pos = checkEnd; break; } + uint8_t strIdx = decrypted[pos++]; + std::string moduleName = resolveString(strIdx); + uint32_t offset = decrypted[pos] | (uint32_t(decrypted[pos+1])<<8) + | (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24); + pos += 4; + uint8_t readLen = decrypted[pos++]; + LOG_WARNING("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), + " len=", (int)readLen, + (strIdx ? " module=\"" + moduleName + "\"" : "")); + if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) { + uint32_t now = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + wardenMemory_->writeLE32(0xCF0BC8, now - 2000); + } + std::vector memBuf(readLen, 0); + bool memOk = wardenMemory_ && wardenMemory_->isLoaded() && + wardenMemory_->readMemory(offset, readLen, memBuf.data()); + if (memOk) { + const char* region = "?"; + if (offset >= 0x7FFE0000 && offset < 0x7FFF0000) region = "KUSER"; + else if (offset >= 0x400000 && offset < 0x800000) region = ".text/.code"; + else if (offset >= 0x7FF000 && offset < 0x827000) region = ".rdata"; + else if (offset >= 0x827000 && offset < 0x883000) region = ".data(raw)"; + else if (offset >= 0x883000 && offset < 0xD06000) region = ".data(BSS)"; + bool allZero = true; + for (int i = 0; i < (int)readLen; i++) { if (memBuf[i] != 0) { allZero = false; break; } } + std::string hexDump; + for (int i = 0; i < (int)readLen; i++) { char hx[4]; snprintf(hx,4,"%02x ",memBuf[i]); hexDump += hx; } + LOG_WARNING("Warden: MEM_CHECK served: [", hexDump, "] region=", region, + (allZero && offset >= 0x883000 ? " \xe2\x98\x85""BSS_ZERO\xe2\x98\x85" : "")); + if (offset == 0x7FFE026C && readLen == 12) + LOG_WARNING("Warden: Applying 4-byte ULONG alignment padding for WinVersionGet"); + resultData.push_back(0x00); + resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); + } else { + // Address not in PE/KUSER — return 0xE9 (not readable). + // Real 32-bit WoW can't read kernel space (>=0x80000000) + // or arbitrary unallocated user-space addresses. + LOG_WARNING("Warden: MEM_CHECK -> 0xE9 (unmapped 0x", + [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), ")"); + resultData.push_back(0xE9); + } + break; + } + case CT_PAGE_A: + case CT_PAGE_B: { + constexpr size_t kPageSize = 29; + const char* pageName = (ct == CT_PAGE_A) ? "PAGE_A" : "PAGE_B"; + bool isImageOnly = (ct == CT_PAGE_A); + if (pos + kPageSize > checkEnd) { pos = checkEnd; resultData.push_back(0x00); break; } + const uint8_t* p = decrypted.data() + pos; + const uint8_t* seed = p; + const uint8_t* sha1 = p + 4; + uint32_t off = uint32_t(p[24])|(uint32_t(p[25])<<8)|(uint32_t(p[26])<<16)|(uint32_t(p[27])<<24); + uint8_t patLen = p[28]; + bool found = false; + bool turtleFallback = false; + if (isKnownWantedCodeScan(seed, sha1, off, patLen)) { + found = true; + } else if (wardenMemory_ && wardenMemory_->isLoaded() && patLen > 0) { + // Hint + nearby window search (instant). + // Skip full brute-force for Turtle PAGE_A to avoid + // 25s delay that triggers response timeout. + bool hintOnly = (ct == CT_PAGE_A && isActiveExpansion("turtle")); + found = wardenMemory_->searchCodePattern(seed, sha1, patLen, isImageOnly, off, hintOnly); + if (!found && !hintOnly && wardenLoadedModule_ && wardenLoadedModule_->isLoaded()) { + const uint8_t* modMem = static_cast(wardenLoadedModule_->getModuleMemory()); + size_t modSize = wardenLoadedModule_->getModuleSize(); + if (modMem && modSize >= patLen) { + for (size_t i = 0; i < modSize - patLen + 1; i++) { + uint8_t h[20]; unsigned int hl = 0; + HMAC(EVP_sha1(), seed, 4, modMem+i, patLen, h, &hl); + if (hl == 20 && !std::memcmp(h, sha1, 20)) { found = true; break; } + } + } + } + } + // Turtle PAGE_A fallback: patterns at runtime-patched + // offsets don't exist in the on-disk PE. The server + // expects "found" for these code integrity checks. + if (!found && ct == CT_PAGE_A && isActiveExpansion("turtle") && off < 0x600000) { + found = true; + turtleFallback = true; + } + uint8_t pageResult = found ? 0x4A : 0x00; + LOG_WARNING("Warden: ", pageName, " offset=0x", + [&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}(), + " patLen=", (int)patLen, " found=", found ? "yes" : "no", + turtleFallback ? " (turtle-fallback)" : ""); + pos += kPageSize; + resultData.push_back(pageResult); + break; + } + case CT_MPQ: { + if (pos + 1 > checkEnd) { pos = checkEnd; break; } + uint8_t strIdx = decrypted[pos++]; + std::string filePath = resolveString(strIdx); + LOG_WARNING("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); + bool found = false; + std::vector hash(20, 0); + if (!filePath.empty()) { + std::string np = asciiLower(filePath); + std::replace(np.begin(), np.end(), '/', '\\'); + auto knownIt = knownDoorHashes().find(np); + if (knownIt != knownDoorHashes().end()) { found = true; hash.assign(knownIt->second.begin(), knownIt->second.end()); } + auto* am = core::Application::getInstance().getAssetManager(); + if (am && am->isInitialized() && !found) { + std::vector fd; + std::string rp = resolveCaseInsensitiveDataPath(am->getDataPath(), filePath); + if (!rp.empty()) fd = readFileBinary(rp); + if (fd.empty()) fd = am->readFile(filePath); + if (!fd.empty()) { found = true; hash = auth::Crypto::sha1(fd); } + } + } + LOG_WARNING("Warden: MPQ result=", (found ? "FOUND" : "NOT_FOUND")); + if (found) { resultData.push_back(0x00); resultData.insert(resultData.end(), hash.begin(), hash.end()); } + else { resultData.push_back(0x01); } + break; + } + case CT_LUA: { + if (pos + 1 > checkEnd) { pos = checkEnd; break; } + pos++; resultData.push_back(0x01); break; + } + case CT_DRIVER: { + if (pos + 25 > checkEnd) { pos = checkEnd; break; } + pos += 24; + uint8_t strIdx = decrypted[pos++]; + std::string dn = resolveString(strIdx); + LOG_WARNING("Warden: DRIVER=\"", (dn.empty() ? "?" : dn), "\" -> 0x00(not found)"); + resultData.push_back(0x00); break; + } + case CT_MODULE: { + if (pos + 24 > checkEnd) { pos = checkEnd; resultData.push_back(0x00); break; } + const uint8_t* p = decrypted.data() + pos; + uint8_t sb[4] = {p[0],p[1],p[2],p[3]}; + uint8_t rh[20]; std::memcpy(rh, p+4, 20); + pos += 24; + bool isWanted = hmacSha1Matches(sb, "KERNEL32.DLL", rh); + std::string mn = isWanted ? "KERNEL32.DLL" : "?"; + if (!isWanted) { + // Cheat modules (unwanted — report not found) + if (hmacSha1Matches(sb,"WPESPY.DLL",rh)) mn = "WPESPY.DLL"; + else if (hmacSha1Matches(sb,"TAMIA.DLL",rh)) mn = "TAMIA.DLL"; + else if (hmacSha1Matches(sb,"PRXDRVPE.DLL",rh)) mn = "PRXDRVPE.DLL"; + else if (hmacSha1Matches(sb,"SPEEDHACK-I386.DLL",rh)) mn = "SPEEDHACK-I386.DLL"; + else if (hmacSha1Matches(sb,"D3DHOOK.DLL",rh)) mn = "D3DHOOK.DLL"; + else if (hmacSha1Matches(sb,"NJUMD.DLL",rh)) mn = "NJUMD.DLL"; + // System DLLs (wanted — report found) + else if (hmacSha1Matches(sb,"USER32.DLL",rh)) { mn = "USER32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"NTDLL.DLL",rh)) { mn = "NTDLL.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"WS2_32.DLL",rh)) { mn = "WS2_32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"WSOCK32.DLL",rh)) { mn = "WSOCK32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"ADVAPI32.DLL",rh)) { mn = "ADVAPI32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"SHELL32.DLL",rh)) { mn = "SHELL32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"GDI32.DLL",rh)) { mn = "GDI32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"OPENGL32.DLL",rh)) { mn = "OPENGL32.DLL"; isWanted = true; } + else if (hmacSha1Matches(sb,"WINMM.DLL",rh)) { mn = "WINMM.DLL"; isWanted = true; } + } + uint8_t mr = isWanted ? 0x4A : 0x00; + LOG_WARNING("Warden: MODULE \"", mn, "\" -> 0x", + [&]{char s[4];snprintf(s,4,"%02x",mr);return std::string(s);}(), + isWanted ? "(found)" : "(not found)"); + resultData.push_back(mr); break; + } + case CT_PROC: { + if (pos + 30 > checkEnd) { pos = checkEnd; break; } + pos += 30; resultData.push_back(0x01); break; + } + default: pos = checkEnd; break; + } + } + #undef WARDEN_ASYNC_HANDLER + + // Log summary + { + std::string summary; + const char* ctNames[] = {"MEM","PAGE_A","PAGE_B","MPQ","LUA","DRIVER","TIMING","PROC","MODULE","UNK"}; + for (int i = 0; i < 10; i++) { + if (checkTypeCounts[i] > 0) { + if (!summary.empty()) summary += " "; + summary += ctNames[i]; summary += "="; summary += std::to_string(checkTypeCounts[i]); + } + } + LOG_WARNING("Warden: (async) Parsed ", checkCount, " checks [", summary, + "] resultSize=", resultData.size()); + std::string fullHex; + for (size_t bi = 0; bi < resultData.size(); bi++) { + char hx[4]; snprintf(hx, 4, "%02x ", resultData[bi]); fullHex += hx; + if ((bi + 1) % 32 == 0 && bi + 1 < resultData.size()) fullHex += "\n "; + } + LOG_WARNING("Warden: RESPONSE_HEX [", fullHex, "]"); + } + + // Build plaintext response: [0x02][uint16 len][uint32 checksum][resultData] + auto resultHash = auth::Crypto::sha1(resultData); + uint32_t checksum = 0; + for (int i = 0; i < 5; i++) { + uint32_t word = resultHash[i*4] | (uint32_t(resultHash[i*4+1])<<8) + | (uint32_t(resultHash[i*4+2])<<16) | (uint32_t(resultHash[i*4+3])<<24); + checksum ^= word; + } + uint16_t rl = static_cast(resultData.size()); + std::vector resp; + resp.push_back(0x02); + resp.push_back(rl & 0xFF); resp.push_back((rl >> 8) & 0xFF); + resp.push_back(checksum & 0xFF); resp.push_back((checksum >> 8) & 0xFF); + resp.push_back((checksum >> 16) & 0xFF); resp.push_back((checksum >> 24) & 0xFF); + resp.insert(resp.end(), resultData.begin(), resultData.end()); + return resp; // plaintext; main thread will encrypt + send + }); + wardenResponsePending_ = true; + break; // exit case 0x02 — response will be sent from update() + } + } + // Check type enum indices enum CheckType { CT_MEM=0, CT_PAGE_A=1, CT_PAGE_B=2, CT_MPQ=3, CT_LUA=4, CT_DRIVER=5, CT_TIMING=6, CT_PROC=7, CT_MODULE=8, CT_UNKNOWN=9 }; @@ -6679,7 +10593,9 @@ void GameHandler::handleWardenData(network::Packet& packet) { switch (ct) { case CT_TIMING: { // No additional request data - // Response: [uint8 result=1][uint32 ticks] + // Response: [uint8 result][uint32 ticks] + // 0x01 = timing check ran successfully (server validates anti-AFK) + // 0x00 = timing failed (Wine/VM — server skips check but flags client) resultData.push_back(0x01); uint32_t ticks = static_cast( std::chrono::duration_cast( @@ -6688,6 +10604,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { resultData.push_back((ticks >> 8) & 0xFF); resultData.push_back((ticks >> 16) & 0xFF); resultData.push_back((ticks >> 24) & 0xFF); + LOG_WARNING("Warden: (sync) TIMING ticks=", ticks); break; } case CT_MEM: { @@ -6699,30 +10616,39 @@ void GameHandler::handleWardenData(network::Packet& packet) { | (uint32_t(decrypted[pos+2])<<16) | (uint32_t(decrypted[pos+3])<<24); pos += 4; uint8_t readLen = decrypted[pos++]; - LOG_DEBUG("Warden: MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), - " len=", (int)readLen); - if (!moduleName.empty()) { - LOG_DEBUG("Warden: MEM module=\"", moduleName, "\""); - } + LOG_WARNING("Warden: (sync) MEM offset=0x", [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), + " len=", (int)readLen, + moduleName.empty() ? "" : (" module=\"" + moduleName + "\"")); // Lazy-load WoW.exe PE image on first MEM_CHECK if (!wardenMemory_) { wardenMemory_ = std::make_unique(); - if (!wardenMemory_->load(static_cast(build))) { + if (!wardenMemory_->load(static_cast(build), isActiveExpansion("turtle"))) { LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); } } + // Dynamically update LastHardwareAction before reading + // (anti-AFK scan compares this timestamp against TIMING ticks) + if (offset == 0x00CF0BC8 && readLen == 4 && wardenMemory_ && wardenMemory_->isLoaded()) { + uint32_t now = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + wardenMemory_->writeLE32(0xCF0BC8, now - 2000); + } + // Read bytes from PE image (includes patched runtime globals) std::vector memBuf(readLen, 0); if (wardenMemory_->isLoaded() && wardenMemory_->readMemory(offset, readLen, memBuf.data())) { LOG_DEBUG("Warden: MEM_CHECK served from PE image"); + resultData.push_back(0x00); + resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); } else { - LOG_WARNING("Warden: MEM_CHECK fallback to zeros for 0x", - [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}()); + // Address not in PE/KUSER — return 0xE9 (not readable). + LOG_WARNING("Warden: (sync) MEM_CHECK -> 0xE9 (unmapped 0x", + [&]{char s[12];snprintf(s,12,"%08x",offset);return std::string(s);}(), ")"); + resultData.push_back(0xE9); } - resultData.push_back(0x00); - resultData.insert(resultData.end(), memBuf.begin(), memBuf.end()); break; } case CT_PAGE_A: { @@ -6766,11 +10692,31 @@ void GameHandler::handleWardenData(network::Packet& packet) { (uint32_t(p[26]) << 16) | (uint32_t(p[27]) << 24); uint8_t len = p[28]; if (isKnownWantedCodeScan(seedBytes, reqHash, off, len)) { - pageResult = 0x4A; // PatternFound + pageResult = 0x4A; + } else if (wardenMemory_ && wardenMemory_->isLoaded() && len > 0) { + if (wardenMemory_->searchCodePattern(seedBytes, reqHash, len, true, off)) + pageResult = 0x4A; + } + // Turtle PAGE_A fallback: runtime-patched offsets aren't in the + // on-disk PE. Server expects "found" for code integrity checks. + if (pageResult == 0x00 && isActiveExpansion("turtle") && off < 0x600000) { + pageResult = 0x4A; + LOG_WARNING("Warden: PAGE_A turtle-fallback for offset=0x", + [&]{char s[12];snprintf(s,12,"%08x",off);return std::string(s);}()); } } - LOG_DEBUG("Warden: PAGE_A request bytes=", consume, - " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); + if (consume >= 29) { + uint32_t off2 = uint32_t((decrypted.data()+pos)[24]) | (uint32_t((decrypted.data()+pos)[25])<<8) | + (uint32_t((decrypted.data()+pos)[26])<<16) | (uint32_t((decrypted.data()+pos)[27])<<24); + uint8_t len2 = (decrypted.data()+pos)[28]; + LOG_WARNING("Warden: (sync) PAGE_A offset=0x", + [&]{char s[12];snprintf(s,12,"%08x",off2);return std::string(s);}(), + " patLen=", (int)len2, + " result=0x", [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); + } else { + LOG_WARNING("Warden: (sync) PAGE_A (short ", consume, "b) result=0x", + [&]{char s[4];snprintf(s,4,"%02x",pageResult);return std::string(s);}()); + } pos += consume; resultData.push_back(pageResult); break; @@ -6819,7 +10765,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { if (pos + 1 > checkEnd) { pos = checkEnd; break; } uint8_t strIdx = decrypted[pos++]; std::string filePath = resolveWardenString(strIdx); - LOG_DEBUG("Warden: MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); + LOG_WARNING("Warden: (sync) MPQ file=\"", (filePath.empty() ? "?" : filePath), "\""); bool found = false; std::vector hash(20, 0); @@ -6854,10 +10800,15 @@ void GameHandler::handleWardenData(network::Packet& packet) { } } - // Response: [uint8 result][20 sha1] - // result=0 => found/success, result=1 => not found/failure - resultData.push_back(found ? 0x00 : 0x01); - resultData.insert(resultData.end(), hash.begin(), hash.end()); + // Response: result=0 + 20-byte SHA1 if found; result=1 (no hash) if not found. + // Server only reads 20 hash bytes when result==0; extra bytes corrupt parsing. + if (found) { + resultData.push_back(0x00); + resultData.insert(resultData.end(), hash.begin(), hash.end()); + } else { + resultData.push_back(0x01); + } + LOG_WARNING("Warden: (sync) MPQ result=", found ? "FOUND" : "NOT_FOUND"); break; } case CT_LUA: { @@ -6865,7 +10816,7 @@ void GameHandler::handleWardenData(network::Packet& packet) { if (pos + 1 > checkEnd) { pos = checkEnd; break; } uint8_t strIdx = decrypted[pos++]; std::string luaVar = resolveWardenString(strIdx); - LOG_DEBUG("Warden: LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\""); + LOG_WARNING("Warden: (sync) LUA str=\"", (luaVar.empty() ? "?" : luaVar), "\""); // Response: [uint8 result=0][uint16 len=0] // Lua string doesn't exist resultData.push_back(0x01); // not found @@ -6877,9 +10828,10 @@ void GameHandler::handleWardenData(network::Packet& packet) { pos += 24; // skip seed + sha1 uint8_t strIdx = decrypted[pos++]; std::string driverName = resolveWardenString(strIdx); - LOG_DEBUG("Warden: DRIVER=\"", (driverName.empty() ? "?" : driverName), "\""); - // Response: [uint8 result=1] (driver NOT found = clean) - resultData.push_back(0x01); + LOG_WARNING("Warden: (sync) DRIVER=\"", (driverName.empty() ? "?" : driverName), "\" -> 0x00(not found)"); + // Response: [uint8 result=0] (driver NOT found = clean) + // VMaNGOS: result != 0 means "found". 0x01 would mean VM driver detected! + resultData.push_back(0x00); break; } case CT_MODULE: { @@ -6897,23 +10849,34 @@ void GameHandler::handleWardenData(network::Packet& packet) { std::memcpy(reqHash, p + 4, 20); pos += moduleSize; - // CMaNGOS uppercases module names before hashing. - // DB module scans: - // KERNEL32.DLL (wanted=true) - // WPESPY.DLL / SPEEDHACK-I386.DLL / TAMIA.DLL (wanted=false) bool shouldReportFound = false; - if (hmacSha1Matches(seedBytes, "KERNEL32.DLL", reqHash)) { - shouldReportFound = true; - } else if (hmacSha1Matches(seedBytes, "WPESPY.DLL", reqHash) || - hmacSha1Matches(seedBytes, "SPEEDHACK-I386.DLL", reqHash) || - hmacSha1Matches(seedBytes, "TAMIA.DLL", reqHash)) { - shouldReportFound = false; - } - resultData.push_back(shouldReportFound ? 0x4A : 0x01); + std::string modName = "?"; + // Wanted system modules + if (hmacSha1Matches(seedBytes, "KERNEL32.DLL", reqHash)) { modName = "KERNEL32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "USER32.DLL", reqHash)) { modName = "USER32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "NTDLL.DLL", reqHash)) { modName = "NTDLL.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "WS2_32.DLL", reqHash)) { modName = "WS2_32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "WSOCK32.DLL", reqHash)) { modName = "WSOCK32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "ADVAPI32.DLL", reqHash)) { modName = "ADVAPI32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "SHELL32.DLL", reqHash)) { modName = "SHELL32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "GDI32.DLL", reqHash)) { modName = "GDI32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "OPENGL32.DLL", reqHash)) { modName = "OPENGL32.DLL"; shouldReportFound = true; } + else if (hmacSha1Matches(seedBytes, "WINMM.DLL", reqHash)) { modName = "WINMM.DLL"; shouldReportFound = true; } + // Unwanted cheat modules + else if (hmacSha1Matches(seedBytes, "WPESPY.DLL", reqHash)) modName = "WPESPY.DLL"; + else if (hmacSha1Matches(seedBytes, "SPEEDHACK-I386.DLL", reqHash)) modName = "SPEEDHACK-I386.DLL"; + else if (hmacSha1Matches(seedBytes, "TAMIA.DLL", reqHash)) modName = "TAMIA.DLL"; + else if (hmacSha1Matches(seedBytes, "PRXDRVPE.DLL", reqHash)) modName = "PRXDRVPE.DLL"; + else if (hmacSha1Matches(seedBytes, "D3DHOOK.DLL", reqHash)) modName = "D3DHOOK.DLL"; + else if (hmacSha1Matches(seedBytes, "NJUMD.DLL", reqHash)) modName = "NJUMD.DLL"; + LOG_WARNING("Warden: (sync) MODULE \"", modName, + "\" -> 0x", [&]{char s[4];snprintf(s,4,"%02x",shouldReportFound?0x4A:0x00);return std::string(s);}(), + "(", shouldReportFound ? "found" : "not found", ")"); + resultData.push_back(shouldReportFound ? 0x4A : 0x00); break; } // Truncated module request fallback: module NOT loaded = clean - resultData.push_back(0x01); + resultData.push_back(0x00); break; } case CT_PROC: { @@ -6922,19 +10885,39 @@ void GameHandler::handleWardenData(network::Packet& packet) { int procSize = 30; if (pos + procSize > checkEnd) { pos = checkEnd; break; } pos += procSize; + LOG_WARNING("Warden: (sync) PROC check -> 0x01(not found)"); // Response: [uint8 result=1] (proc NOT found = clean) resultData.push_back(0x01); break; } default: { - LOG_WARNING("Warden: Unknown check type, cannot parse remaining"); + uint8_t rawByte = decrypted[pos - 1]; + uint8_t decoded = rawByte ^ xorByte; + LOG_WARNING("Warden: Unknown check type raw=0x", + [&]{char s[4];snprintf(s,4,"%02x",rawByte);return std::string(s);}(), + " decoded=0x", + [&]{char s[4];snprintf(s,4,"%02x",decoded);return std::string(s);}(), + " xorByte=0x", + [&]{char s[4];snprintf(s,4,"%02x",xorByte);return std::string(s);}(), + " opcodes=[", + [&]{std::string r;for(int i=0;i<9;i++){char s[6];snprintf(s,6,"0x%02x ",wardenCheckOpcodes_[i]);r+=s;}return r;}(), + "] pos=", pos, "/", checkEnd); pos = checkEnd; // stop parsing break; } } } - LOG_DEBUG("Warden: Parsed ", checkCount, " checks, result data size=", resultData.size()); + // Log synchronous round summary at WARNING level for diagnostics + { + LOG_WARNING("Warden: (sync) Parsed ", checkCount, " checks, resultSize=", resultData.size()); + std::string fullHex; + for (size_t bi = 0; bi < resultData.size(); bi++) { + char hx[4]; snprintf(hx, 4, "%02x ", resultData[bi]); fullHex += hx; + if ((bi + 1) % 32 == 0 && bi + 1 < resultData.size()) fullHex += "\n "; + } + LOG_WARNING("Warden: (sync) RESPONSE_HEX [", fullHex, "]"); + } // --- Compute checksum: XOR of 5 uint32s from SHA1(resultData) --- auto resultHash = auth::Crypto::sha1(resultData); @@ -7033,14 +11016,159 @@ void GameHandler::sendPing() { // Increment sequence number pingSequence++; - LOG_DEBUG("Sending CMSG_PING (heartbeat)"); - LOG_DEBUG(" Sequence: ", pingSequence); + LOG_DEBUG("Sending CMSG_PING: sequence=", pingSequence, + " latencyHintMs=", lastLatency); + + // Record send time for RTT measurement + pingTimestamp_ = std::chrono::steady_clock::now(); // Build and send ping packet auto packet = PingPacket::build(pingSequence, lastLatency); socket->send(packet); } +void GameHandler::sendRequestVehicleExit() { + if (state != WorldState::IN_WORLD || vehicleId_ == 0) return; + // CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only + network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT)); + socket->send(pkt); + vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) +} + +bool GameHandler::supportsEquipmentSets() const { + return wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE) != 0xFFFF; +} + +void GameHandler::useEquipmentSet(uint32_t setId) { + if (state != WorldState::IN_WORLD || !socket) return; + uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE); + if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } + // Find the equipment set to get target item GUIDs per slot + const EquipmentSet* es = nullptr; + for (const auto& s : equipmentSets_) { + if (s.setId == setId) { es = &s; break; } + } + if (!es) { + addUIError("Equipment set not found."); + return; + } + // CMSG_EQUIPMENT_SET_USE: 19 × (PackedGuid itemGuid + uint8 srcBag + uint8 srcSlot) + network::Packet pkt(wire); + for (int slot = 0; slot < 19; ++slot) { + uint64_t itemGuid = es->itemGuids[slot]; + MovementPacket::writePackedGuid(pkt, itemGuid); + uint8_t srcBag = 0xFF; + uint8_t srcSlot = 0; + if (itemGuid != 0) { + bool found = false; + // Check if item is already in an equipment slot + for (int eq = 0; eq < 19 && !found; ++eq) { + if (getEquipSlotGuid(eq) == itemGuid) { + srcBag = 0xFF; // INVENTORY_SLOT_BAG_0 + srcSlot = static_cast(eq); + found = true; + } + } + // Check backpack (slots 23-38 in the body container) + for (int bp = 0; bp < 16 && !found; ++bp) { + if (getBackpackItemGuid(bp) == itemGuid) { + srcBag = 0xFF; + srcSlot = static_cast(23 + bp); + found = true; + } + } + // Check extra bags (bag indices 19-22) + for (int bag = 0; bag < 4 && !found; ++bag) { + int bagSize = inventory.getBagSize(bag); + for (int s = 0; s < bagSize && !found; ++s) { + if (getBagItemGuid(bag, s) == itemGuid) { + srcBag = static_cast(19 + bag); + srcSlot = static_cast(s); + found = true; + } + } + } + } + pkt.writeUInt8(srcBag); + pkt.writeUInt8(srcSlot); + } + socket->send(pkt); + LOG_INFO("CMSG_EQUIPMENT_SET_USE: setId=", setId); +} + +void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, + uint64_t existingGuid, uint32_t setIndex) { + if (state != WorldState::IN_WORLD) return; + uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE); + if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } + // CMSG_EQUIPMENT_SET_SAVE: uint64 setGuid + uint32 setIndex + string name + string iconName + // + 19 × PackedGuid itemGuid (one per equipment slot, 0–18) + if (setIndex == 0xFFFFFFFF) { + // Auto-assign next free index + setIndex = 0; + for (const auto& es : equipmentSets_) { + if (es.setId >= setIndex) setIndex = es.setId + 1; + } + } + network::Packet pkt(wire); + pkt.writeUInt64(existingGuid); // 0 = create new, nonzero = update + pkt.writeUInt32(setIndex); + pkt.writeString(name); + pkt.writeString(iconName); + for (int slot = 0; slot < 19; ++slot) { + uint64_t guid = getEquipSlotGuid(slot); + MovementPacket::writePackedGuid(pkt, guid); + } + // Track pending save so SMSG_EQUIPMENT_SET_SAVED can add the new set locally + pendingSaveSetName_ = name; + pendingSaveSetIcon_ = iconName; + socket->send(pkt); + LOG_INFO("CMSG_EQUIPMENT_SET_SAVE: name=\"", name, "\" guid=", existingGuid, " index=", setIndex); +} + +void GameHandler::deleteEquipmentSet(uint64_t setGuid) { + if (state != WorldState::IN_WORLD || setGuid == 0) return; + uint16_t wire = wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET); + if (wire == 0xFFFF) { addUIError("Equipment sets not supported."); return; } + // CMSG_DELETEEQUIPMENT_SET: uint64 setGuid + network::Packet pkt(wire); + pkt.writeUInt64(setGuid); + socket->send(pkt); + // Remove locally so UI updates immediately + equipmentSets_.erase( + std::remove_if(equipmentSets_.begin(), equipmentSets_.end(), + [setGuid](const EquipmentSet& es) { return es.setGuid == setGuid; }), + equipmentSets_.end()); + equipmentSetInfo_.erase( + std::remove_if(equipmentSetInfo_.begin(), equipmentSetInfo_.end(), + [setGuid](const EquipmentSetInfo& es) { return es.setGuid == setGuid; }), + equipmentSetInfo_.end()); + LOG_INFO("CMSG_DELETEEQUIPMENT_SET: guid=", setGuid); +} + +void GameHandler::sendMinimapPing(float wowX, float wowY) { + if (state != WorldState::IN_WORLD) return; + + // MSG_MINIMAP_PING (CMSG direction): float posX + float posY + // Server convention: posX = east/west axis = canonical Y (west) + // posY = north/south axis = canonical X (north) + const float serverX = wowY; // canonical Y (west) → server posX + const float serverY = wowX; // canonical X (north) → server posY + + network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING)); + pkt.writeFloat(serverX); + pkt.writeFloat(serverY); + socket->send(pkt); + + // Add ping locally so the sender sees their own ping immediately + MinimapPing localPing; + localPing.senderGuid = activeCharacterGuid_; + localPing.wowX = wowX; + localPing.wowY = wowY; + localPing.age = 0.0f; + minimapPings_.push_back(localPing); +} + void GameHandler::handlePong(network::Packet& packet) { LOG_DEBUG("Handling SMSG_PONG"); @@ -7057,7 +11185,13 @@ void GameHandler::handlePong(network::Packet& packet) { return; } - LOG_DEBUG("Heartbeat acknowledged (sequence: ", data.sequence, ")"); + // Measure round-trip time + auto rtt = std::chrono::steady_clock::now() - pingTimestamp_; + lastLatency = static_cast( + std::chrono::duration_cast(rtt).count()); + + LOG_DEBUG("SMSG_PONG acknowledged: sequence=", data.sequence, + " latencyMs=", lastLatency); } uint32_t GameHandler::nextMovementTimestampMs() { @@ -7101,7 +11235,43 @@ void GameHandler::sendMovement(Opcode opcode) { if (resurrectPending_ && !taxiAllowed) return; // Always send a strictly increasing non-zero client movement clock value. - movementInfo.time = nextMovementTimestampMs(); + const uint32_t movementTime = nextMovementTimestampMs(); + movementInfo.time = movementTime; + + if (opcode == Opcode::MSG_MOVE_SET_FACING && + (isClassicLikeExpansion() || isActiveExpansion("tbc"))) { + const float facingDelta = core::coords::normalizeAngleRad( + movementInfo.orientation - lastFacingSentOrientation_); + const uint32_t sinceLastFacingMs = + lastFacingSendTimeMs_ != 0 && movementTime >= lastFacingSendTimeMs_ + ? (movementTime - lastFacingSendTimeMs_) + : std::numeric_limits::max(); + if (std::abs(facingDelta) < 0.02f && sinceLastFacingMs < 200U) { + return; + } + } + + // Track movement state transition for PLAYER_STARTED/STOPPED_MOVING events + const uint32_t kMoveMask = static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT); + const bool wasMoving = (movementInfo.flags & kMoveMask) != 0; + + // Cancel any timed (non-channeled) cast the moment the player starts moving. + // Channeled spells end via MSG_CHANNEL_UPDATE / SMSG_CHANNEL_NOTIFY from the server. + // Turning (MSG_MOVE_START_TURN_*) is allowed while casting. + if (casting && !castIsChannel) { + const bool isPositionalMove = + opcode == Opcode::MSG_MOVE_START_FORWARD || + opcode == Opcode::MSG_MOVE_START_BACKWARD || + opcode == Opcode::MSG_MOVE_START_STRAFE_LEFT || + opcode == Opcode::MSG_MOVE_START_STRAFE_RIGHT || + opcode == Opcode::MSG_MOVE_JUMP; + if (isPositionalMove) { + cancelCast(); + } + } // Update movement flags based on opcode switch (opcode) { @@ -7127,6 +11297,31 @@ void GameHandler::sendMovement(Opcode opcode) { break; case Opcode::MSG_MOVE_JUMP: movementInfo.flags |= static_cast(MovementFlags::FALLING); + // Record fall start and capture horizontal velocity for jump fields. + isFalling_ = true; + fallStartMs_ = movementInfo.time; + movementInfo.fallTime = 0; + // jumpVelocity: WoW convention is the upward speed at launch. + movementInfo.jumpVelocity = 7.96f; // WOW_JUMP_VELOCITY from CameraController + { + // Facing direction encodes the horizontal movement direction at launch. + const float facingRad = movementInfo.orientation; + movementInfo.jumpCosAngle = std::cos(facingRad); + movementInfo.jumpSinAngle = std::sin(facingRad); + // Horizontal speed: only non-zero when actually moving at jump time. + const uint32_t horizFlags = + static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT); + const bool movingHoriz = (movementInfo.flags & horizFlags) != 0; + if (movingHoriz) { + const bool isWalking = (movementInfo.flags & static_cast(MovementFlags::WALKING)) != 0; + movementInfo.jumpXYSpeed = isWalking ? 2.5f : (serverRunSpeed_ > 0.0f ? serverRunSpeed_ : 7.0f); + } else { + movementInfo.jumpXYSpeed = 0.0f; + } + } break; case Opcode::MSG_MOVE_START_TURN_LEFT: movementInfo.flags |= static_cast(MovementFlags::TURN_LEFT); @@ -7140,20 +11335,80 @@ void GameHandler::sendMovement(Opcode opcode) { break; case Opcode::MSG_MOVE_FALL_LAND: movementInfo.flags &= ~static_cast(MovementFlags::FALLING); + isFalling_ = false; + fallStartMs_ = 0; + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; break; case Opcode::MSG_MOVE_HEARTBEAT: // No flag changes — just sends current position + timeSinceLastMoveHeartbeat_ = 0.0f; + break; + case Opcode::MSG_MOVE_START_ASCEND: + movementInfo.flags |= static_cast(MovementFlags::ASCENDING); + break; + case Opcode::MSG_MOVE_STOP_ASCEND: + // Clears ascending (and descending) — one stop opcode for both directions + movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); + break; + case Opcode::MSG_MOVE_START_DESCEND: + // Descending: no separate flag; clear ASCENDING so they don't conflict + movementInfo.flags &= ~static_cast(MovementFlags::ASCENDING); break; default: break; } + // Fire PLAYER_STARTED/STOPPED_MOVING on movement state transitions + { + const bool isMoving = (movementInfo.flags & kMoveMask) != 0; + if (isMoving && !wasMoving && addonEventCallback_) + addonEventCallback_("PLAYER_STARTED_MOVING", {}); + else if (!isMoving && wasMoving && addonEventCallback_) + addonEventCallback_("PLAYER_STOPPED_MOVING", {}); + } + + if (opcode == Opcode::MSG_MOVE_SET_FACING) { + lastFacingSendTimeMs_ = movementInfo.time; + lastFacingSentOrientation_ = movementInfo.orientation; + } + + // Keep fallTime current: it must equal the elapsed milliseconds since FALLING + // was set, so the server can compute fall damage correctly. + if (isFalling_ && movementInfo.hasFlag(MovementFlags::FALLING)) { + // movementInfo.time is the strictly-increasing client clock (ms). + // Subtract fallStartMs_ to get elapsed fall time; clamp to non-negative. + uint32_t elapsed = (movementInfo.time >= fallStartMs_) + ? (movementInfo.time - fallStartMs_) + : 0u; + movementInfo.fallTime = elapsed; + } else if (!movementInfo.hasFlag(MovementFlags::FALLING)) { + // Ensure fallTime is zeroed whenever we're not falling. + if (isFalling_) { + isFalling_ = false; + fallStartMs_ = 0; + } + movementInfo.fallTime = 0; + } + if (onTaxiFlight_ || taxiMountActive_ || taxiActivatePending_ || taxiClientActive_) { sanitizeMovementForTaxi(); } - // Add transport data if player is on a transport - if (isOnTransport()) { + bool includeTransportInWire = isOnTransport(); + if (includeTransportInWire && transportManager_) { + if (auto* tr = transportManager_->getTransport(playerTransportGuid_); tr && tr->isM2) { + // Client-detected M2 elevators/trams are not always server-recognized transports. + // Sending ONTRANSPORT for these can trigger bad fall-state corrections server-side. + includeTransportInWire = false; + } + } + + // Add transport data if player is on a server-recognized transport + if (includeTransportInWire) { // Keep authoritative world position synchronized to parent transport transform // so heartbeats/corrections don't drag the passenger through geometry. if (transportManager_) { @@ -7193,9 +11448,52 @@ void GameHandler::sendMovement(Opcode opcode) { movementInfo.transportSeat = -1; } + if (opcode == Opcode::MSG_MOVE_HEARTBEAT && isClassicLikeExpansion()) { + const uint32_t locomotionFlags = + static_cast(MovementFlags::FORWARD) | + static_cast(MovementFlags::BACKWARD) | + static_cast(MovementFlags::STRAFE_LEFT) | + static_cast(MovementFlags::STRAFE_RIGHT) | + static_cast(MovementFlags::TURN_LEFT) | + static_cast(MovementFlags::TURN_RIGHT) | + static_cast(MovementFlags::ASCENDING) | + static_cast(MovementFlags::FALLING) | + static_cast(MovementFlags::FALLINGFAR) | + static_cast(MovementFlags::SWIMMING); + const bool stationaryIdle = + !onTaxiFlight_ && + !taxiMountActive_ && + !taxiActivatePending_ && + !taxiClientActive_ && + !includeTransportInWire && + (movementInfo.flags & locomotionFlags) == 0; + const uint32_t sinceLastHeartbeatMs = + lastHeartbeatSendTimeMs_ != 0 && movementTime >= lastHeartbeatSendTimeMs_ + ? (movementTime - lastHeartbeatSendTimeMs_) + : std::numeric_limits::max(); + const bool unchangedState = + std::abs(movementInfo.x - lastHeartbeatX_) < 0.01f && + std::abs(movementInfo.y - lastHeartbeatY_) < 0.01f && + std::abs(movementInfo.z - lastHeartbeatZ_) < 0.01f && + movementInfo.flags == lastHeartbeatFlags_ && + movementInfo.transportGuid == lastHeartbeatTransportGuid_; + if (stationaryIdle && unchangedState && sinceLastHeartbeatMs < 1500U) { + timeSinceLastMoveHeartbeat_ = 0.0f; + return; + } + const uint32_t sinceLastNonHeartbeatMoveMs = + lastNonHeartbeatMoveSendTimeMs_ != 0 && movementTime >= lastNonHeartbeatMoveSendTimeMs_ + ? (movementTime - lastNonHeartbeatMoveSendTimeMs_) + : std::numeric_limits::max(); + if (sinceLastNonHeartbeatMoveMs < 350U) { + timeSinceLastMoveHeartbeat_ = 0.0f; + return; + } + } + LOG_DEBUG("Sending movement packet: opcode=0x", std::hex, wireOpcode(opcode), std::dec, - (isOnTransport() ? " ONTRANSPORT" : "")); + (includeTransportInWire ? " ONTRANSPORT" : "")); // Convert canonical → server coordinates for the wire MovementInfo wireInfo = movementInfo; @@ -7208,7 +11506,7 @@ void GameHandler::sendMovement(Opcode opcode) { wireInfo.orientation = core::coords::canonicalToServerYaw(wireInfo.orientation); // Also convert transport local position to server coordinates if on transport - if (isOnTransport()) { + if (includeTransportInWire) { glm::vec3 serverTransportPos = core::coords::canonicalToServer( glm::vec3(wireInfo.transportX, wireInfo.transportY, wireInfo.transportZ)); wireInfo.transportX = serverTransportPos.x; @@ -7223,6 +11521,17 @@ void GameHandler::sendMovement(Opcode opcode) { ? packetParsers_->buildMovementPacket(opcode, wireInfo, playerGuid) : MovementPacket::build(opcode, wireInfo, playerGuid); socket->send(packet); + + if (opcode == Opcode::MSG_MOVE_HEARTBEAT) { + lastHeartbeatSendTimeMs_ = movementInfo.time; + lastHeartbeatX_ = movementInfo.x; + lastHeartbeatY_ = movementInfo.y; + lastHeartbeatZ_ = movementInfo.z; + lastHeartbeatFlags_ = movementInfo.flags; + lastHeartbeatTransportGuid_ = movementInfo.transportGuid; + } else { + lastNonHeartbeatMoveSendTimeMs_ = movementInfo.time; + } } void GameHandler::sanitizeMovementForTaxi() { @@ -7263,10 +11572,14 @@ void GameHandler::forceClearTaxiAndMovementState() { taxiMountActive_ = false; taxiMountDisplayId_ = 0; currentMountDisplayId_ = 0; + vehicleId_ = 0; resurrectPending_ = false; resurrectRequestPending_ = false; + selfResAvailable_ = false; playerDead_ = false; releasedSpirit_ = false; + corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; @@ -7298,15 +11611,70 @@ void GameHandler::setOrientation(float orientation) { } void GameHandler::handleUpdateObject(network::Packet& packet) { - static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); UpdateObjectData data; if (!packetParsers_->parseUpdateObject(packet, data)) { 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. } + enqueueUpdateObjectWork(std::move(data)); +} + +void GameHandler::processOutOfRangeObjects(const std::vector& guids) { + // Process out-of-range objects first + for (uint64_t guid : guids) { + auto entity = entityManager.getEntity(guid); + if (!entity) continue; + + const bool isKnownTransport = transportGuids_.count(guid) > 0; + if (isKnownTransport) { + // Keep transports alive across out-of-range flapping. + // Boats/zeppelins are global movers and removing them here can make + // them disappear until a later movement snapshot happens to recreate them. + const bool playerAboardNow = (playerTransportGuid_ == guid); + const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); + const bool movementSaysAboard = (movementInfo.transportGuid == guid); + LOG_INFO("Preserving transport on out-of-range: 0x", + std::hex, guid, std::dec, + " now=", playerAboardNow, + " sticky=", stickyAboard, + " movement=", movementSaysAboard); + continue; + } + + LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); + // Trigger despawn callbacks before removing entity + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); + otherPlayerVisibleItemEntries_.erase(guid); + otherPlayerVisibleDirty_.erase(guid); + 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); + } + transportGuids_.erase(guid); + serverUpdatedTransportGuids_.erase(guid); + clearTransportAttachment(guid); + if (playerTransportGuid_ == guid) { + clearPlayerTransport(); + } + entityManager.removeEntity(guid); + } + +} + +void GameHandler::applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated) { + static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); auto extractPlayerAppearance = [&](const std::map& fields, uint8_t& outRace, uint8_t& outGender, @@ -7428,829 +11796,637 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { pendingMoneyDeltaTimer_ = 0.0f; }; - // Process out-of-range objects first - for (uint64_t guid : data.outOfRangeGuids) { - auto entity = entityManager.getEntity(guid); - if (!entity) continue; + switch (block.updateType) { + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: { + // Create new entity + std::shared_ptr entity; - const bool isKnownTransport = transportGuids_.count(guid) > 0; - if (isKnownTransport) { - // Keep transports alive across out-of-range flapping. - // Boats/zeppelins are global movers and removing them here can make - // them disappear until a later movement snapshot happens to recreate them. - const bool playerAboardNow = (playerTransportGuid_ == guid); - const bool stickyAboard = (playerTransportStickyGuid_ == guid && playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (movementInfo.transportGuid == guid); - LOG_INFO("Preserving transport on out-of-range: 0x", - std::hex, guid, std::dec, - " now=", playerAboardNow, - " sticky=", stickyAboard, - " movement=", movementSaysAboard); - continue; - } + switch (block.objectType) { + case ObjectType::PLAYER: + entity = std::make_shared(block.guid); + break; - LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); - // Trigger despawn callbacks before removing entity - if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { - creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { - playerDespawnCallback_(guid); - otherPlayerVisibleItemEntries_.erase(guid); - otherPlayerVisibleDirty_.erase(guid); - 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); - } - transportGuids_.erase(guid); - serverUpdatedTransportGuids_.erase(guid); - clearTransportAttachment(guid); - if (playerTransportGuid_ == guid) { - clearPlayerTransport(); - } - entityManager.removeEntity(guid); - } + case ObjectType::UNIT: + entity = std::make_shared(block.guid); + break; - // Process update blocks - bool newItemCreated = false; - for (const auto& block : data.blocks) { - switch (block.updateType) { - case UpdateType::CREATE_OBJECT: - case UpdateType::CREATE_OBJECT2: { - // Create new entity - std::shared_ptr entity; + case ObjectType::GAMEOBJECT: + entity = std::make_shared(block.guid); + break; - switch (block.objectType) { - case ObjectType::PLAYER: - entity = std::make_shared(block.guid); - break; + default: + entity = std::make_shared(block.guid); + entity->setType(block.objectType); + break; + } - case ObjectType::UNIT: - entity = std::make_shared(block.guid); - break; - - case ObjectType::GAMEOBJECT: - entity = std::make_shared(block.guid); - break; - - default: - entity = std::make_shared(block.guid); - entity->setType(block.objectType); - break; + // Set position from movement block (server → canonical) + if (block.hasMovement) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); + if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + serverRunSpeed_ = block.runSpeed; + } + // Track player-on-transport state + if (block.guid == playerGuid) { + if (block.onTransport) { + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + setPlayerOnTransport(block.transportGuid, canonicalOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } + LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, + " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); + } else { + // Don't clear client-side M2 transport boarding (trams) — + // the server doesn't know about client-detected transport attachment. + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport"); + clearPlayerTransport(); + } + } } - // Set position from movement block (server → canonical) + // Track transport-relative children so they follow parent transport motion. + if (block.guid != playerGuid && + (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + setTransportAttachment(block.guid, block.objectType, block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); + } + } + } + + // Set fields + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } + + // Add to manager + entityManager.addEntity(block.guid, entity); + + // For the local player, capture the full initial field state (CREATE_OBJECT carries the + // large baseline update-field set, including visible item fields on many cores). + // Later VALUES updates often only include deltas and may never touch visible item fields. + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + lastPlayerFields_ = entity->getFields(); + maybeDetectVisibleItemLayout(); + } + + // Auto-query names (Phase 1) + if (block.objectType == ObjectType::PLAYER) { + queryPlayerName(block.guid); + if (block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + } else if (block.objectType == ObjectType::UNIT) { + auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (it != block.fields.end() && it->second != 0) { + auto unit = std::static_pointer_cast(entity); + unit->setEntry(it->second); + // Set name from cache immediately if available + std::string cached = getCachedCreatureName(it->second); + if (!cached.empty()) { + unit->setName(cached); + } + queryCreatureInfo(it->second, block.guid); + } + } + + // Extract health/mana/power from fields (Phase 2) — single pass + if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + bool unitInitiallyDead = false; + const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); + const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); + const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); + const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); + const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); + const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); + const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); + const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); + const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); + const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + for (const auto& [key, val] : block.fields) { + // Check all specific fields BEFORE power/maxpower range checks. + // In Classic, power indices (23-27) are adjacent to maxHealth (28), + // and maxPower indices (29-33) are adjacent to level (34) and faction (35). + // A range check like "key >= powerBase && key < powerBase+7" would + // incorrectly capture maxHealth/level/faction in Classic's tight layout. + if (key == ufHealth) { + unit->setHealth(val); + if (block.objectType == ObjectType::UNIT && val == 0) { + unitInitiallyDead = true; + } + if (block.guid == playerGuid && val == 0) { + playerDead_ = true; + LOG_INFO("Player logged in dead"); + } + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } + else if (key == ufLevel) { + unit->setLevel(val); + } else if (key == ufFaction) { + unit->setFactionTemplate(val); + if (addonEventCallback_) { + std::string uid; + if (block.guid == playerGuid) uid = "player"; + else if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + if (!uid.empty()) + addonEventCallback_("UNIT_FACTION", {uid}); + } + } + else if (key == ufFlags) { + unit->setUnitFlags(val); + if (addonEventCallback_) { + std::string uid; + if (block.guid == playerGuid) uid = "player"; + else if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + if (!uid.empty()) + addonEventCallback_("UNIT_FLAGS", {uid}); + } + } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufDisplayId) { + unit->setDisplayId(val); + if (addonEventCallback_) { + std::string uid; + if (block.guid == playerGuid) uid = "player"; + else if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + else if (block.guid == petGuid_) uid = "pet"; + if (!uid.empty()) + addonEventCallback_("UNIT_MODEL_CHANGED", {uid}); + } + } + else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + else if (key == ufDynFlags) { + unit->setDynamicFlags(val); + if (block.objectType == ObjectType::UNIT && + ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { + unitInitiallyDead = true; + } + } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + } + else if (key == ufMountDisplayId) { + if (block.guid == playerGuid) { + uint32_t old = currentMountDisplayId_; + currentMountDisplayId_ = val; + if (val != old && mountCallback_) mountCallback_(val); + if (val != old && addonEventCallback_) + addonEventCallback_("UNIT_MODEL_CHANGED", {"player"}); + if (old == 0 && val != 0) { + // Just mounted — find the mount aura (indefinite duration, self-cast) + mountAuraSpellId_ = 0; + for (const auto& a : playerAuras) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { + mountAuraSpellId_ = a.spellId; + } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + mountAuraSpellId_ = fv; + break; + } + } + } + } + LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); + } + if (old != 0 && val == 0) { + mountAuraSpellId_ = 0; + for (auto& a : playerAuras) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; + } + } + unit->setMountDisplayId(val); + } + } + if (block.guid == playerGuid) { + constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; + if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { + onTaxiFlight_ = true; + taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); + sanitizeMovementForTaxi(); + applyTaxiMountForCurrentNode(); + } + } + if (block.guid == playerGuid && + (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { + playerDead_ = true; + LOG_INFO("Player logged in dead (dynamic flags)"); + } + // Detect ghost state on login via PLAYER_FLAGS + if (block.guid == playerGuid) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); + if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { + releasedSpirit_ = true; + playerDead_ = true; + LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); + // Query corpse position so minimap marker is accurate on reconnect + if (socket) { + network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); + socket->send(cq); + } + } + } + // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraField = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } + } + if (hasAuraField) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful + // Normalize to WotLK convention: 0x80 = negative (debuff) + uint8_t classicFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) + a.flags = (classicFlag & 0x02) ? 0x80u : 0u; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); + } + } + } + // Determine hostility from faction template for online creatures. + // Always call isHostileFaction — factionTemplate=0 defaults to hostile + // in the lookup rather than silently staying at the struct default (false). + unit->setHostile(isHostileFaction(unit->getFactionTemplate())); + // Trigger creature spawn callback for units/players with displayId + if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { + LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " has displayId=0 — no spawn (entry=", unit->getEntry(), + " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + } + if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { + if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately via spawnPlayerCharacter() + } else if (block.objectType == ObjectType::PLAYER) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); + } + } + if (unitInitiallyDead && npcDeathCallback_) { + npcDeathCallback_(block.guid); + } + } else if (creatureSpawnCallback_) { + LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " at (", + unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + float unitScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale, &raw, sizeof(float)); + if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; + } + } + } + creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); + if (unitInitiallyDead && npcDeathCallback_) { + 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)); + qsPkt.writeUInt64(block.guid); + socket->send(qsPkt); + } + } + } + // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) + if (block.objectType == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); + if (itDisp != block.fields.end()) { + go->setDisplayId(itDisp->second); + } + auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + if (itEntry != block.fields.end() && itEntry->second != 0) { + go->setEntry(itEntry->second); + auto cacheIt = gameObjectInfoCache_.find(itEntry->second); + if (cacheIt != gameObjectInfoCache_.end()) { + go->setName(cacheIt->second.name); + } + queryGameObjectInfo(itEntry->second, block.guid); + } + // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) + 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_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + " entry=", go->getEntry(), + " displayId=", go->getDisplayId(), + " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); + // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created + } + if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { + float goScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&goScale, &raw, sizeof(float)); + if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; + } + } + } + gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), + go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); + } + // Fire transport move callback for transports (position update on re-creation) + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, + go->getX(), go->getY(), go->getZ(), go->getOrientation()); + } + } + // Detect player's own corpse object so we have the position even when + // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). + if (block.objectType == ObjectType::CORPSE && block.hasMovement) { + // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) + uint16_t ownerLowIdx = 6; + auto ownerLowIt = block.fields.find(ownerLowIdx); + uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; + auto ownerHighIt = block.fields.find(ownerLowIdx + 1); + uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; + uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; + if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { + // Server coords from movement block + corpseGuid_ = block.guid; + corpseX_ = block.x; + corpseY_ = block.y; + corpseZ_ = block.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Corpse object detected: guid=0x", std::hex, corpseGuid_, std::dec, + " server=(", block.x, ", ", block.y, ", ", block.z, + ") map=", corpseMapId_); + } + } + + // Track online item objects (CONTAINER = bags, also tracked as items) + if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { + auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); + auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); + auto durIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_DURABILITY)); + auto maxDurIt= block.fields.find(fieldIndex(UF::ITEM_FIELD_MAXDURABILITY)); + const uint16_t enchBase = (fieldIndex(UF::ITEM_FIELD_STACK_COUNT) != 0xFFFF) + ? static_cast(fieldIndex(UF::ITEM_FIELD_STACK_COUNT) + 8u) : 0xFFFFu; + auto permEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase) : block.fields.end(); + auto tempEnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 3u) : block.fields.end(); + auto sock1EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 6u) : block.fields.end(); + auto sock2EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 9u) : block.fields.end(); + auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); + if (entryIt != block.fields.end() && entryIt->second != 0) { + // Preserve existing info when doing partial updates + OnlineItemInfo info = onlineItems_.count(block.guid) + ? onlineItems_[block.guid] : OnlineItemInfo{}; + info.entry = entryIt->second; + if (stackIt != block.fields.end()) info.stackCount = stackIt->second; + if (durIt != block.fields.end()) info.curDurability = durIt->second; + if (maxDurIt != block.fields.end()) info.maxDurability = maxDurIt->second; + if (permEnchIt != block.fields.end()) info.permanentEnchantId = permEnchIt->second; + if (tempEnchIt != block.fields.end()) info.temporaryEnchantId = tempEnchIt->second; + if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; + if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; + if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second; + bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); + onlineItems_[block.guid] = info; + if (isNew) newItemCreated = true; + queryItemInfo(info.entry, block.guid); + } + // Extract container slot GUIDs for bags + if (block.objectType == ObjectType::CONTAINER) { + extractContainerFields(block.guid, block.fields); + } + } + + // Extract XP / inventory slot / skill fields for player entity + if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { + // Auto-detect coinage index using the previous snapshot vs this full snapshot. + maybeDetectCoinageIndex(lastPlayerFields_, block.fields); + + lastPlayerFields_ = block.fields; + detectInventorySlotBases(block.fields); + + if (kVerboseUpdateObject) { + uint16_t maxField = 0; + for (const auto& [key, _val] : block.fields) { + if (key > maxField) maxField = key; + } + LOG_INFO("Player update with ", block.fields.size(), + " fields (max index=", maxField, ")"); + } + + 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 ufHonor = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); + const uint16_t ufArena = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAP = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAP = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1 = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonus = fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPct = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePct = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPct = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPct = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPct = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1 = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1 = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + 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) { + if (ch.guid == playerGuid) { ch.level = val; break; } + } + } + else if (key == ufCoinage) { + uint64_t oldMoney = playerMoneyCopper_; + playerMoneyCopper_ = val; + LOG_DEBUG("Money set from update fields: ", val, " copper"); + if (val != oldMoney && addonEventCallback_) + addonEventCallback_("PLAYER_MONEY", {}); + } + else if (ufHonor != 0xFFFF && key == ufHonor) { + playerHonorPoints_ = val; + LOG_DEBUG("Honor points from update fields: ", val); + } + else if (ufArena != 0xFFFF && key == ufArena) { + playerArenaPoints_ = val; + LOG_DEBUG("Arena points from update fields: ", val); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + playerArmorRating_ = static_cast(val); + LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots)); + inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte != 0); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); + } + else if (ufMeleeAP != 0xFFFF && key == ufMeleeAP) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAP != 0xFFFF && key == ufRangedAP) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1 != 0xFFFF && key >= ufSpDmg1 && key < ufSpDmg1 + 7) { + playerSpellDmgBonus_[key - ufSpDmg1] = static_cast(val); + } + else if (ufHealBonus != 0xFFFF && key == ufHealBonus) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPct != 0xFFFF && key == ufBlockPct) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePct != 0xFFFF && key == ufDodgePct) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPct != 0xFFFF && key == ufParryPct) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPct != 0xFFFF && key == ufCritPct) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPct != 0xFFFF && key == ufRCritPct) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1 != 0xFFFF && key >= ufSCrit1 && key < ufSCrit1 + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1], &val, 4); + } + else if (ufRating1 != 0xFFFF && key >= ufRating1 && key < ufRating1 + 25) { + playerCombatRatings_[key - ufRating1] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } + // Do not synthesize quest-log entries from raw update-field slots. + // Slot layouts differ on some classic-family realms and can produce + // phantom "already accepted" quests that block quest acceptance. + } + if (applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) rebuildOnlineInventory(); + maybeDetectVisibleItemLayout(); + extractSkillFields(lastPlayerFields_); + extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); + } + break; + } + + case UpdateType::VALUES: { + // Update existing entity fields + auto entity = entityManager.getEntity(block.guid); + if (entity) { if (block.hasMovement) { glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - if (block.guid == playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - } - // Track player-on-transport state - if (block.guid == playerGuid) { - if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } - LOG_INFO("Player on transport: 0x", std::hex, playerTransportGuid_, std::dec, - " offset=(", playerTransportOffset_.x, ", ", playerTransportOffset_.y, ", ", playerTransportOffset_.z, ")"); - } else { - // Don't clear client-side M2 transport boarding (trams) — - // the server doesn't know about client-detected transport attachment. - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); - } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport"); - clearPlayerTransport(); - } - } - } - - // Track transport-relative children so they follow parent transport motion. - if (block.guid != playerGuid && - (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, block.objectType, block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - } - - // Set fields - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - // Add to manager - entityManager.addEntity(block.guid, entity); - - // For the local player, capture the full initial field state (CREATE_OBJECT carries the - // large baseline update-field set, including visible item fields on many cores). - // Later VALUES updates often only include deltas and may never touch visible item fields. - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - lastPlayerFields_ = entity->getFields(); - maybeDetectVisibleItemLayout(); - } - - // Auto-query names (Phase 1) - if (block.objectType == ObjectType::PLAYER) { - queryPlayerName(block.guid); - if (block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - } else if (block.objectType == ObjectType::UNIT) { - auto it = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (it != block.fields.end() && it->second != 0) { - auto unit = std::static_pointer_cast(entity); - unit->setEntry(it->second); - // Set name from cache immediately if available - std::string cached = getCachedCreatureName(it->second); - if (!cached.empty()) { - unit->setName(cached); - } - queryCreatureInfo(it->second, block.guid); - } - } - - // Extract health/mana/power from fields (Phase 2) — single pass - if (block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - bool unitInitiallyDead = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - for (const auto& [key, val] : block.fields) { - // Check all specific fields BEFORE power/maxpower range checks. - // In Classic, power indices (23-27) are adjacent to maxHealth (28), - // and maxPower indices (29-33) are adjacent to level (34) and faction (35). - // A range check like "key >= powerBase && key < powerBase+7" would - // incorrectly capture maxHealth/level/faction in Classic's tight layout. - if (key == ufHealth) { - unit->setHealth(val); - if (block.objectType == ObjectType::UNIT && val == 0) { - unitInitiallyDead = true; - } - if (block.guid == playerGuid && val == 0) { - playerDead_ = true; - LOG_INFO("Player logged in dead"); - } - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufLevel) { - unit->setLevel(val); - } else if (key == ufFaction) { unit->setFactionTemplate(val); } - else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufDisplayId) { unit->setDisplayId(val); } - else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - else if (key == ufDynFlags) { - unit->setDynamicFlags(val); - if (block.objectType == ObjectType::UNIT && - ((val & UNIT_DYNFLAG_DEAD) != 0 || (val & UNIT_DYNFLAG_LOOTABLE) != 0)) { - unitInitiallyDead = true; - } - } - // Power/maxpower range checks AFTER all specific fields - else if (key >= ufPowerBase && key < ufPowerBase + 7) { - unit->setPowerByType(static_cast(key - ufPowerBase), val); - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); - } - else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (old == 0 && val != 0) { - // Just mounted — find the mount aura (indefinite duration, self-cast) - mountAuraSpellId_ = 0; - for (const auto& a : playerAuras) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } - } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } - } - } - } - LOG_INFO("Mount detected: displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - for (auto& a : playerAuras) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; - } - } - unit->setMountDisplayId(val); - } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - } - if (block.guid == playerGuid) { - constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; - if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !onTaxiFlight_ && taxiLandingCooldown_ <= 0.0f) { - onTaxiFlight_ = true; - taxiStartGrace_ = std::max(taxiStartGrace_, 2.0f); - sanitizeMovementForTaxi(); - applyTaxiMountForCurrentNode(); - } - } - if (block.guid == playerGuid && - (unit->getDynamicFlags() & UNIT_DYNFLAG_DEAD) != 0) { - playerDead_ = true; - LOG_INFO("Player logged in dead (dynamic flags)"); - } - // Detect ghost state on login via PLAYER_FLAGS - if (block.guid == playerGuid) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); - if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { - releasedSpirit_ = true; - playerDead_ = true; - LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); - } - } - // Determine hostility from faction template for online creatures - if (unit->getFactionTemplate() != 0) { - unit->setHostile(isHostileFaction(unit->getFactionTemplate())); - } - // Trigger creature spawn callback for units/players with displayId - if (block.objectType == ObjectType::UNIT && unit->getDisplayId() == 0) { - LOG_WARNING("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " has displayId=0 — no spawn (entry=", unit->getEntry(), - " at ", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - } - if ((block.objectType == ObjectType::UNIT || block.objectType == ObjectType::PLAYER) && unit->getDisplayId() != 0) { - if (block.objectType == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately via spawnPlayerCharacter() - } else if (block.objectType == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } - } - } else if (creatureSpawnCallback_) { - LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, - " displayId=", unit->getDisplayId(), " at (", - unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - if (unitInitiallyDead && npcDeathCallback_) { - npcDeathCallback_(block.guid); - } - } - // 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)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); - } - } - } - // Extract displayId and entry for gameobjects (3.3.5a: GAMEOBJECT_DISPLAYID = field 8) - if (block.objectType == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - auto itDisp = block.fields.find(fieldIndex(UF::GAMEOBJECT_DISPLAYID)); - if (itDisp != block.fields.end()) { - go->setDisplayId(itDisp->second); - } - auto itEntry = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - if (itEntry != block.fields.end() && itEntry->second != 0) { - go->setEntry(itEntry->second); - auto cacheIt = gameObjectInfoCache_.find(itEntry->second); - if (cacheIt != gameObjectInfoCache_.end()) { - go->setName(cacheIt->second.name); - } - queryGameObjectInfo(itEntry->second, block.guid); - } - // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) - LOG_WARNING("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, - " entry=", go->getEntry(), - " displayId=", go->getDisplayId(), - " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); - // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created - } - if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { - gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), - go->getX(), go->getY(), go->getZ(), go->getOrientation()); - } - // Fire transport move callback for transports (position update on re-creation) - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, - go->getX(), go->getY(), go->getZ(), go->getOrientation()); - } - } - // Track online item objects (CONTAINER = bags, also tracked as items) - if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { - auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); - auto stackIt = block.fields.find(fieldIndex(UF::ITEM_FIELD_STACK_COUNT)); - if (entryIt != block.fields.end() && entryIt->second != 0) { - OnlineItemInfo info; - info.entry = entryIt->second; - info.stackCount = (stackIt != block.fields.end()) ? stackIt->second : 1; - bool isNew = (onlineItems_.find(block.guid) == onlineItems_.end()); - onlineItems_[block.guid] = info; - if (isNew) newItemCreated = true; - queryItemInfo(info.entry, block.guid); - } - // Extract container slot GUIDs for bags - if (block.objectType == ObjectType::CONTAINER) { - extractContainerFields(block.guid, block.fields); - } - } - - // Extract XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid && block.objectType == ObjectType::PLAYER) { - // Auto-detect coinage index using the previous snapshot vs this full snapshot. - maybeDetectCoinageIndex(lastPlayerFields_, block.fields); - - lastPlayerFields_ = block.fields; - detectInventorySlotBases(block.fields); - - if (kVerboseUpdateObject) { - uint16_t maxField = 0; - for (const auto& [key, _val] : block.fields) { - if (key > maxField) maxField = key; - } - LOG_INFO("Player update with ", block.fields.size(), - " fields (max index=", maxField, ")"); - } - - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - 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); - const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { playerXp_ = val; } - else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - for (auto& ch : characters) { - if (ch.guid == playerGuid) { ch.level = val; break; } - } - } - else if (key == ufCoinage) { - playerMoneyCopper_ = val; - LOG_DEBUG("Money set from update fields: ", val, " copper"); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - LOG_DEBUG("Armor rating from update fields: ", playerArmorRating_); - } - else if (ufPBytes2 != 0xFFFF && key == ufPBytes2) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - } - // Do not synthesize quest-log entries from raw update-field slots. - // Slot layouts differ on some classic-family realms and can produce - // phantom "already accepted" quests that block quest acceptance. - } - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - maybeDetectVisibleItemLayout(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - } - break; - } - - case UpdateType::VALUES: { - // Update existing entity fields - auto entity = entityManager.getEntity(block.guid); - if (entity) { - if (block.hasMovement) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - - if (block.guid != playerGuid && - (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { - if (block.onTransport && block.transportGuid != 0) { - glm::vec3 localOffset = core::coords::serverToCanonical( - glm::vec3(block.transportX, block.transportY, block.transportZ)); - const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING - float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); - setTransportAttachment(block.guid, entity->getType(), block.transportGuid, - localOffset, hasLocalOrientation, localOriCanonical); - if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); - entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); - } - } else { - clearTransportAttachment(block.guid); - } - } - } - - for (const auto& field : block.fields) { - entity->setField(field.first, field.second); - } - - if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { - updateOtherPlayerVisibleItems(block.guid, entity->getFields()); - } - - // Update cached health/mana/power values (Phase 2) — single pass - if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(entity); - constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - uint32_t oldDisplayId = unit->getDisplayId(); - bool displayIdChanged = false; - bool npcDeathNotified = false; - bool npcRespawnNotified = false; - const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); - const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); - const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); - const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); - const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); - const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); - const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); - const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); - const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); - const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); - const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); - for (const auto& [key, val] : block.fields) { - if (key == ufHealth) { - uint32_t oldHealth = unit->getHealth(); - unit->setHealth(val); - if (val == 0) { - if (block.guid == autoAttackTarget) { - stopAutoAttack(); - } - hostileAttackers_.erase(block.guid); - if (block.guid == playerGuid) { - playerDead_ = true; - releasedSpirit_ = false; - stopAutoAttack(); - LOG_INFO("Player died!"); - } - if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (oldHealth == 0 && val > 0) { - if (block.guid == playerGuid) { - playerDead_ = false; - if (!releasedSpirit_) { - LOG_INFO("Player resurrected!"); - } else { - LOG_INFO("Player entered ghost form"); - } - } - if (entity->getType() == ObjectType::UNIT && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - // Specific fields checked BEFORE power/maxpower range checks - // (Classic packs maxHealth/level/faction adjacent to power indices) - } else if (key == ufMaxHealth) { unit->setMaxHealth(val); } - else if (key == ufBytes0) { - unit->setPowerType(static_cast((val >> 24) & 0xFF)); - } else if (key == ufFlags) { unit->setUnitFlags(val); } - else if (key == ufDynFlags) { - uint32_t oldDyn = unit->getDynamicFlags(); - unit->setDynamicFlags(val); - if (block.guid == playerGuid) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - playerDead_ = true; - releasedSpirit_ = false; - LOG_INFO("Player died (dynamic flags)"); - } else if (wasDead && !nowDead) { - playerDead_ = false; - releasedSpirit_ = false; - LOG_INFO("Player resurrected (dynamic flags)"); - } - } else if (entity->getType() == ObjectType::UNIT) { - bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; - bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; - if (!wasDead && nowDead) { - if (!npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } else if (wasDead && !nowDead) { - if (!npcRespawnNotified && npcRespawnCallback_) { - npcRespawnCallback_(block.guid); - npcRespawnNotified = true; - } - } - } - } else if (key == ufLevel) { - uint32_t oldLvl = unit->getLevel(); - unit->setLevel(val); - if (block.guid != playerGuid && - entity->getType() == ObjectType::PLAYER && - val > oldLvl && oldLvl > 0 && - otherPlayerLevelUpCallback_) { - otherPlayerLevelUpCallback_(block.guid, val); - } - } - else if (key == ufFaction) { - unit->setFactionTemplate(val); - unit->setHostile(isHostileFaction(val)); - } else if (key == ufDisplayId) { - if (val != unit->getDisplayId()) { - unit->setDisplayId(val); - displayIdChanged = true; - } - } else if (key == ufMountDisplayId) { - if (block.guid == playerGuid) { - uint32_t old = currentMountDisplayId_; - currentMountDisplayId_ = val; - if (val != old && mountCallback_) mountCallback_(val); - if (old == 0 && val != 0) { - mountAuraSpellId_ = 0; - for (const auto& a : playerAuras) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { - mountAuraSpellId_ = a.spellId; - } - } - // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (mountAuraSpellId_ == 0) { - const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); - if (ufAuras != 0xFFFF) { - for (const auto& [fk, fv] : block.fields) { - if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - mountAuraSpellId_ = fv; - break; - } - } - } - } - LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); - } - if (old != 0 && val == 0) { - mountAuraSpellId_ = 0; - for (auto& a : playerAuras) - if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; - } - } - unit->setMountDisplayId(val); - } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } - // Power/maxpower range checks AFTER all specific fields - else if (key >= ufPowerBase && key < ufPowerBase + 7) { - unit->setPowerByType(static_cast(key - ufPowerBase), val); - } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { - unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); - } - } - - // Some units/players are created without displayId and get it later via VALUES. - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && - displayIdChanged && - unit->getDisplayId() != 0 && - unit->getDisplayId() != oldDisplayId) { - if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { - // Skip local player — spawned separately - } else if (entity->getType() == ObjectType::PLAYER) { - if (playerSpawnCallback_) { - uint8_t race = 0, gender = 0, facial = 0; - uint32_t appearanceBytes = 0; - // Use the entity's accumulated field state, not just this block's changed fields. - if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, - appearanceBytes, facial, - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - } - } - } else if (creatureSpawnCallback_) { - creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); - bool isDeadNow = (unit->getHealth() == 0) || - ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); - if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { - npcDeathCallback_(block.guid); - npcDeathNotified = true; - } - } - if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { - network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); - qsPkt.writeUInt64(block.guid); - socket->send(qsPkt); - } - } - } - // Update XP / inventory slot / skill fields for player entity - if (block.guid == playerGuid) { - const bool needCoinageDetectSnapshot = - (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); - std::map oldFieldsSnapshot; - if (needCoinageDetectSnapshot) { - oldFieldsSnapshot = lastPlayerFields_; - } - if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - serverRunSpeed_ = block.runSpeed; - // Some server dismount paths update run speed without updating mount display field. - if (!onTaxiFlight_ && !taxiMountActive_ && - currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { - LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, - " displayId=", currentMountDisplayId_); - currentMountDisplayId_ = 0; - if (mountCallback_) { - mountCallback_(0); - } - } - } - auto mergeHint = lastPlayerFields_.end(); - for (const auto& [key, val] : block.fields) { - mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); - } - if (needCoinageDetectSnapshot) { - maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); - } - maybeDetectVisibleItemLayout(); - detectInventorySlotBases(block.fields); - bool slotsChanged = false; - const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); - const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); - const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); - const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); - const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); - const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); - const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); - for (const auto& [key, val] : block.fields) { - if (key == ufPlayerXp) { - playerXp_ = val; - LOG_DEBUG("XP updated: ", val); - } - else if (key == ufPlayerNextXp) { - playerNextLevelXp_ = val; - LOG_DEBUG("Next level XP updated: ", val); - } - else if (key == ufPlayerLevel) { - serverPlayerLevel_ = val; - LOG_DEBUG("Level updated: ", val); - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.level = val; - break; - } - } - } - else if (key == ufCoinage) { - playerMoneyCopper_ = val; - LOG_DEBUG("Money updated via VALUES: ", val, " copper"); - } - else if (ufArmor != 0xFFFF && key == ufArmor) { - playerArmorRating_ = static_cast(val); - } - else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { - uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - LOG_WARNING("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, - " bankBagSlots=", static_cast(bankBagSlots)); - inventory.setPurchasedBankBagSlots(bankBagSlots); - } - else if (key == ufPlayerFlags) { - constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - bool wasGhost = releasedSpirit_; - bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; - if (!wasGhost && nowGhost) { - releasedSpirit_ = true; - LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); - } else if (wasGhost && !nowGhost) { - releasedSpirit_ = false; - playerDead_ = false; - repopPending_ = false; - resurrectPending_ = false; - LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); - } - } - } - // Do not auto-create quests from VALUES quest-log slot fields for the - // same reason as CREATE_OBJECT2 above (can be misaligned per realm). - if (applyInventoryFields(block.fields)) slotsChanged = true; - if (slotsChanged) rebuildOnlineInventory(); - extractSkillFields(lastPlayerFields_); - extractExploredZoneFields(lastPlayerFields_); - } - - // Update item stack count for online items - if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { - bool inventoryChanged = false; - const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); - const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); - const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); - for (const auto& [key, val] : block.fields) { - if (key == itemStackField) { - auto it = onlineItems_.find(block.guid); - if (it != onlineItems_.end() && it->second.stackCount != val) { - it->second.stackCount = val; - inventoryChanged = true; - } - } - } - // Update container slot GUIDs on bag content changes - if (entity->getType() == ObjectType::CONTAINER) { - for (const auto& [key, _] : block.fields) { - if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || - (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { - inventoryChanged = true; - break; - } - } - extractContainerFields(block.guid, block.fields); - } - if (inventoryChanged) { - rebuildOnlineInventory(); - } - } - if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { - if (transportGuids_.count(block.guid) && transportMoveCallback_) { - serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } else if (gameObjectMoveCallback_) { - gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), - entity->getZ(), entity->getOrientation()); - } - } - - LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); - } else { - } - break; - } - - case UpdateType::MOVEMENT: { - // Diagnostic: Log if we receive MOVEMENT blocks for transports - if (transportGuids_.count(block.guid)) { - LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); - } - - // Update entity position (server → canonical) - auto entity = entityManager.getEntity(block.guid); - if (entity) { - glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); - float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); - entity->setPosition(pos.x, pos.y, pos.z, oCanonical); - LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); if (block.guid != playerGuid && (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { @@ -8269,78 +12445,737 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { clearTransportAttachment(block.guid); } } + } - if (block.guid == playerGuid) { - movementInfo.orientation = oCanonical; + for (const auto& field : block.fields) { + entity->setField(field.first, field.second); + } - // Track player-on-transport state from MOVEMENT updates - if (block.onTransport) { - setPlayerOnTransport(block.transportGuid, glm::vec3(0.0f)); - // Convert transport offset from server → canonical coordinates - glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); - playerTransportOffset_ = core::coords::serverToCanonical(serverOffset); - if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { - glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); - entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - movementInfo.x = composed.x; - movementInfo.y = composed.y; - movementInfo.z = composed.z; - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; + if (entity->getType() == ObjectType::PLAYER && block.guid != playerGuid) { + updateOtherPlayerVisibleItems(block.guid, entity->getFields()); + } + + // Update cached health/mana/power values (Phase 2) — single pass + if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(entity); + constexpr uint32_t UNIT_DYNFLAG_DEAD = 0x0008; + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + uint32_t oldDisplayId = unit->getDisplayId(); + bool displayIdChanged = false; + bool npcDeathNotified = false; + bool npcRespawnNotified = false; + bool healthChanged = false; + bool powerChanged = false; + const uint16_t ufHealth = fieldIndex(UF::UNIT_FIELD_HEALTH); + const uint16_t ufPowerBase = fieldIndex(UF::UNIT_FIELD_POWER1); + const uint16_t ufMaxHealth = fieldIndex(UF::UNIT_FIELD_MAXHEALTH); + const uint16_t ufMaxPowerBase = fieldIndex(UF::UNIT_FIELD_MAXPOWER1); + const uint16_t ufLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufFaction = fieldIndex(UF::UNIT_FIELD_FACTIONTEMPLATE); + const uint16_t ufFlags = fieldIndex(UF::UNIT_FIELD_FLAGS); + const uint16_t ufDynFlags = fieldIndex(UF::UNIT_DYNAMIC_FLAGS); + const uint16_t ufDisplayId = fieldIndex(UF::UNIT_FIELD_DISPLAYID); + const uint16_t ufMountDisplayId = fieldIndex(UF::UNIT_FIELD_MOUNTDISPLAYID); + const uint16_t ufNpcFlags = fieldIndex(UF::UNIT_NPC_FLAGS); + const uint16_t ufBytes0 = fieldIndex(UF::UNIT_FIELD_BYTES_0); + for (const auto& [key, val] : block.fields) { + if (key == ufHealth) { + uint32_t oldHealth = unit->getHealth(); + unit->setHealth(val); + healthChanged = true; + if (val == 0) { + if (block.guid == autoAttackTarget) { + stopAutoAttack(); + } + hostileAttackers_.erase(block.guid); + if (block.guid == playerGuid) { + playerDead_ = true; + releasedSpirit_ = false; + stopAutoAttack(); + // Cache death position as corpse location. + // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so + // this is the primary source for canReclaimCorpse(). + // movementInfo is canonical (x=north, y=west); corpseX_/Y_ + // are raw server coords (x=west, y=north) — swap axes. + corpseX_ = movementInfo.y; // canonical west = server X + corpseY_ = movementInfo.x; // canonical north = server Y + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died! Corpse position cached at server=(", + corpseX_, ",", corpseY_, ",", corpseZ_, + ") map=", corpseMapId_); + } + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (oldHealth == 0 && val > 0) { + if (block.guid == playerGuid) { + playerDead_ = false; + if (!releasedSpirit_) { + LOG_INFO("Player resurrected!"); + } else { + LOG_INFO("Player entered ghost form"); + } + } + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && npcRespawnCallback_) { + npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } } - LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); - } else { - movementInfo.x = pos.x; - movementInfo.y = pos.y; - movementInfo.z = pos.z; - // Don't clear client-side M2 transport boarding - bool isClientM2Transport = false; - if (playerTransportGuid_ != 0 && transportManager_) { - auto* tr = transportManager_->getTransport(playerTransportGuid_); - isClientM2Transport = (tr && tr->isM2); + // Specific fields checked BEFORE power/maxpower range checks + // (Classic packs maxHealth/level/faction adjacent to power indices) + } else if (key == ufMaxHealth) { unit->setMaxHealth(val); healthChanged = true; } + else if (key == ufBytes0) { + unit->setPowerType(static_cast((val >> 24) & 0xFF)); + } else if (key == ufFlags) { unit->setUnitFlags(val); } + else if (key == ufDynFlags) { + uint32_t oldDyn = unit->getDynamicFlags(); + unit->setDynamicFlags(val); + if (block.guid == playerGuid) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + playerDead_ = true; + releasedSpirit_ = false; + corpseX_ = movementInfo.y; + corpseY_ = movementInfo.x; + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); + } else if (wasDead && !nowDead) { + playerDead_ = false; + releasedSpirit_ = false; + selfResAvailable_ = false; + LOG_INFO("Player resurrected (dynamic flags)"); + } + } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { + bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; + bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; + if (!wasDead && nowDead) { + if (!npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (wasDead && !nowDead) { + if (!npcRespawnNotified && npcRespawnCallback_) { + npcRespawnCallback_(block.guid); + npcRespawnNotified = true; + } + } } - if (playerTransportGuid_ != 0 && !isClientM2Transport) { - LOG_INFO("Player left transport (MOVEMENT)"); - clearPlayerTransport(); + } else if (key == ufLevel) { + uint32_t oldLvl = unit->getLevel(); + unit->setLevel(val); + if (block.guid != playerGuid && + entity->getType() == ObjectType::PLAYER && + val > oldLvl && oldLvl > 0 && + otherPlayerLevelUpCallback_) { + otherPlayerLevelUpCallback_(block.guid, val); + } + } + else if (key == ufFaction) { + unit->setFactionTemplate(val); + unit->setHostile(isHostileFaction(val)); + } else if (key == ufDisplayId) { + if (val != unit->getDisplayId()) { + unit->setDisplayId(val); + displayIdChanged = true; + } + } else if (key == ufMountDisplayId) { + if (block.guid == playerGuid) { + uint32_t old = currentMountDisplayId_; + currentMountDisplayId_ = val; + if (val != old && mountCallback_) mountCallback_(val); + if (val != old && addonEventCallback_) + addonEventCallback_("UNIT_MODEL_CHANGED", {"player"}); + if (old == 0 && val != 0) { + mountAuraSpellId_ = 0; + for (const auto& a : playerAuras) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == playerGuid) { + mountAuraSpellId_ = a.spellId; + } + } + // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block + if (mountAuraSpellId_ == 0) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + if (ufAuras != 0xFFFF) { + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { + mountAuraSpellId_ = fv; + break; + } + } + } + } + LOG_INFO("Mount detected (values update): displayId=", val, " auraSpellId=", mountAuraSpellId_); + } + if (old != 0 && val == 0) { + mountAuraSpellId_ = 0; + for (auto& a : playerAuras) + if (!a.isEmpty() && a.maxDurationMs < 0) a = AuraSlot{}; + } + } + unit->setMountDisplayId(val); + } else if (key == ufNpcFlags) { unit->setNpcFlags(val); } + // Power/maxpower range checks AFTER all specific fields + else if (key >= ufPowerBase && key < ufPowerBase + 7) { + unit->setPowerByType(static_cast(key - ufPowerBase), val); + powerChanged = true; + } else if (key >= ufMaxPowerBase && key < ufMaxPowerBase + 7) { + unit->setMaxPowerByType(static_cast(key - ufMaxPowerBase), val); + powerChanged = true; + } + } + + // Fire UNIT_HEALTH / UNIT_POWER events for Lua addons + if (addonEventCallback_ && (healthChanged || powerChanged)) { + std::string unitId; + if (block.guid == playerGuid) unitId = "player"; + else if (block.guid == targetGuid) unitId = "target"; + else if (block.guid == focusGuid) unitId = "focus"; + else if (block.guid == petGuid_) unitId = "pet"; + if (!unitId.empty()) { + if (healthChanged) addonEventCallback_("UNIT_HEALTH", {unitId}); + if (powerChanged) addonEventCallback_("UNIT_POWER", {unitId}); + } + } + + // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraUpdate = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } + } + if (hasAuraUpdate) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); } } } - // Fire transport move callback if this is a known transport + // Some units/players are created without displayId and get it later via VALUES. + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && + displayIdChanged && + unit->getDisplayId() != 0 && + unit->getDisplayId() != oldDisplayId) { + if (entity->getType() == ObjectType::PLAYER && block.guid == playerGuid) { + // Skip local player — spawned separately + } else if (entity->getType() == ObjectType::PLAYER) { + if (playerSpawnCallback_) { + uint8_t race = 0, gender = 0, facial = 0; + uint32_t appearanceBytes = 0; + // Use the entity's accumulated field state, not just this block's changed fields. + if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { + playerSpawnCallback_(block.guid, unit->getDisplayId(), race, gender, + appearanceBytes, facial, + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + } else { + LOG_WARNING("[Spawn] PLAYER guid=0x", std::hex, block.guid, std::dec, + " displayId=", unit->getDisplayId(), " appearance extraction failed (VALUES update) — model will not render"); + } + } + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } else if (creatureSpawnCallback_) { + float unitScale2 = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale2, &raw, sizeof(float)); + if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; + } + } + } + creatureSpawnCallback_(block.guid, unit->getDisplayId(), + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); + bool isDeadNow = (unit->getHealth() == 0) || + ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); + if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { + npcDeathCallback_(block.guid); + npcDeathNotified = true; + } + } + if (entity->getType() == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { + network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); + qsPkt.writeUInt64(block.guid); + socket->send(qsPkt); + } + // Fire UNIT_MODEL_CHANGED for addons that track model swaps + if (addonEventCallback_) { + std::string uid; + if (block.guid == targetGuid) uid = "target"; + else if (block.guid == focusGuid) uid = "focus"; + else if (block.guid == petGuid_) uid = "pet"; + if (!uid.empty()) + addonEventCallback_("UNIT_MODEL_CHANGED", {uid}); + } + } + } + // Update XP / inventory slot / skill fields for player entity + if (block.guid == playerGuid) { + const bool needCoinageDetectSnapshot = + (pendingMoneyDelta_ != 0 && pendingMoneyDeltaTimer_ > 0.0f); + std::map oldFieldsSnapshot; + if (needCoinageDetectSnapshot) { + oldFieldsSnapshot = lastPlayerFields_; + } + if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + serverRunSpeed_ = block.runSpeed; + // Some server dismount paths update run speed without updating mount display field. + if (!onTaxiFlight_ && !taxiMountActive_ && + currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { + LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, + " displayId=", currentMountDisplayId_); + currentMountDisplayId_ = 0; + if (mountCallback_) { + mountCallback_(0); + } + } + } + auto mergeHint = lastPlayerFields_.end(); + for (const auto& [key, val] : block.fields) { + mergeHint = lastPlayerFields_.insert_or_assign(mergeHint, key, val); + } + if (needCoinageDetectSnapshot) { + maybeDetectCoinageIndex(oldFieldsSnapshot, lastPlayerFields_); + } + maybeDetectVisibleItemLayout(); + detectInventorySlotBases(block.fields); + bool slotsChanged = false; + const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); + const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXpV = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); + const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); + const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); + const uint16_t ufHonorV = fieldIndex(UF::PLAYER_FIELD_HONOR_CURRENCY); + const uint16_t ufArenaV = fieldIndex(UF::PLAYER_FIELD_ARENA_CURRENCY); + const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); + const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); + const uint16_t ufPBytesV = fieldIndex(UF::PLAYER_BYTES); + const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; + const uint16_t ufMeleeAPV = fieldIndex(UF::UNIT_FIELD_ATTACK_POWER); + const uint16_t ufRangedAPV = fieldIndex(UF::UNIT_FIELD_RANGED_ATTACK_POWER); + const uint16_t ufSpDmg1V = fieldIndex(UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS); + const uint16_t ufHealBonusV= fieldIndex(UF::PLAYER_FIELD_MOD_HEALING_DONE_POS); + const uint16_t ufBlockPctV = fieldIndex(UF::PLAYER_BLOCK_PERCENTAGE); + const uint16_t ufDodgePctV = fieldIndex(UF::PLAYER_DODGE_PERCENTAGE); + const uint16_t ufParryPctV = fieldIndex(UF::PLAYER_PARRY_PERCENTAGE); + const uint16_t ufCritPctV = fieldIndex(UF::PLAYER_CRIT_PERCENTAGE); + const uint16_t ufRCritPctV = fieldIndex(UF::PLAYER_RANGED_CRIT_PERCENTAGE); + const uint16_t ufSCrit1V = fieldIndex(UF::PLAYER_SPELL_CRIT_PERCENTAGE1); + const uint16_t ufRating1V = fieldIndex(UF::PLAYER_FIELD_COMBAT_RATING_1); + for (const auto& [key, val] : block.fields) { + if (key == ufPlayerXp) { + playerXp_ = val; + LOG_DEBUG("XP updated: ", val); + if (addonEventCallback_) + addonEventCallback_("PLAYER_XP_UPDATE", {std::to_string(val)}); + } + else if (key == ufPlayerNextXp) { + playerNextLevelXp_ = val; + LOG_DEBUG("Next level XP updated: ", val); + } + else if (ufPlayerRestedXpV != 0xFFFF && key == ufPlayerRestedXpV) { + playerRestedXp_ = val; + } + else if (key == ufPlayerLevel) { + serverPlayerLevel_ = val; + LOG_DEBUG("Level updated: ", val); + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.level = val; + break; + } + } + } + else if (key == ufCoinage) { + uint64_t oldM = playerMoneyCopper_; + playerMoneyCopper_ = val; + LOG_DEBUG("Money updated via VALUES: ", val, " copper"); + if (val != oldM && addonEventCallback_) + addonEventCallback_("PLAYER_MONEY", {}); + } + else if (ufHonorV != 0xFFFF && key == ufHonorV) { + playerHonorPoints_ = val; + LOG_DEBUG("Honor points updated: ", val); + } + else if (ufArenaV != 0xFFFF && key == ufArenaV) { + playerArenaPoints_ = val; + LOG_DEBUG("Arena points updated: ", val); + } + else if (ufArmor != 0xFFFF && key == ufArmor) { + playerArmorRating_ = static_cast(val); + } + else if (ufArmor != 0xFFFF && key > ufArmor && key <= ufArmor + 6) { + playerResistances_[key - ufArmor - 1] = static_cast(val); + } + else if (ufPBytesV != 0xFFFF && key == ufPBytesV) { + // PLAYER_BYTES changed (barber shop, polymorph, etc.) + // Update the Character struct so inventory preview refreshes + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.appearanceBytes = val; + break; + } + } + if (appearanceChangedCallback_) + appearanceChangedCallback_(); + } + else if (ufPBytes2v != 0xFFFF && key == ufPBytes2v) { + // Byte 0 (bits 0-7): facial hair / piercings + uint8_t facialHair = static_cast(val & 0xFF); + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.facialFeatures = facialHair; + break; + } + } + uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); + LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, + " bankBagSlots=", static_cast(bankBagSlots), + " facial=", static_cast(facialHair)); + inventory.setPurchasedBankBagSlots(bankBagSlots); + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY + uint8_t restStateByte = static_cast((val >> 24) & 0xFF); + isResting_ = (restStateByte != 0); + if (appearanceChangedCallback_) + appearanceChangedCallback_(); + } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); + } + else if (key == ufPlayerFlags) { + constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; + bool wasGhost = releasedSpirit_; + bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; + 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; + selfResAvailable_ = false; + corpseMapId_ = 0; // corpse reclaimed + corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; + LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); + if (addonEventCallback_) addonEventCallback_("PLAYER_ALIVE", {}); + if (ghostStateCallback_) ghostStateCallback_(false); + } + if (addonEventCallback_) + addonEventCallback_("PLAYER_FLAGS_CHANGED", {}); + } + else if (ufMeleeAPV != 0xFFFF && key == ufMeleeAPV) { playerMeleeAP_ = static_cast(val); } + else if (ufRangedAPV != 0xFFFF && key == ufRangedAPV) { playerRangedAP_ = static_cast(val); } + else if (ufSpDmg1V != 0xFFFF && key >= ufSpDmg1V && key < ufSpDmg1V + 7) { + playerSpellDmgBonus_[key - ufSpDmg1V] = static_cast(val); + } + else if (ufHealBonusV != 0xFFFF && key == ufHealBonusV) { playerHealBonus_ = static_cast(val); } + else if (ufBlockPctV != 0xFFFF && key == ufBlockPctV) { std::memcpy(&playerBlockPct_, &val, 4); } + else if (ufDodgePctV != 0xFFFF && key == ufDodgePctV) { std::memcpy(&playerDodgePct_, &val, 4); } + else if (ufParryPctV != 0xFFFF && key == ufParryPctV) { std::memcpy(&playerParryPct_, &val, 4); } + else if (ufCritPctV != 0xFFFF && key == ufCritPctV) { std::memcpy(&playerCritPct_, &val, 4); } + else if (ufRCritPctV != 0xFFFF && key == ufRCritPctV) { std::memcpy(&playerRangedCritPct_, &val, 4); } + else if (ufSCrit1V != 0xFFFF && key >= ufSCrit1V && key < ufSCrit1V + 7) { + std::memcpy(&playerSpellCritPct_[key - ufSCrit1V], &val, 4); + } + else if (ufRating1V != 0xFFFF && key >= ufRating1V && key < ufRating1V + 25) { + playerCombatRatings_[key - ufRating1V] = static_cast(val); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } + } + // Do not auto-create quests from VALUES quest-log slot fields for the + // same reason as CREATE_OBJECT2 above (can be misaligned per realm). + if (applyInventoryFields(block.fields)) slotsChanged = true; + if (slotsChanged) { + rebuildOnlineInventory(); + if (addonEventCallback_) + addonEventCallback_("PLAYER_EQUIPMENT_CHANGED", {}); + } + extractSkillFields(lastPlayerFields_); + extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); + } + + // Update item stack count / durability for online items + if (entity->getType() == ObjectType::ITEM || entity->getType() == ObjectType::CONTAINER) { + bool inventoryChanged = false; + const uint16_t itemStackField = fieldIndex(UF::ITEM_FIELD_STACK_COUNT); + const uint16_t itemDurField = fieldIndex(UF::ITEM_FIELD_DURABILITY); + const uint16_t itemMaxDurField = fieldIndex(UF::ITEM_FIELD_MAXDURABILITY); + const uint16_t containerNumSlotsField = fieldIndex(UF::CONTAINER_FIELD_NUM_SLOTS); + const uint16_t containerSlot1Field = fieldIndex(UF::CONTAINER_FIELD_SLOT_1); + // ITEM_FIELD_ENCHANTMENT starts 8 fields after ITEM_FIELD_STACK_COUNT (fixed offset + // across all expansions: +DURATION, +5×SPELL_CHARGES, +FLAGS = +8). + // Slot 0 = permanent (field +0), slot 1 = temp (+3), slots 2-4 = sockets (+6,+9,+12). + const uint16_t itemEnchBase = (itemStackField != 0xFFFF) ? (itemStackField + 8u) : 0xFFFF; + const uint16_t itemPermEnchField = itemEnchBase; + const uint16_t itemTempEnchField = (itemEnchBase != 0xFFFF) ? (itemEnchBase + 3u) : 0xFFFF; + const uint16_t itemSock1EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 6u) : 0xFFFF; + const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; + const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; + + auto it = onlineItems_.find(block.guid); + bool isItemInInventory = (it != onlineItems_.end()); + + for (const auto& [key, val] : block.fields) { + if (key == itemStackField && isItemInInventory) { + if (it->second.stackCount != val) { + it->second.stackCount = val; + inventoryChanged = true; + } + } else if (key == itemDurField && isItemInInventory) { + if (it->second.curDurability != val) { + const uint32_t prevDur = it->second.curDurability; + it->second.curDurability = val; + inventoryChanged = true; + // Warn once when durability drops below 20% for an equipped item. + const uint32_t maxDur = it->second.maxDurability; + if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { + // Check if this item is in an equip slot (not bag inventory). + bool isEquipped = false; + for (uint64_t slotGuid : equipSlotGuids_) { + if (slotGuid == block.guid) { isEquipped = true; break; } + } + if (isEquipped) { + std::string itemName; + const auto* info = getItemInfo(it->second.entry); + if (info) itemName = info->name; + char buf[128]; + if (!itemName.empty()) + std::snprintf(buf, sizeof(buf), "%s is about to break!", itemName.c_str()); + else + std::snprintf(buf, sizeof(buf), "An equipped item is about to break!"); + addUIError(buf); + addSystemChatMessage(buf); + } + } + } + } else if (key == itemMaxDurField && isItemInInventory) { + if (it->second.maxDurability != val) { + it->second.maxDurability = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemPermEnchField != 0xFFFF && key == itemPermEnchField) { + if (it->second.permanentEnchantId != val) { + it->second.permanentEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemTempEnchField != 0xFFFF && key == itemTempEnchField) { + if (it->second.temporaryEnchantId != val) { + it->second.temporaryEnchantId = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock1EnchField != 0xFFFF && key == itemSock1EnchField) { + if (it->second.socketEnchantIds[0] != val) { + it->second.socketEnchantIds[0] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock2EnchField != 0xFFFF && key == itemSock2EnchField) { + if (it->second.socketEnchantIds[1] != val) { + it->second.socketEnchantIds[1] = val; + inventoryChanged = true; + } + } else if (isItemInInventory && itemSock3EnchField != 0xFFFF && key == itemSock3EnchField) { + if (it->second.socketEnchantIds[2] != val) { + it->second.socketEnchantIds[2] = val; + inventoryChanged = true; + } + } + } + // Update container slot GUIDs on bag content changes + if (entity->getType() == ObjectType::CONTAINER) { + for (const auto& [key, _] : block.fields) { + if ((containerNumSlotsField != 0xFFFF && key == containerNumSlotsField) || + (containerSlot1Field != 0xFFFF && key >= containerSlot1Field && key < containerSlot1Field + 72)) { + inventoryChanged = true; + break; + } + } + extractContainerFields(block.guid, block.fields); + } + if (inventoryChanged) { + rebuildOnlineInventory(); + if (addonEventCallback_) { + addonEventCallback_("BAG_UPDATE", {}); + addonEventCallback_("UNIT_INVENTORY_CHANGED", {"player"}); + } + } + } + if (block.hasMovement && entity->getType() == ObjectType::GAMEOBJECT) { if (transportGuids_.count(block.guid) && transportMoveCallback_) { serverUpdatedTransportGuids_.insert(block.guid); - transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); - } - // Fire move callback for non-transport gameobjects. - if (entity->getType() == ObjectType::GAMEOBJECT && - transportGuids_.count(block.guid) == 0 && - gameObjectMoveCallback_) { + transportMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } else if (gameObjectMoveCallback_) { gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); } - // Fire move callback for non-player units (creatures). - // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many - // servers (especially vanilla/Turtle WoW) communicate NPC positions - // via MOVEMENT blocks instead. Use duration=0 for an instant snap. - if (block.guid != playerGuid && - entity->getType() == ObjectType::UNIT && - transportGuids_.count(block.guid) == 0 && - creatureMoveCallback_) { - creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); - } - } else { - LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); } - break; + + LOG_DEBUG("Updated entity fields: 0x", std::hex, block.guid, std::dec); + } else { + } + break; + } + + case UpdateType::MOVEMENT: { + // Diagnostic: Log if we receive MOVEMENT blocks for transports + if (transportGuids_.count(block.guid)) { + LOG_INFO("MOVEMENT update for transport 0x", std::hex, block.guid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, ")"); } - default: - break; - } - } + // Update entity position (server → canonical) + auto entity = entityManager.getEntity(block.guid); + if (entity) { + glm::vec3 pos = core::coords::serverToCanonical(glm::vec3(block.x, block.y, block.z)); + float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); + entity->setPosition(pos.x, pos.y, pos.z, oCanonical); + LOG_DEBUG("Updated entity position: 0x", std::hex, block.guid, std::dec); + if (block.guid != playerGuid && + (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::GAMEOBJECT)) { + if (block.onTransport && block.transportGuid != 0) { + glm::vec3 localOffset = core::coords::serverToCanonical( + glm::vec3(block.transportX, block.transportY, block.transportZ)); + const bool hasLocalOrientation = (block.updateFlags & 0x0020) != 0; // UPDATEFLAG_LIVING + float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); + setTransportAttachment(block.guid, entity->getType(), block.transportGuid, + localOffset, hasLocalOrientation, localOriCanonical); + if (transportManager_ && transportManager_->getTransport(block.transportGuid)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); + } + } else { + clearTransportAttachment(block.guid); + } + } + + if (block.guid == playerGuid) { + movementInfo.orientation = oCanonical; + + // Track player-on-transport state from MOVEMENT updates + if (block.onTransport) { + // Convert transport offset from server → canonical coordinates + glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); + glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); + setPlayerOnTransport(block.transportGuid, canonicalOffset); + if (transportManager_ && transportManager_->getTransport(playerTransportGuid_)) { + glm::vec3 composed = transportManager_->getPlayerWorldPosition(playerTransportGuid_, playerTransportOffset_); + entity->setPosition(composed.x, composed.y, composed.z, oCanonical); + movementInfo.x = composed.x; + movementInfo.y = composed.y; + movementInfo.z = composed.z; + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + } + LOG_INFO("Player on transport (MOVEMENT): 0x", std::hex, playerTransportGuid_, std::dec); + } else { + movementInfo.x = pos.x; + movementInfo.y = pos.y; + movementInfo.z = pos.z; + // Don't clear client-side M2 transport boarding + bool isClientM2Transport = false; + if (playerTransportGuid_ != 0 && transportManager_) { + auto* tr = transportManager_->getTransport(playerTransportGuid_); + isClientM2Transport = (tr && tr->isM2); + } + if (playerTransportGuid_ != 0 && !isClientM2Transport) { + LOG_INFO("Player left transport (MOVEMENT)"); + clearPlayerTransport(); + } + } + } + + // Fire transport move callback if this is a known transport + if (transportGuids_.count(block.guid) && transportMoveCallback_) { + serverUpdatedTransportGuids_.insert(block.guid); + transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); + } + // Fire move callback for non-transport gameobjects. + if (entity->getType() == ObjectType::GAMEOBJECT && + transportGuids_.count(block.guid) == 0 && + gameObjectMoveCallback_) { + gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + entity->getZ(), entity->getOrientation()); + } + // Fire move callback for non-player units (creatures). + // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many + // servers (especially vanilla/Turtle WoW) communicate NPC positions + // via MOVEMENT blocks instead. Use duration=0 for an instant snap. + if (block.guid != playerGuid && + entity->getType() == ObjectType::UNIT && + transportGuids_.count(block.guid) == 0 && + creatureMoveCallback_) { + creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); + } + } else { + LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); + } + break; + } + + default: + break; + } +} + +void GameHandler::finalizeUpdateObjectBatch(bool newItemCreated) { tabCycleStale = true; // Entity count logging disabled @@ -8373,7 +13208,9 @@ void GameHandler::handleCompressedUpdateObject(network::Packet& packet) { uint32_t decompressedSize = packet.readUInt32(); LOG_DEBUG(" Decompressed size: ", decompressedSize); - if (decompressedSize == 0 || decompressedSize > 1024 * 1024) { + // Capital cities and large raids can produce very large update packets. + // The real WoW client handles up to ~10MB; 5MB covers all practical cases. + if (decompressedSize == 0 || decompressedSize > 5 * 1024 * 1024) { LOG_WARNING("Invalid decompressed size: ", decompressedSize); return; } @@ -8472,8 +13309,21 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { // Clean up quest giver status npcQuestStatus_.erase(data.guid); + // Remove combat text entries referencing the destroyed entity so floating + // damage numbers don't linger after the source/target despawns. + combatText.erase( + std::remove_if(combatText.begin(), combatText.end(), + [&data](const CombatTextEntry& e) { + return e.dstGuid == data.guid; + }), + combatText.end()); + + // Clean up unit cast state (cast bar) for the destroyed unit + unitCastStates_.erase(data.guid); + // Clean up cached auras + unitAurasCache_.erase(data.guid); + tabCycleStale = true; - // Entity count logging disabled } void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) { @@ -8581,6 +13431,15 @@ void GameHandler::handleMessageChat(network::Packet& packet) { // Track whisper sender for /r command if (data.type == ChatType::WHISPER && !data.senderName.empty()) { lastWhisperSender_ = data.senderName; + + // Auto-reply if AFK or DND + if (afkStatus_ && !data.senderName.empty()) { + std::string reply = afkMessage_.empty() ? "Away from Keyboard" : afkMessage_; + sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); + } else if (dndStatus_ && !data.senderName.empty()) { + std::string reply = dndMessage_.empty() ? "Do Not Disturb" : dndMessage_; + sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); + } } // Trigger chat bubble for SAY/YELL messages from others @@ -8756,10 +13615,86 @@ void GameHandler::handleChannelNotify(network::Packet& packet) { } break; } - case ChannelNotifyType::NOT_IN_AREA: { + case ChannelNotifyType::NOT_IN_AREA: + addSystemChatMessage("You must be in the area to join '" + data.channelName + "'."); LOG_DEBUG("Cannot join channel ", data.channelName, " (not in area)"); break; - } + case ChannelNotifyType::WRONG_PASSWORD: + addSystemChatMessage("Wrong password for channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MEMBER: + addSystemChatMessage("You are not in channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MODERATOR: + addSystemChatMessage("You are not a moderator of '" + data.channelName + "'."); + break; + case ChannelNotifyType::MUTED: + addSystemChatMessage("You are muted in channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::BANNED: + addSystemChatMessage("You are banned from channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::THROTTLED: + addSystemChatMessage("Channel '" + data.channelName + "' is throttled. Please wait."); + break; + case ChannelNotifyType::NOT_IN_LFG: + addSystemChatMessage("You must be in a LFG queue to join '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_KICKED: + addSystemChatMessage("A player was kicked from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PASSWORD_CHANGED: + addSystemChatMessage("Password for '" + data.channelName + "' changed."); + break; + case ChannelNotifyType::OWNER_CHANGED: + addSystemChatMessage("Owner of '" + data.channelName + "' changed."); + break; + case ChannelNotifyType::NOT_OWNER: + addSystemChatMessage("You are not the owner of '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVALID_NAME: + addSystemChatMessage("Invalid channel name '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_NOT_FOUND: + addSystemChatMessage("Player not found."); + break; + case ChannelNotifyType::ANNOUNCEMENTS_ON: + addSystemChatMessage("Channel '" + data.channelName + "': announcements enabled."); + break; + case ChannelNotifyType::ANNOUNCEMENTS_OFF: + addSystemChatMessage("Channel '" + data.channelName + "': announcements disabled."); + break; + case ChannelNotifyType::MODERATION_ON: + addSystemChatMessage("Channel '" + data.channelName + "' is now moderated."); + break; + case ChannelNotifyType::MODERATION_OFF: + addSystemChatMessage("Channel '" + data.channelName + "' is no longer moderated."); + break; + case ChannelNotifyType::PLAYER_BANNED: + addSystemChatMessage("A player was banned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_UNBANNED: + addSystemChatMessage("A player was unbanned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_NOT_BANNED: + addSystemChatMessage("That player is not banned from '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVITE: + addSystemChatMessage("You have been invited to join channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::INVITE_WRONG_FACTION: + case ChannelNotifyType::WRONG_FACTION: + addSystemChatMessage("Wrong faction for channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::NOT_MODERATED: + addSystemChatMessage("Channel '" + data.channelName + "' is not moderated."); + break; + case ChannelNotifyType::PLAYER_INVITED: + addSystemChatMessage("Player invited to channel '" + data.channelName + "'."); + break; + case ChannelNotifyType::PLAYER_INVITE_BANNED: + addSystemChatMessage("That player is banned from '" + data.channelName + "'."); + break; default: LOG_DEBUG("Channel notify type ", static_cast(data.notifyType), " for channel ", data.channelName); @@ -8804,11 +13739,13 @@ void GameHandler::setTarget(uint64_t guid) { if (guid != 0) { LOG_INFO("Target set: 0x", std::hex, guid, std::dec); } + if (addonEventCallback_) addonEventCallback_("PLAYER_TARGET_CHANGED", {}); } void GameHandler::clearTarget() { if (targetGuid != 0) { LOG_INFO("Target cleared"); + if (addonEventCallback_) addonEventCallback_("PLAYER_TARGET_CHANGED", {}); } targetGuid = 0; tabCycleIndex = -1; @@ -8822,16 +13759,20 @@ std::shared_ptr GameHandler::getTarget() const { void GameHandler::setFocus(uint64_t guid) { focusGuid = guid; + if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {}); if (guid != 0) { auto entity = entityManager.getEntity(guid); if (entity) { - std::string name = "Unknown"; - if (entity->getType() == ObjectType::PLAYER) { - auto player = std::dynamic_pointer_cast(entity); - if (player && !player->getName().empty()) { - name = player->getName(); - } + std::string name; + auto unit = std::dynamic_pointer_cast(entity); + if (unit && !unit->getName().empty()) { + name = unit->getName(); } + if (name.empty()) { + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) name = nit->second; + } + if (name.empty()) name = "Unknown"; addSystemChatMessage("Focus set: " + name); LOG_INFO("Focus set: 0x", std::hex, guid, std::dec); } @@ -8844,6 +13785,14 @@ void GameHandler::clearFocus() { LOG_INFO("Focus cleared"); } focusGuid = 0; + if (addonEventCallback_) addonEventCallback_("PLAYER_FOCUS_CHANGED", {}); +} + +void GameHandler::setMouseoverGuid(uint64_t guid) { + if (mouseoverGuid_ != guid) { + mouseoverGuid_ = guid; + if (addonEventCallback_) addonEventCallback_("UPDATE_MOUSEOVER_UNIT", {}); + } } std::shared_ptr GameHandler::getFocus() const { @@ -8870,9 +13819,8 @@ void GameHandler::targetEnemy(bool reverse) { for (const auto& [guid, entity] : entities) { if (entity->getType() == ObjectType::UNIT) { - // Check if hostile (this is simplified - would need faction checking) auto unit = std::dynamic_pointer_cast(entity); - if (unit && guid != playerGuid) { + if (unit && guid != playerGuid && unit->isHostile()) { hostiles.push_back(guid); } } @@ -8969,6 +13917,12 @@ void GameHandler::inspectTarget() { auto packet = InspectPacket::build(targetGuid); socket->send(packet); + // WotLK: also query the player's achievement data so the inspect UI can display it + if (isActiveExpansion("wotlk")) { + auto achPkt = QueryInspectAchievementsPacket::build(targetGuid); + socket->send(achPkt); + } + auto player = std::static_pointer_cast(target); std::string name = player->getName().empty() ? "Target" : player->getName(); addSystemChatMessage("Inspecting " + name + "..."); @@ -9167,6 +14121,7 @@ void GameHandler::cancelLogout() { auto packet = LogoutCancelPacket::build(); socket->send(packet); loggingOut_ = false; + logoutCountdown_ = 0.0f; addSystemChatMessage("Logout cancelled."); LOG_INFO("Cancelled logout"); } @@ -9242,6 +14197,17 @@ void GameHandler::followTarget() { addSystemChatMessage("Now following " + targetName + "."); LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, targetGuid, std::dec, ")"); + if (addonEventCallback_) addonEventCallback_("AUTOFOLLOW_BEGIN", {}); +} + +void GameHandler::cancelFollow() { + if (followTargetGuid_ == 0) { + addSystemChatMessage("You are not following anyone."); + return; + } + followTargetGuid_ = 0; + addSystemChatMessage("You stop following."); + if (addonEventCallback_) addonEventCallback_("AUTOFOLLOW_END", {}); } void GameHandler::assistTarget() { @@ -9482,6 +14448,11 @@ void GameHandler::handleDuelRequested(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { duelChallengerName_ = unit->getName(); } + if (duelChallengerName_.empty()) { + auto nit = playerNameCache.find(duelChallengerGuid_); + if (nit != playerNameCache.end()) + duelChallengerName_ = nit->second; + } if (duelChallengerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -9491,8 +14462,13 @@ void GameHandler::handleDuelRequested(network::Packet& packet) { pendingDuelRequest_ = true; addSystemChatMessage(duelChallengerName_ + " challenges you to a duel!"); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playTargetSelect(); + } LOG_INFO("SMSG_DUEL_REQUESTED: challenger=0x", std::hex, duelChallengerGuid_, " flag=0x", duelFlagGuid_, std::dec, " name=", duelChallengerName_); + if (addonEventCallback_) addonEventCallback_("DUEL_REQUESTED", {duelChallengerName_}); } void GameHandler::handleDuelComplete(network::Packet& packet) { @@ -9500,21 +14476,28 @@ void GameHandler::handleDuelComplete(network::Packet& packet) { uint8_t started = packet.readUInt8(); // started=1: duel began, started=0: duel was cancelled before starting pendingDuelRequest_ = false; + duelCountdownMs_ = 0; // clear countdown once duel is resolved if (!started) { addSystemChatMessage("The duel was cancelled."); } LOG_INFO("SMSG_DUEL_COMPLETE: started=", static_cast(started)); + if (addonEventCallback_) addonEventCallback_("DUEL_FINISHED", {}); } void GameHandler::handleDuelWinner(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 3) return; - /*uint8_t type =*/ packet.readUInt8(); // 0=normal, 1=flee + uint8_t duelType = packet.readUInt8(); // 0=normal win, 1=opponent fled duel area std::string winner = packet.readString(); std::string loser = packet.readString(); - std::string msg = winner + " has defeated " + loser + " in a duel!"; + std::string msg; + if (duelType == 1) { + msg = loser + " has fled from the duel. " + winner + " wins!"; + } else { + msg = winner + " has defeated " + loser + " in a duel!"; + } addSystemChatMessage(msg); - LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser); + LOG_INFO("SMSG_DUEL_WINNER: winner=", winner, " loser=", loser, " type=", static_cast(duelType)); } void GameHandler::toggleAfk(const std::string& message) { @@ -9675,6 +14658,29 @@ void GameHandler::clearMainAssist() { LOG_INFO("Cleared main assist"); } +void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) { + if (state != WorldState::IN_WORLD || !socket) return; + + static const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", "Moon", "Square", "Cross", "Skull" + }; + + if (icon == 0xFF) { + // Clear mark: find which slot this guid holds and send 0 GUID + for (int i = 0; i < 8; ++i) { + if (raidTargetGuids_[i] == guid) { + auto packet = RaidTargetUpdatePacket::build(static_cast(i), 0); + socket->send(packet); + break; + } + } + } else if (icon < 8) { + auto packet = RaidTargetUpdatePacket::build(icon, guid); + socket->send(packet); + LOG_INFO("Set raid mark %s on guid %llu", kMarkNames[icon], (unsigned long long)guid); + } +} + void GameHandler::requestRaidInfo() { if (state != WorldState::IN_WORLD || !socket) { LOG_WARNING("Cannot request raid info: not in world or not connected"); @@ -9737,12 +14743,18 @@ void GameHandler::stopCasting() { socket->send(packet); } - // Reset casting state + // Reset casting state and clear any queued spell so it doesn't fire later casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; castTimeTotal = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; LOG_INFO("Cancelled spell cast"); } @@ -9756,29 +14768,63 @@ void GameHandler::releaseSpirit() { } auto packet = RepopRequestPacket::build(); socket->send(packet); - releasedSpirit_ = true; + // Do NOT set releasedSpirit_ = true here. Setting it optimistically races + // with PLAYER_FLAGS field updates that arrive before the server processes + // CMSG_REPOP_REQUEST: the PLAYER_FLAGS handler sees wasGhost=true/nowGhost=false + // and fires the "ghost cleared" path, wiping corpseMapId_/corpseGuid_. + // Let the server drive ghost state via PLAYER_FLAGS_GHOST (field update path). + selfResAvailable_ = false; // self-res window closes when spirit is released repopPending_ = true; lastRepopRequestMs_ = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); + // Query server for authoritative corpse position (response updates corpseX_/Y_/Z_) + network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); + socket->send(cq); } } bool GameHandler::canReclaimCorpse() const { - if (!releasedSpirit_ || corpseMapId_ == 0) return false; - // Only if ghost is on the same map as their corpse + // Need: ghost state + corpse object GUID (required by CMSG_RECLAIM_CORPSE) + + // corpse map known + same map + within 40 yards. + if (!releasedSpirit_ || corpseGuid_ == 0 || corpseMapId_ == 0) return false; if (currentMapId_ != corpseMapId_) return false; - // Must be within 40 yards (server also validates proximity) - float dx = movementInfo.x - corpseX_; - float dy = movementInfo.y - corpseY_; + // movementInfo.x/y are canonical (x=north=server_y, y=west=server_x). + // corpseX_/Y_ are raw server coords (x=west, y=north). + float dx = movementInfo.x - corpseY_; // canonical north - server.y + float dy = movementInfo.y - corpseX_; // canonical west - server.x float dz = movementInfo.z - corpseZ_; return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } +float GameHandler::getCorpseReclaimDelaySec() const { + if (corpseReclaimAvailableMs_ == 0) return 0.0f; + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + if (nowMs >= corpseReclaimAvailableMs_) return 0.0f; + return static_cast(corpseReclaimAvailableMs_ - nowMs) / 1000.0f; +} + void GameHandler::reclaimCorpse() { if (!canReclaimCorpse() || !socket) return; - network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + // CMSG_RECLAIM_CORPSE requires the corpse object's own GUID. + // Servers look up the corpse by this GUID; sending the player GUID silently fails. + if (corpseGuid_ == 0) { + LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim"); + return; + } + auto packet = ReclaimCorpsePacket::build(corpseGuid_); socket->send(packet); - LOG_INFO("Sent CMSG_RECLAIM_CORPSE"); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, corpseGuid_, std::dec); +} + +void GameHandler::useSelfRes() { + if (!selfResAvailable_ || !socket) return; + // CMSG_SELF_RES: empty body — server confirms resurrection via SMSG_UPDATE_OBJECT. + network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES)); + socket->send(pkt); + selfResAvailable_ = false; + LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)"); } void GameHandler::activateSpiritHealer(uint64_t npcGuid) { @@ -9825,7 +14871,19 @@ void GameHandler::tabTarget(float playerX, float playerY, float playerZ) { const uint64_t guid = e->getGuid(); auto* unit = dynamic_cast(e.get()); if (!unit) return false; // Not a unit (shouldn't happen after type filter) - if (unit->getHealth() == 0) return false; // Dead / corpse + if (unit->getHealth() == 0) { + // Dead corpse: only targetable if it has loot or is skinnableable + // If corpse was looted and is now empty, skip it (except for skinning) + auto lootIt = localLootState_.find(guid); + if (lootIt == localLootState_.end() || lootIt->second.data.items.empty()) { + // No loot data or all items taken; check if skinnableable + // For now, skip empty looted corpses (proper skinning check requires + // creature type data that may not be immediately available) + return false; + } + // Has unlooted items available + return true; + } const bool hostileByFaction = unit->isHostile(); const bool hostileByCombat = isAggressiveTowardPlayer(guid); if (!hostileByFaction && !hostileByCombat) return false; @@ -9888,6 +14946,7 @@ void GameHandler::addLocalChatMessage(const MessageChatData& msg) { if (chatHistory.size() > maxChatHistory) { chatHistory.pop_front(); } + if (addonChatCallback_) addonChatCallback_(msg); } // ============================================================ @@ -9964,6 +15023,10 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { if (data.isValid()) { playerNameCache[data.guid] = data.name; + // Cache class/race from name query for UnitClass/UnitRace fallback + if (data.classId != 0 || data.race != 0) { + playerClassRaceCache_[data.guid] = {data.classId, data.race}; + } // Update entity name auto entity = entityManager.getEntity(data.guid); if (entity && entity->getType() == ObjectType::PLAYER) { @@ -9990,6 +15053,16 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { if (friendGuids_.count(data.guid)) { friendsCache[data.name] = data.guid; } + + // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available + if (addonEventCallback_) { + std::string unitId; + if (data.guid == targetGuid) unitId = "target"; + else if (data.guid == focusGuid) unitId = "focus"; + else if (data.guid == playerGuid) unitId = "player"; + if (!unitId.empty()) + addonEventCallback_("UNIT_NAME_UPDATE", {unitId}); + } } } @@ -10077,6 +15150,7 @@ void GameHandler::handleGameObjectPageText(network::Packet& packet) { else if (info.type == 10) pageId = info.data[7]; if (pageId != 0 && socket && state == WorldState::IN_WORLD) { + bookPages_.clear(); // start a fresh book for this interaction auto req = PageTextQueryPacket::build(pageId, guid); socket->send(req); return; @@ -10091,19 +15165,31 @@ void GameHandler::handlePageTextQueryResponse(network::Packet& packet) { PageTextQueryResponseData data; if (!PageTextQueryResponseParser::parse(packet, data)) return; - if (!data.text.empty()) { - std::istringstream iss(data.text); - std::string line; - bool wrote = false; - while (std::getline(iss, line)) { - if (line.empty()) continue; - addSystemChatMessage(line); - wrote = true; + if (!data.isValid()) return; + + // Append page if not already collected + bool alreadyHave = false; + for (const auto& bp : bookPages_) { + if (bp.pageId == data.pageId) { alreadyHave = true; break; } + } + if (!alreadyHave) { + bookPages_.push_back({data.pageId, data.text}); + } + + // Follow the chain: if there's a next page we haven't fetched yet, request it + if (data.nextPageId != 0) { + bool nextHave = false; + for (const auto& bp : bookPages_) { + if (bp.pageId == data.nextPageId) { nextHave = true; break; } } - if (!wrote) { - addSystemChatMessage(data.text); + if (!nextHave && socket && state == WorldState::IN_WORLD) { + auto req = PageTextQueryPacket::build(data.nextPageId, playerGuid); + socket->send(req); } } + LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, + " nextPage=", data.nextPageId, + " totalPages=", bookPages_.size()); } // ============================================================ @@ -10146,6 +15232,25 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { rebuildOnlineInventory(); maybeDetectVisibleItemLayout(); + // Flush any deferred loot notifications waiting on this item's name/quality. + for (auto it = pendingItemPushNotifs_.begin(); it != pendingItemPushNotifs_.end(); ) { + if (it->itemId == data.entry) { + std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name; + std::string link = buildItemLink(data.entry, data.quality, itemName); + std::string msg = "Received: " + link; + if (it->count > 1) msg += " x" + std::to_string(it->count); + addSystemChatMessage(msg); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); + } + if (itemLootCallback_) itemLootCallback_(data.entry, it->count, data.quality, itemName); + it = pendingItemPushNotifs_.erase(it); + } else { + ++it; + } + } + // Selectively re-emit only players whose equipment references this item entry const uint32_t resolvedEntry = data.entry; for (const auto& [guid, entries] : otherPlayerVisibleItemEntries_) { @@ -10191,8 +15296,53 @@ void GameHandler::handleInspectResults(network::Packet& packet) { uint8_t talentType = packet.readUInt8(); if (talentType == 0) { - // Own talent info — silently consume (sent on login, talent changes, respecs) - LOG_DEBUG("SMSG_TALENTS_INFO: received own talent data, ignoring"); + // Own talent info (type 0): uint32 unspentTalents, uint8 groupCount, uint8 activeGroup + // Per group: uint8 talentCount, [talentId(4)+rank(1)]..., uint8 glyphCount, [glyphId(2)]... + if (packet.getSize() - packet.getReadPos() < 6) { + LOG_DEBUG("SMSG_TALENTS_INFO type=0: too short"); + return; + } + uint32_t unspentTalents = packet.readUInt32(); + uint8_t talentGroupCount = packet.readUInt8(); + uint8_t activeTalentGroup = packet.readUInt8(); + + if (activeTalentGroup > 1) activeTalentGroup = 0; + activeTalentSpec_ = activeTalentGroup; + + for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t talentCount = packet.readUInt8(); + learnedTalents_[g].clear(); + for (uint8_t t = 0; t < talentCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t talentId = packet.readUInt32(); + uint8_t rank = packet.readUInt8(); + learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed + } + if (packet.getSize() - packet.getReadPos() < 1) break; + learnedGlyphs_[g].fill(0); + uint8_t glyphCount = packet.readUInt8(); + for (uint8_t gl = 0; gl < glyphCount; ++gl) { + if (packet.getSize() - packet.getReadPos() < 2) break; + uint16_t glyphId = packet.readUInt16(); + if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; + } + } + + unspentTalentPoints_[activeTalentGroup] = static_cast( + unspentTalents > 255 ? 255 : unspentTalents); + + if (!talentsInitialized_) { + talentsInitialized_ = true; + if (unspentTalents > 0) { + addSystemChatMessage("You have " + std::to_string(unspentTalents) + + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); + } + } + + LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents, + " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, + " learned=", learnedTalents_[activeTalentGroup].size()); return; } @@ -10256,6 +15406,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } // Parse enchantment slot mask + enchant IDs + std::array enchantIds{}; bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft >= 4) { uint32_t slotMask = packet.readUInt32(); @@ -10263,24 +15414,35 @@ void GameHandler::handleInspectResults(network::Packet& packet) { if (slotMask & (1u << slot)) { bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft < 2) break; - packet.readUInt16(); // enchantId + enchantIds[slot] = packet.readUInt16(); } } } - // Display inspect results - std::string msg = "Inspect: " + playerName; - msg += " - " + std::to_string(totalTalents) + " talent points spent"; - if (unspentTalents > 0) { - msg += ", " + std::to_string(unspentTalents) + " unspent"; + // Store inspect result for UI display + inspectResult_.guid = guid; + inspectResult_.playerName = playerName; + inspectResult_.totalTalents = totalTalents; + inspectResult_.unspentTalents = unspentTalents; + inspectResult_.talentGroups = talentGroupCount; + inspectResult_.activeTalentGroup = activeTalentGroup; + inspectResult_.enchantIds = enchantIds; + + // Merge any gear we already have from a prior inspect request + auto gearIt = inspectedPlayerItemEntries_.find(guid); + if (gearIt != inspectedPlayerItemEntries_.end()) { + inspectResult_.itemEntries = gearIt->second; + } else { + inspectResult_.itemEntries = {}; } - if (talentGroupCount > 1) { - msg += " (dual spec, active: " + std::to_string(activeTalentGroup + 1) + ")"; - } - addSystemChatMessage(msg); LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ", unspentTalents, " unspent, ", (int)talentGroupCount, " specs"); + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); + addonEventCallback_("INSPECT_READY", {guidBuf}); + } } uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const { @@ -10380,6 +15542,21 @@ bool GameHandler::applyInventoryFields(const std::map& field bool slotsChanged = false; int equipBase = (invSlotBase_ >= 0) ? invSlotBase_ : static_cast(fieldIndex(UF::PLAYER_FIELD_INV_SLOT_HEAD)); int packBase = (packSlotBase_ >= 0) ? packSlotBase_ : static_cast(fieldIndex(UF::PLAYER_FIELD_PACK_SLOT_1)); + int bankBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1)); + int bankBagBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1)); + + // Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7). + if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) { + effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28); + effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7; + } + + int keyringBase = static_cast(fieldIndex(UF::PLAYER_FIELD_KEYRING_SLOT_1)); + if (keyringBase == 0xFFFF && bankBagBase != 0xFFFF) { + // Layout fallback for profiles that don't define PLAYER_FIELD_KEYRING_SLOT_1. + // Bank bag slots are followed by 12 vendor buyback slots (24 fields), then keyring. + keyringBase = bankBagBase + (effectiveBankBagSlots_ * 2) + 24; + } for (const auto& [key, val] : fields) { if (key >= equipBase && key <= equipBase + (game::Inventory::NUM_EQUIP_SLOTS * 2 - 1)) { @@ -10400,15 +15577,17 @@ bool GameHandler::applyInventoryFields(const std::map& field else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); slotsChanged = true; } - } - - // Bank slots starting at PLAYER_FIELD_BANK_SLOT_1 - int bankBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANK_SLOT_1)); - int bankBagBase = static_cast(fieldIndex(UF::PLAYER_FIELD_BANKBAG_SLOT_1)); - // Derive slot counts from field gap (Classic=24/6, TBC/WotLK=28/7) - if (bankBase != 0xFFFF && bankBagBase != 0xFFFF) { - effectiveBankSlots_ = std::min((bankBagBase - bankBase) / 2, 28); - effectiveBankBagSlots_ = (effectiveBankSlots_ <= 24) ? 6 : 7; + } else if (keyringBase != 0xFFFF && + key >= keyringBase && + key <= keyringBase + (game::Inventory::KEYRING_SLOTS * 2 - 1)) { + int slotIndex = (key - keyringBase) / 2; + bool isLow = ((key - keyringBase) % 2 == 0); + if (slotIndex < static_cast(keyringSlotGuids_.size())) { + uint64_t& guid = keyringSlotGuids_[slotIndex]; + if (isLow) guid = (guid & 0xFFFFFFFF00000000ULL) | val; + else guid = (guid & 0x00000000FFFFFFFFULL) | (uint64_t(val) << 32); + slotsChanged = true; + } } if (bankBase != 0xFFFF && key >= static_cast(bankBase) && key <= static_cast(bankBase) + (effectiveBankSlots_ * 2 - 1)) { @@ -10482,6 +15661,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10501,6 +15682,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10520,6 +15710,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10539,6 +15731,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); } else { def.name = "Item " + std::to_string(def.itemId); queryItemInfo(def.itemId, guid); @@ -10547,6 +15748,55 @@ void GameHandler::rebuildOnlineInventory() { inventory.setBackpackSlot(i, def); } + // Keyring slots + for (int i = 0; i < game::Inventory::KEYRING_SLOTS; i++) { + uint64_t guid = keyringSlotGuids_[i]; + if (guid == 0) continue; + + auto itemIt = onlineItems_.find(guid); + if (itemIt == onlineItems_.end()) continue; + + ItemDef def; + def.itemId = itemIt->second.entry; + def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; + def.maxStack = 1; + + auto infoIt = itemInfoCache_.find(itemIt->second.entry); + if (infoIt != itemInfoCache_.end()) { + def.name = infoIt->second.name; + def.quality = static_cast(infoIt->second.quality); + def.inventoryType = infoIt->second.inventoryType; + def.maxStack = std::max(1, infoIt->second.maxStack); + def.displayInfoId = infoIt->second.displayInfoId; + def.subclassName = infoIt->second.subclassName; + def.damageMin = infoIt->second.damageMin; + def.damageMax = infoIt->second.damageMax; + def.delayMs = infoIt->second.delayMs; + def.armor = infoIt->second.armor; + def.stamina = infoIt->second.stamina; + def.strength = infoIt->second.strength; + def.agility = infoIt->second.agility; + def.intellect = infoIt->second.intellect; + def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); + } else { + def.name = "Item " + std::to_string(def.itemId); + queryItemInfo(def.itemId, guid); + } + + inventory.setKeyringSlot(i, def); + } + // Bag contents (BAG1-BAG4 are equip slots 19-22) for (int bagIdx = 0; bagIdx < 4; bagIdx++) { uint64_t bagGuid = equipSlotGuids_[19 + bagIdx]; @@ -10593,6 +15843,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10612,6 +15864,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.sellPrice = infoIt->second.sellPrice; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); @@ -10633,6 +15894,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10652,6 +15915,14 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.sellPrice = infoIt->second.sellPrice; def.bagSlots = infoIt->second.containerSlots; } else { @@ -10714,6 +15985,8 @@ void GameHandler::rebuildOnlineInventory() { ItemDef def; def.itemId = itemIt->second.entry; def.stackCount = itemIt->second.stackCount; + def.curDurability = itemIt->second.curDurability; + def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; auto infoIt = itemInfoCache_.find(itemIt->second.entry); @@ -10733,7 +16006,15 @@ void GameHandler::rebuildOnlineInventory() { def.agility = infoIt->second.agility; def.intellect = infoIt->second.intellect; def.spirit = infoIt->second.spirit; + def.itemLevel = infoIt->second.itemLevel; + def.requiredLevel = infoIt->second.requiredLevel; def.sellPrice = infoIt->second.sellPrice; + def.bindType = infoIt->second.bindType; + def.description = infoIt->second.description; + def.startQuestId = infoIt->second.startQuestId; + def.extraStats.clear(); + for (const auto& es : infoIt->second.extraStats) + def.extraStats.push_back({es.statType, es.statValue}); def.bagSlots = infoIt->second.containerSlots; } else { def.name = "Item " + std::to_string(def.itemId); @@ -10759,6 +16040,8 @@ void GameHandler::rebuildOnlineInventory() { int c = 0; for (auto g : equipSlotGuids_) if (g) c++; return c; }(), " backpack=", [&](){ int c = 0; for (auto g : backpackSlotGuids_) if (g) c++; return c; + }(), " keyring=", [&](){ + int c = 0; for (auto g : keyringSlotGuids_) if (g) c++; return c; }()); } @@ -10960,6 +16243,7 @@ void GameHandler::startAutoAttack(uint64_t targetGuid) { } autoAttackRequested_ = true; + autoAttackRetryPending_ = true; // Keep combat animation/state server-authoritative. We only flip autoAttacking // on SMSG_ATTACKSTART where attackerGuid == playerGuid. autoAttacking = false; @@ -10979,6 +16263,7 @@ void GameHandler::stopAutoAttack() { if (!autoAttacking && !autoAttackRequested_) return; autoAttackRequested_ = false; autoAttacking = false; + autoAttackRetryPending_ = false; autoAttackTarget = 0; autoAttackOutOfRange_ = false; autoAttackOutOfRangeTime_ = 0.0f; @@ -10989,16 +16274,101 @@ void GameHandler::stopAutoAttack() { socket->send(packet); } LOG_INFO("Stopping auto-attack"); + if (addonEventCallback_) + addonEventCallback_("PLAYER_LEAVE_COMBAT", {}); } -void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource) { +void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, + uint64_t srcGuid, uint64_t dstGuid) { CombatTextEntry entry; entry.type = type; entry.amount = amount; entry.spellId = spellId; entry.age = 0.0f; entry.isPlayerSource = isPlayerSource; + entry.powerType = powerType; + entry.srcGuid = srcGuid; + entry.dstGuid = dstGuid; + // Random horizontal stagger so simultaneous hits don't stack vertically + static std::mt19937 rng(std::random_device{}()); + std::uniform_real_distribution dist(-1.0f, 1.0f); + entry.xSeed = dist(rng); combatText.push_back(entry); + + // Persistent combat log — use explicit GUIDs if provided, else fall back to + // player/current-target (the old behaviour for events without specific participants). + CombatLogEntry log; + log.type = type; + log.amount = amount; + log.spellId = spellId; + log.isPlayerSource = isPlayerSource; + log.powerType = powerType; + log.timestamp = std::time(nullptr); + // If the caller provided an explicit destination GUID but left source GUID as 0, + // preserve "unknown/no source" (e.g. environmental damage) instead of + // backfilling from current target. + uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid + : ((dstGuid != 0) ? 0 : (isPlayerSource ? playerGuid : targetGuid)); + uint64_t effectiveDst = (dstGuid != 0) ? dstGuid + : (isPlayerSource ? targetGuid : playerGuid); + log.sourceName = lookupName(effectiveSrc); + log.targetName = (effectiveDst != 0) ? lookupName(effectiveDst) : std::string{}; + if (combatLog_.size() >= MAX_COMBAT_LOG) + combatLog_.pop_front(); + combatLog_.push_back(std::move(log)); + + // Fire COMBAT_LOG_EVENT_UNFILTERED for Lua addons + // Args: subevent, sourceGUID, sourceName, 0 (sourceFlags), destGUID, destName, 0 (destFlags), spellId, spellName, amount + if (addonEventCallback_) { + static const char* kSubevents[] = { + "SWING_DAMAGE", "SPELL_DAMAGE", "SPELL_HEAL", "SWING_MISSED", "SWING_MISSED", + "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SPELL_DAMAGE", "SPELL_HEAL", + "SPELL_PERIODIC_DAMAGE", "SPELL_PERIODIC_HEAL", "ENVIRONMENTAL_DAMAGE", + "SPELL_ENERGIZE", "SPELL_DRAIN", "PARTY_KILL", "SPELL_MISSED", "SPELL_ABSORBED", + "SPELL_MISSED", "SPELL_MISSED", "SPELL_MISSED", "SPELL_AURA_APPLIED", + "SPELL_DISPEL", "SPELL_STOLEN", "SPELL_INTERRUPT", "SPELL_INSTAKILL", + "PARTY_KILL", "SWING_DAMAGE", "SWING_DAMAGE" + }; + const char* subevent = (type < sizeof(kSubevents)/sizeof(kSubevents[0])) + ? kSubevents[type] : "UNKNOWN"; + char srcBuf[32], dstBuf[32]; + snprintf(srcBuf, sizeof(srcBuf), "0x%016llX", (unsigned long long)effectiveSrc); + snprintf(dstBuf, sizeof(dstBuf), "0x%016llX", (unsigned long long)effectiveDst); + std::string spellName = (spellId != 0) ? getSpellName(spellId) : std::string{}; + std::string timestamp = std::to_string(static_cast(std::time(nullptr))); + addonEventCallback_("COMBAT_LOG_EVENT_UNFILTERED", { + timestamp, subevent, + srcBuf, log.sourceName, "0", + dstBuf, log.targetName, "0", + std::to_string(spellId), spellName, + std::to_string(amount) + }); + } +} + +bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { + if (spellId == 0) return false; + + const auto now = std::chrono::steady_clock::now(); + constexpr auto kRecentWindow = std::chrono::seconds(1); + while (!recentSpellstealLogs_.empty() && + now - recentSpellstealLogs_.front().timestamp > kRecentWindow) { + recentSpellstealLogs_.pop_front(); + } + + for (auto it = recentSpellstealLogs_.begin(); it != recentSpellstealLogs_.end(); ++it) { + if (it->casterGuid == casterGuid && + it->victimGuid == victimGuid && + it->spellId == spellId) { + recentSpellstealLogs_.erase(it); + return false; + } + } + + if (recentSpellstealLogs_.size() >= MAX_RECENT_SPELLSTEAL_LOGS) + recentSpellstealLogs_.pop_front(); + recentSpellstealLogs_.push_back({casterGuid, victimGuid, spellId, now}); + return true; } void GameHandler::updateCombatText(float deltaTime) { @@ -11025,7 +16395,10 @@ void GameHandler::handleAttackStart(network::Packet& packet) { if (data.attackerGuid == playerGuid) { autoAttackRequested_ = true; autoAttacking = true; + autoAttackRetryPending_ = false; autoAttackTarget = data.victimGuid; + if (addonEventCallback_) + addonEventCallback_("PLAYER_ENTER_COMBAT", {}); } else if (data.victimGuid == playerGuid && data.attackerGuid != 0) { hostileAttackers_.insert(data.attackerGuid); autoTargetAttacker(data.attackerGuid); @@ -11062,13 +16435,9 @@ void GameHandler::handleAttackStop(network::Packet& packet) { // Keep intent, but clear server-confirmed active state until ATTACKSTART resumes. if (data.attackerGuid == playerGuid) { autoAttacking = false; + autoAttackRetryPending_ = autoAttackRequested_; + autoAttackResendTimer_ = 0.0f; LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)"); - if (autoAttackRequested_ && autoAttackTarget != 0 && socket) { - // Classic-family servers may emit transient ATTACKSTOP when range/facing jitters. - // Reassert melee intent immediately instead of waiting for periodic resend. - auto pkt = AttackSwingPacket::build(autoAttackTarget); - socket->send(pkt); - } } else if (data.victimGuid == playerGuid) { hostileAttackers_.erase(data.attackerGuid); } @@ -11326,6 +16695,47 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* socket->send(ack); } +void GameHandler::handleMoveSetCollisionHeight(network::Packet& packet) { + // SMSG_MOVE_SET_COLLISION_HGT: packed guid + counter + float (height) + // ACK: CMSG_MOVE_SET_COLLISION_HGT_ACK = packed guid + counter + movement block + float (height) + const bool legacyGuid = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (legacyGuid ? 8u : 2u)) return; + uint64_t guid = legacyGuid ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) return; // counter(4) + height(4) + uint32_t counter = packet.readUInt32(); + float height = packet.readFloat(); + + LOG_INFO("SMSG_MOVE_SET_COLLISION_HGT: guid=0x", std::hex, guid, std::dec, + " counter=", counter, " height=", height); + + if (guid != playerGuid) return; + if (!socket) return; + + uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_SET_COLLISION_HGT_ACK); + if (ackWire == 0xFFFF) return; + + network::Packet ack(ackWire); + const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); + if (legacyGuidAck) { + ack.writeUInt64(playerGuid); + } else { + MovementPacket::writePackedGuid(ack, playerGuid); + } + ack.writeUInt32(counter); + + MovementInfo wire = movementInfo; + wire.time = nextMovementTimestampMs(); + glm::vec3 serverPos = core::coords::canonicalToServer(glm::vec3(wire.x, wire.y, wire.z)); + wire.x = serverPos.x; + wire.y = serverPos.y; + wire.z = serverPos.z; + if (packetParsers_) packetParsers_->writeMovementPayload(ack, wire); + else MovementPacket::writeMovementPayload(ack, wire); + ack.writeFloat(height); + + socket->send(ack); +} + void GameHandler::handleMoveKnockBack(network::Packet& packet) { // WotLK: packed GUID; TBC/Classic: full uint64 const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); @@ -11334,16 +16744,23 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) { ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4) uint32_t counter = packet.readUInt32(); - [[maybe_unused]] float vcos = packet.readFloat(); - [[maybe_unused]] float vsin = packet.readFloat(); - [[maybe_unused]] float hspeed = packet.readFloat(); - [[maybe_unused]] float vspeed = packet.readFloat(); + float vcos = packet.readFloat(); + float vsin = packet.readFloat(); + float hspeed = packet.readFloat(); + float vspeed = packet.readFloat(); LOG_INFO("SMSG_MOVE_KNOCK_BACK: guid=0x", std::hex, guid, std::dec, - " counter=", counter, " hspeed=", hspeed, " vspeed=", vspeed); + " counter=", counter, " vcos=", vcos, " vsin=", vsin, + " hspeed=", hspeed, " vspeed=", vspeed); if (guid != playerGuid) return; + // Apply knockback physics locally so the player visually flies through the air. + // The callback forwards to CameraController::applyKnockBack(). + if (knockBackCallback_) { + knockBackCallback_(vcos, vsin, hspeed, vspeed); + } + if (!socket) return; uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK); if (ackWire == 0xFFFF) return; @@ -11386,20 +16803,40 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) { // ============================================================ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { + // SMSG_BATTLEFIELD_STATUS wire format differs by expansion: + // + // Classic 1.12 (vmangos/cmangos): + // queueSlot(4) bgTypeId(4) unk(2) instanceId(4) isRegistered(1) statusId(4) [status fields...] + // STATUS_NONE sends only: queueSlot(4) bgTypeId(4) + // + // TBC 2.4.3 / WotLK 3.3.5a: + // queueSlot(4) arenaType(1) unk(1) bgTypeId(4) unk2(2) instanceId(4) isRated(1) statusId(4) [status fields...] + // STATUS_NONE sends only: queueSlot(4) arenaType(1) + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t queueSlot = packet.readUInt32(); - // Minimal packet = just queueSlot + arenaType(1) when status is NONE - if (packet.getSize() - packet.getReadPos() < 1) { - LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared"); - return; + const bool classicFormat = isClassicLikeExpansion(); + + uint8_t arenaType = 0; + if (!classicFormat) { + // TBC/WotLK: arenaType(1) + unk(1) before bgTypeId + // STATUS_NONE sends only queueSlot + arenaType + if (packet.getSize() - packet.getReadPos() < 1) { + LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared"); + return; + } + arenaType = packet.readUInt8(); + if (packet.getSize() - packet.getReadPos() < 1) return; + packet.readUInt8(); // unk + } else { + // Classic STATUS_NONE sends only queueSlot + bgTypeId (4 bytes) + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_INFO("Battlefield status: queue slot ", queueSlot, " cleared"); + return; + } } - uint8_t arenaType = packet.readUInt8(); - if (packet.getSize() - packet.getReadPos() < 1) return; - - // Unknown byte - packet.readUInt8(); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t bgTypeId = packet.readUInt32(); @@ -11418,17 +16855,81 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t statusId = packet.readUInt32(); - std::string bgName = "Battleground #" + std::to_string(bgTypeId); + // Map BG type IDs to their names (stable across all three expansions) + // BattlemasterList.dbc IDs (3.3.5a) + static const std::pair kBgNames[] = { + {1, "Alterac Valley"}, + {2, "Warsong Gulch"}, + {3, "Arathi Basin"}, + {4, "Nagrand Arena"}, + {5, "Blade's Edge Arena"}, + {6, "All Arenas"}, + {7, "Eye of the Storm"}, + {8, "Ruins of Lordaeron"}, + {9, "Strand of the Ancients"}, + {10, "Dalaran Sewers"}, + {11, "Ring of Valor"}, + {30, "Isle of Conquest"}, + {32, "Random Battleground"}, + }; + std::string bgName = "Battleground"; + for (const auto& kv : kBgNames) { + if (kv.first == bgTypeId) { bgName = kv.second; break; } + } + if (bgName == "Battleground") + bgName = "Battleground #" + std::to_string(bgTypeId); if (arenaType > 0) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; + // If bgTypeId matches a named arena, prefer that name + for (const auto& kv : kBgNames) { + if (kv.first == bgTypeId) { + bgName += " (" + std::string(kv.second) + ")"; + break; + } + } + } + + // Parse status-specific fields + uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) + uint32_t avgWaitSec = 0, timeInQueueSec = 0; + if (statusId == 1) { + // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) + if (packet.getSize() - packet.getReadPos() >= 8) { + avgWaitSec = packet.readUInt32() / 1000; // ms → seconds + timeInQueueSec = packet.readUInt32() / 1000; + } + } else if (statusId == 2) { + // STATUS_WAIT_JOIN: timeout(4) + mapId(4) + if (packet.getSize() - packet.getReadPos() >= 4) { + inviteTimeout = packet.readUInt32(); + } + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t mapId =*/ packet.readUInt32(); + } + } else if (statusId == 3) { + // STATUS_IN_PROGRESS: mapId(4) + timeSinceStart(4) + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t mapId =*/ packet.readUInt32(); + /*uint32_t elapsed =*/ packet.readUInt32(); + } } // Store queue state if (queueSlot < bgQueues_.size()) { + bool wasInvite = (bgQueues_[queueSlot].statusId == 2); bgQueues_[queueSlot].queueSlot = queueSlot; bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; + bgQueues_[queueSlot].bgName = bgName; + if (statusId == 1) { + bgQueues_[queueSlot].avgWaitTimeSec = avgWaitSec; + bgQueues_[queueSlot].timeInQueueSec = timeInQueueSec; + } + if (statusId == 2 && !wasInvite) { + bgQueues_[queueSlot].inviteTimeout = inviteTimeout; + bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now(); + } } switch (statusId) { @@ -11440,8 +16941,10 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName); break; case 2: // STATUS_WAIT_JOIN - addSystemChatMessage(bgName + " is ready! Type /join to enter."); - LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName); + // Popup shown by the UI; add chat notification too. + addSystemChatMessage(bgName + " is ready!"); + LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName, + " timeout=", inviteTimeout, "s"); break; case 3: // STATUS_IN_PROGRESS addSystemChatMessage("Entered " + bgName + "."); @@ -11454,6 +16957,120 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { LOG_INFO("Battlefield status: unknown (", statusId, ") for ", bgName); break; } + if (addonEventCallback_) + addonEventCallback_("UPDATE_BATTLEFIELD_STATUS", {std::to_string(statusId)}); +} + +void GameHandler::handleBattlefieldList(network::Packet& packet) { + // SMSG_BATTLEFIELD_LIST wire format by expansion: + // + // Classic 1.12 (vmangos/cmangos): + // bgTypeId(4) isRegistered(1) count(4) [instanceId(4)...] + // + // TBC 2.4.3: + // bgTypeId(4) isRegistered(1) isHoliday(1) count(4) [instanceId(4)...] + // + // WotLK 3.3.5a: + // bgTypeId(4) isRegistered(1) isHoliday(1) minLevel(4) maxLevel(4) count(4) [instanceId(4)...] + + if (packet.getSize() - packet.getReadPos() < 5) return; + + AvailableBgInfo info; + info.bgTypeId = packet.readUInt32(); + info.isRegistered = packet.readUInt8() != 0; + + const bool isWotlk = isActiveExpansion("wotlk"); + const bool isTbc = isActiveExpansion("tbc"); + + if (isTbc || isWotlk) { + if (packet.getSize() - packet.getReadPos() < 1) return; + info.isHoliday = packet.readUInt8() != 0; + } + + if (isWotlk) { + if (packet.getSize() - packet.getReadPos() < 8) return; + info.minLevel = packet.readUInt32(); + info.maxLevel = packet.readUInt32(); + } + + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t count = packet.readUInt32(); + + // Sanity cap to avoid OOM from malformed packets + constexpr uint32_t kMaxInstances = 256; + count = std::min(count, kMaxInstances); + info.instanceIds.reserve(count); + + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 4) break; + info.instanceIds.push_back(packet.readUInt32()); + } + + // Update or append the entry for this BG type + bool updated = false; + for (auto& existing : availableBgs_) { + if (existing.bgTypeId == info.bgTypeId) { + existing = std::move(info); + updated = true; + break; + } + } + if (!updated) { + availableBgs_.push_back(std::move(info)); + } + + const auto& stored = availableBgs_.back(); + static const std::unordered_map kBgNames = { + {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, + {4, "Nagrand Arena"}, {5, "Blade's Edge Arena"}, {6, "All Arenas"}, + {7, "Eye of the Storm"}, {8, "Ruins of Lordaeron"}, + {9, "Strand of the Ancients"}, {10, "Dalaran Sewers"}, + {11, "The Ring of Valor"}, {30, "Isle of Conquest"}, + }; + auto nameIt = kBgNames.find(stored.bgTypeId); + const char* bgName = (nameIt != kBgNames.end()) ? nameIt->second : "Unknown Battleground"; + + LOG_INFO("SMSG_BATTLEFIELD_LIST: ", bgName, " bgType=", stored.bgTypeId, + " registered=", stored.isRegistered ? "yes" : "no", + " instances=", stored.instanceIds.size()); +} + +void GameHandler::declineBattlefield(uint32_t queueSlot) { + if (state != WorldState::IN_WORLD) return; + if (!socket) return; + + const BgQueueSlot* slot = nullptr; + if (queueSlot == 0xFFFFFFFF) { + for (const auto& s : bgQueues_) { + if (s.statusId == 2) { slot = &s; break; } + } + } else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) { + slot = &bgQueues_[queueSlot]; + } + + if (!slot) { + addSystemChatMessage("No battleground invitation pending."); + return; + } + + // CMSG_BATTLEFIELD_PORT with action=0 (decline) + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); + pkt.writeUInt8(slot->arenaType); + pkt.writeUInt8(0x00); + pkt.writeUInt32(slot->bgTypeId); + pkt.writeUInt16(0x0000); + pkt.writeUInt8(0); // 0 = decline + + socket->send(pkt); + + // Clear queue slot + uint32_t clearSlot = slot->queueSlot; + if (clearSlot < bgQueues_.size()) { + bgQueues_[clearSlot] = BgQueueSlot{}; + } + + addSystemChatMessage("Battleground invitation declined."); + LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: decline"); } bool GameHandler::hasPendingBgInvite() const { @@ -11492,6 +17109,12 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { socket->send(pkt); + // Optimistically clear the invite so the popup disappears immediately. + uint32_t clearSlot = slot->queueSlot; + if (clearSlot < bgQueues_.size()) { + bgQueues_[clearSlot].statusId = 3; // STATUS_IN_PROGRESS (server will confirm) + } + addSystemChatMessage("Accepting battleground invitation..."); LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); } @@ -11532,11 +17155,39 @@ void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { } void GameHandler::handleInstanceDifficulty(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 8) return; + // SMSG_INSTANCE_DIFFICULTY: uint32 difficulty, uint32 heroic (8 bytes) + // MSG_SET_DUNGEON_DIFFICULTY: uint32 difficulty[, uint32 isInGroup, uint32 savedBool] (4 or 12 bytes) + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 4) return; + uint32_t prevDifficulty = instanceDifficulty_; instanceDifficulty_ = packet.readUInt32(); - uint32_t isHeroic = packet.readUInt32(); - instanceIsHeroic_ = (isHeroic != 0); + if (rem() >= 4) { + uint32_t secondField = packet.readUInt32(); + // SMSG_INSTANCE_DIFFICULTY: second field is heroic flag (0 or 1) + // MSG_SET_DUNGEON_DIFFICULTY: second field is isInGroup (not heroic) + // Heroic = difficulty value 1 for 5-man, so use the field value for SMSG and + // infer from difficulty for MSG variant (which has larger payloads). + if (rem() >= 4) { + // Three+ fields: this is MSG_SET_DUNGEON_DIFFICULTY; heroic = (difficulty == 1) + instanceIsHeroic_ = (instanceDifficulty_ == 1); + } else { + // Two fields: SMSG_INSTANCE_DIFFICULTY format + instanceIsHeroic_ = (secondField != 0); + } + } else { + instanceIsHeroic_ = (instanceDifficulty_ == 1); + } + inInstance_ = true; LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); + + // Announce difficulty change to the player (only when it actually changes) + // difficulty values: 0=Normal, 1=Heroic, 2=25-Man Normal, 3=25-Man Heroic + if (instanceDifficulty_ != prevDifficulty) { + static const char* kDiffLabels[] = {"Normal", "Heroic", "25-Man Normal", "25-Man Heroic"}; + const char* diffLabel = (instanceDifficulty_ < 4) ? kDiffLabels[instanceDifficulty_] : nullptr; + if (diffLabel) + addSystemChatMessage(std::string("Dungeon difficulty set to ") + diffLabel + "."); + } } // --------------------------------------------------------------------------- @@ -11588,10 +17239,17 @@ void GameHandler::handleLfgJoinResult(network::Packet& packet) { // Success — state tells us what phase we're entering lfgState_ = static_cast(state); LOG_INFO("SMSG_LFG_JOIN_RESULT: success, state=", static_cast(state)); - addSystemChatMessage("Dungeon Finder: Joined the queue."); + { + std::string dName = getLfgDungeonName(lfgDungeonId_); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: Joined the queue for " + dName + "."); + else + addSystemChatMessage("Dungeon Finder: Joined the queue."); + } } else { const char* msg = lfgJoinResultString(result); std::string errMsg = std::string("Dungeon Finder: ") + (msg ? msg : "Join failed."); + addUIError(errMsg); addSystemChatMessage(errMsg); LOG_INFO("SMSG_LFG_JOIN_RESULT: result=", static_cast(result), " state=", static_cast(state)); @@ -11637,17 +17295,28 @@ void GameHandler::handleLfgProposalUpdate(network::Packet& packet) { case 0: lfgState_ = LfgState::Queued; lfgProposalId_ = 0; + addUIError("Dungeon Finder: Group proposal failed."); addSystemChatMessage("Dungeon Finder: Group proposal failed."); break; - case 1: + case 1: { lfgState_ = LfgState::InDungeon; lfgProposalId_ = 0; - addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); + std::string dName = getLfgDungeonName(dungeonId); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: Group found for " + dName + "! Entering dungeon..."); + else + addSystemChatMessage("Dungeon Finder: Group found! Entering dungeon..."); break; - case 2: + } + case 2: { lfgState_ = LfgState::Proposal; - addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); + std::string dName = getLfgDungeonName(dungeonId); + if (!dName.empty()) + addSystemChatMessage("Dungeon Finder: A group has been found for " + dName + ". Accept or decline."); + else + addSystemChatMessage("Dungeon Finder: A group has been found. Accept or decline."); break; + } default: break; } @@ -11670,6 +17339,7 @@ void GameHandler::handleLfgRoleCheckUpdate(network::Packet& packet) { LOG_INFO("LFG role check finished"); } else if (roleCheckState == 3) { lfgState_ = LfgState::None; + addUIError("Dungeon Finder: Role check failed — missing required role."); addSystemChatMessage("Dungeon Finder: Role check failed — missing required role."); } else if (roleCheckState == 2) { lfgState_ = LfgState::RoleCheck; @@ -11738,8 +17408,7 @@ void GameHandler::handleLfgUpdatePlayer(network::Packet& packet) { } void GameHandler::handleLfgPlayerReward(network::Packet& packet) { - size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining < 4 + 4 + 1 + 4 + 4 + 4) return; + if (!packetHasRemaining(packet, 4 + 4 + 1 + 4 + 4 + 4)) return; /*uint32_t randomDungeonEntry =*/ packet.readUInt32(); /*uint32_t dungeonEntry =*/ packet.readUInt32(); @@ -11762,14 +17431,20 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { std::string rewardMsg = std::string("Dungeon Finder reward: ") + moneyBuf + ", " + std::to_string(xp) + " XP"; - if (packet.getSize() - packet.getReadPos() >= 4) { + if (packetHasRemaining(packet, 4)) { uint32_t rewardCount = packet.readUInt32(); - for (uint32_t i = 0; i < rewardCount && packet.getSize() - packet.getReadPos() >= 9; ++i) { + for (uint32_t i = 0; i < rewardCount && packetHasRemaining(packet, 9); ++i) { uint32_t itemId = packet.readUInt32(); uint32_t itemCount = packet.readUInt32(); packet.readUInt8(); // unk if (i == 0) { - rewardMsg += ", item #" + std::to_string(itemId); + std::string itemLabel = "item #" + std::to_string(itemId); + uint32_t lfgItemQuality = 1; + if (const ItemQueryResponseData* info = getItemInfo(itemId)) { + if (!info->name.empty()) itemLabel = info->name; + lfgItemQuality = info->quality; + } + rewardMsg += ", " + buildItemLink(itemId, lfgItemQuality, itemLabel); if (itemCount > 1) rewardMsg += " x" + std::to_string(itemCount); } } @@ -11781,31 +17456,47 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { } void GameHandler::handleLfgBootProposalUpdate(network::Packet& packet) { - size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining < 7 + 4 + 4 + 4 + 4) return; + if (!packetHasRemaining(packet, 7 + 4 + 4 + 4 + 4)) return; bool inProgress = packet.readUInt8() != 0; - bool myVote = packet.readUInt8() != 0; - bool myAnswer = packet.readUInt8() != 0; + /*bool myVote =*/ packet.readUInt8(); // whether local player has voted + /*bool myAnswer =*/ packet.readUInt8(); // local player's vote (yes/no) — unused; result derived from counts uint32_t totalVotes = packet.readUInt32(); uint32_t bootVotes = packet.readUInt32(); uint32_t timeLeft = packet.readUInt32(); uint32_t votesNeeded = packet.readUInt32(); - (void)myVote; (void)totalVotes; (void)bootVotes; (void)timeLeft; (void)votesNeeded; + lfgBootVotes_ = bootVotes; + lfgBootTotal_ = totalVotes; + lfgBootTimeLeft_ = timeLeft; + lfgBootNeeded_ = votesNeeded; + + // Optional: reason string and target name (null-terminated) follow the fixed fields + if (packet.getReadPos() < packet.getSize()) + lfgBootReason_ = packet.readString(); + if (packet.getReadPos() < packet.getSize()) + lfgBootTargetName_ = packet.readString(); if (inProgress) { - addSystemChatMessage( - std::string("Dungeon Finder: Vote to kick in progress (") + - std::to_string(timeLeft) + "s remaining)."); - } else if (myAnswer) { - addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); + lfgState_ = LfgState::Boot; } else { - addSystemChatMessage("Dungeon Finder: Vote kick failed."); + // Boot vote ended — pass/fail determined by whether enough yes votes were cast, + // not by the local player's own vote (myAnswer = what *I* voted, not the result). + const bool bootPassed = (bootVotes >= votesNeeded); + lfgBootVotes_ = lfgBootTotal_ = lfgBootTimeLeft_ = lfgBootNeeded_ = 0; + lfgBootTargetName_.clear(); + lfgBootReason_.clear(); + lfgState_ = LfgState::InDungeon; + if (bootPassed) { + addSystemChatMessage("Dungeon Finder: Vote kick passed — member removed."); + } else { + addSystemChatMessage("Dungeon Finder: Vote kick failed."); + } } LOG_INFO("SMSG_LFG_BOOT_PROPOSAL_UPDATE: inProgress=", inProgress, - " bootVotes=", bootVotes, "/", totalVotes); + " bootVotes=", bootVotes, "/", totalVotes, + " target=", lfgBootTargetName_, " reason=", lfgBootReason_); } void GameHandler::handleLfgTeleportDenied(network::Packet& packet) { @@ -11849,6 +17540,17 @@ void GameHandler::lfgLeave() { LOG_INFO("Sent CMSG_LFG_LEAVE"); } +void GameHandler::lfgSetRoles(uint8_t roles) { + if (state != WorldState::IN_WORLD || !socket) return; + const uint32_t wire = wireOpcode(Opcode::CMSG_LFG_SET_ROLES); + if (wire == 0xFFFF) return; + + network::Packet pkt(static_cast(wire)); + pkt.writeUInt8(roles); + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_SET_ROLES: roles=", static_cast(roles)); +} + void GameHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) { if (!socket) return; @@ -11870,6 +17572,18 @@ void GameHandler::lfgTeleport(bool toLfgDungeon) { LOG_INFO("Sent CMSG_LFG_TELEPORT: toLfgDungeon=", toLfgDungeon); } +void GameHandler::lfgSetBootVote(bool vote) { + if (!socket) return; + uint16_t wireOp = wireOpcode(Opcode::CMSG_LFG_SET_BOOT_VOTE); + if (wireOp == 0xFFFF) return; + + network::Packet pkt(wireOp); + pkt.writeUInt8(vote ? 1 : 0); + + socket->send(pkt); + LOG_INFO("Sent CMSG_LFG_SET_BOOT_VOTE: vote=", vote); +} + void GameHandler::loadAreaTriggerDbc() { if (areaTriggerDbcLoaded_) return; areaTriggerDbcLoaded_ = true; @@ -12016,7 +17730,89 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t teamId = packet.readUInt32(); std::string teamName = packet.readString(); - LOG_INFO("Arena team query response: id=", teamId, " name=", teamName); + uint32_t teamType = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + teamType = packet.readUInt32(); + LOG_INFO("Arena team query response: id=", teamId, " name=", teamName, " type=", teamType); + + // Store name and type in matching ArenaTeamStats entry + for (auto& s : arenaTeamStats_) { + if (s.teamId == teamId) { + s.teamName = teamName; + s.teamType = teamType; + return; + } + } + // No stats entry yet — create a placeholder so we can show the name + ArenaTeamStats stub; + stub.teamId = teamId; + stub.teamName = teamName; + stub.teamType = teamType; + arenaTeamStats_.push_back(std::move(stub)); +} + +void GameHandler::handleArenaTeamRoster(network::Packet& packet) { + // SMSG_ARENA_TEAM_ROSTER (WotLK 3.3.5a): + // uint32 teamId + // uint8 unk (0 = not captainship packet) + // uint32 memberCount + // For each member: + // uint64 guid + // uint8 online (1=online, 0=offline) + // string name (null-terminated) + // uint32 gamesWeek + // uint32 winsWeek + // uint32 gamesSeason + // uint32 winsSeason + // uint32 personalRating + // float modDay (unused here) + // float modWeek (unused here) + if (packet.getSize() - packet.getReadPos() < 9) return; + + uint32_t teamId = packet.readUInt32(); + /*uint8_t unk =*/ packet.readUInt8(); + uint32_t memberCount = packet.readUInt32(); + + // Sanity cap to avoid huge allocations from malformed packets + if (memberCount > 100) memberCount = 100; + + ArenaTeamRoster roster; + roster.teamId = teamId; + roster.members.reserve(memberCount); + + for (uint32_t i = 0; i < memberCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 12) break; + + ArenaTeamMember m; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + m.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 20) break; + m.weekGames = packet.readUInt32(); + m.weekWins = packet.readUInt32(); + m.seasonGames = packet.readUInt32(); + m.seasonWins = packet.readUInt32(); + m.personalRating = packet.readUInt32(); + // skip 2 floats (modDay, modWeek) + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readFloat(); + packet.readFloat(); + } + roster.members.push_back(std::move(m)); + } + + // Replace existing roster for this team or append + for (auto& r : arenaTeamRosters_) { + if (r.teamId == teamId) { + r = std::move(roster); + LOG_INFO("SMSG_ARENA_TEAM_ROSTER: updated teamId=", teamId, + " members=", r.members.size()); + return; + } + } + LOG_INFO("SMSG_ARENA_TEAM_ROSTER: new teamId=", teamId, + " members=", roster.members.size()); + arenaTeamRosters_.push_back(std::move(roster)); } void GameHandler::handleArenaTeamInvite(network::Packet& packet) { @@ -12030,12 +17826,6 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 1) return; uint8_t event = packet.readUInt8(); - static const char* events[] = { - "joined", "left", "removed", "leader changed", - "disbanded", "created" - }; - std::string eventName = (event < 6) ? events[event] : "unknown event"; - // Read string params (up to 3) uint8_t strCount = 0; if (packet.getSize() - packet.getReadPos() >= 1) { @@ -12046,11 +17836,85 @@ void GameHandler::handleArenaTeamEvent(network::Packet& packet) { if (strCount >= 1 && packet.getSize() > packet.getReadPos()) param1 = packet.readString(); if (strCount >= 2 && packet.getSize() > packet.getReadPos()) param2 = packet.readString(); - std::string msg = "Arena team " + eventName; - if (!param1.empty()) msg += ": " + param1; - if (!param2.empty()) msg += " (" + param2 + ")"; + // Build natural-language message based on event type + // Event params: 0=joined(name), 1=left(name), 2=removed(name,kicker), + // 3=leader_changed(new,old), 4=disbanded, 5=created(name) + std::string msg; + switch (event) { + case 0: // joined + msg = param1.empty() ? "A player has joined your arena team." + : param1 + " has joined your arena team."; + break; + case 1: // left + msg = param1.empty() ? "A player has left the arena team." + : param1 + " has left the arena team."; + break; + case 2: // removed + if (!param1.empty() && !param2.empty()) + msg = param1 + " has been removed from the arena team by " + param2 + "."; + else if (!param1.empty()) + msg = param1 + " has been removed from the arena team."; + else + msg = "A player has been removed from the arena team."; + break; + case 3: // leader changed + msg = param1.empty() ? "The arena team captain has changed." + : param1 + " is now the arena team captain."; + break; + case 4: // disbanded + msg = "Your arena team has been disbanded."; + break; + case 5: // created + msg = param1.empty() ? "Your arena team has been created." + : "Arena team \"" + param1 + "\" has been created."; + break; + default: + msg = "Arena team event " + std::to_string(event); + if (!param1.empty()) msg += ": " + param1; + break; + } addSystemChatMessage(msg); - LOG_INFO("Arena team event: ", eventName, " ", param1, " ", param2); + LOG_INFO("Arena team event: ", (int)event, " ", param1, " ", param2); +} + +void GameHandler::handleArenaTeamStats(network::Packet& packet) { + // SMSG_ARENA_TEAM_STATS (WotLK 3.3.5a): + // uint32 teamId, uint32 rating, uint32 weekGames, uint32 weekWins, + // uint32 seasonGames, uint32 seasonWins, uint32 rank + if (packet.getSize() - packet.getReadPos() < 28) return; + + ArenaTeamStats stats; + stats.teamId = packet.readUInt32(); + stats.rating = packet.readUInt32(); + stats.weekGames = packet.readUInt32(); + stats.weekWins = packet.readUInt32(); + stats.seasonGames = packet.readUInt32(); + stats.seasonWins = packet.readUInt32(); + stats.rank = packet.readUInt32(); + + // Update or insert for this team (preserve name/type from query response) + for (auto& s : arenaTeamStats_) { + if (s.teamId == stats.teamId) { + stats.teamName = std::move(s.teamName); + stats.teamType = s.teamType; + s = std::move(stats); + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", s.teamId, + " rating=", s.rating, " rank=", s.rank); + return; + } + } + arenaTeamStats_.push_back(std::move(stats)); + LOG_INFO("SMSG_ARENA_TEAM_STATS: teamId=", arenaTeamStats_.back().teamId, + " rating=", arenaTeamStats_.back().rating, + " rank=", arenaTeamStats_.back().rank); +} + +void GameHandler::requestArenaTeamRoster(uint32_t teamId) { + if (!socket) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_ARENA_TEAM_ROSTER)); + pkt.writeUInt32(teamId); + socket->send(pkt); + LOG_INFO("Requesting arena team roster for teamId=", teamId); } void GameHandler::handleArenaError(network::Packet& packet) { @@ -12069,6 +17933,130 @@ void GameHandler::handleArenaError(network::Packet& packet) { LOG_INFO("Arena error: ", error, " - ", msg); } +void GameHandler::requestPvpLog() { + if (state != WorldState::IN_WORLD || !socket) return; + // MSG_PVP_LOG_DATA is bidirectional: client sends an empty packet to request + network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA)); + socket->send(pkt); + LOG_INFO("Requested PvP log data"); +} + +void GameHandler::handlePvpLogData(network::Packet& packet) { + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 1) return; + + bgScoreboard_ = BgScoreboardData{}; + bgScoreboard_.isArena = (packet.readUInt8() != 0); + + if (bgScoreboard_.isArena) { + // WotLK 3.3.5a MSG_PVP_LOG_DATA arena header: + // two team blocks × (uint32 ratingChange + uint32 newRating + uint32 unk1 + uint32 unk2 + uint32 unk3 + CString teamName) + // After both team blocks: same player list and winner fields as battleground. + for (int t = 0; t < 2; ++t) { + if (remaining() < 20) { packet.setReadPos(packet.getSize()); return; } + bgScoreboard_.arenaTeams[t].ratingChange = packet.readUInt32(); + bgScoreboard_.arenaTeams[t].newRating = packet.readUInt32(); + packet.readUInt32(); // unk1 + packet.readUInt32(); // unk2 + packet.readUInt32(); // unk3 + bgScoreboard_.arenaTeams[t].teamName = remaining() > 0 ? packet.readString() : ""; + } + // Fall through to parse player list and winner fields below (same layout as BG) + } + + if (remaining() < 4) return; + uint32_t playerCount = packet.readUInt32(); + bgScoreboard_.players.reserve(playerCount); + + for (uint32_t i = 0; i < playerCount && remaining() >= 13; ++i) { + BgPlayerScore ps; + ps.guid = packet.readUInt64(); + ps.team = packet.readUInt8(); + ps.killingBlows = packet.readUInt32(); + ps.honorableKills = packet.readUInt32(); + ps.deaths = packet.readUInt32(); + ps.bonusHonor = packet.readUInt32(); + + // Resolve player name from entity manager + { + auto ent = entityManager.getEntity(ps.guid); + if (ent && (ent->getType() == game::ObjectType::PLAYER || + ent->getType() == game::ObjectType::UNIT)) { + auto u = std::static_pointer_cast(ent); + if (!u->getName().empty()) ps.name = u->getName(); + } + } + + // BG-specific stat blocks: uint32 count + N × (string fieldName + uint32 value) + if (remaining() < 4) { bgScoreboard_.players.push_back(std::move(ps)); break; } + uint32_t statCount = packet.readUInt32(); + for (uint32_t s = 0; s < statCount && remaining() >= 5; ++s) { + std::string fieldName; + while (remaining() > 0) { + char c = static_cast(packet.readUInt8()); + if (c == '\0') break; + fieldName += c; + } + uint32_t val = (remaining() >= 4) ? packet.readUInt32() : 0; + ps.bgStats.emplace_back(std::move(fieldName), val); + } + + bgScoreboard_.players.push_back(std::move(ps)); + } + + if (remaining() >= 1) { + bgScoreboard_.hasWinner = (packet.readUInt8() != 0); + if (bgScoreboard_.hasWinner && remaining() >= 1) + bgScoreboard_.winner = packet.readUInt8(); + } + + if (bgScoreboard_.isArena) { + LOG_INFO("Arena log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner, + " team0='", bgScoreboard_.arenaTeams[0].teamName, + "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[0].ratingChange, + " team1='", bgScoreboard_.arenaTeams[1].teamName, + "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[1].ratingChange); + } else { + LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); + } +} + +void GameHandler::handleMoveSetSpeed(network::Packet& packet) { + // MSG_MOVE_SET_*_SPEED: PackedGuid (WotLK) / full uint64 (Classic/TBC) + MovementInfo + float speed. + // The MovementInfo block is variable-length; rather than fully parsing it, we read the + // fixed prefix, skip over optional blocks by consuming remaining bytes until 4 remain, + // then read the speed float. This is safe because the speed is always the last field. + const bool useFull = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t moverGuid = useFull + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + + // Skip to the last 4 bytes — the speed float — by advancing past the MovementInfo. + // This avoids duplicating the full variable-length MovementInfo parser here. + const size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4) return; + if (remaining > 4) { + // Advance past all MovementInfo bytes (flags, time, position, optional blocks). + // Speed is always the last 4 bytes in the packet. + packet.setReadPos(packet.getSize() - 4); + } + + float speed = packet.readFloat(); + if (!std::isfinite(speed) || speed <= 0.01f || speed > 200.0f) return; + + // Update local player speed state if this broadcast targets us. + if (moverGuid != playerGuid) return; + const uint16_t wireOp = packet.getOpcode(); + if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_SPEED)) serverRunSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_BACK_SPEED)) serverRunBackSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_WALK_SPEED)) serverWalkSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_SPEED)) serverSwimSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED)) serverSwimBackSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_SPEED)) serverFlightSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED))serverFlightBackSpeed_= speed; +} + void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); @@ -12093,6 +18081,32 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { info.z = packet.readFloat(); info.orientation = packet.readFloat(); + // Read transport data if the on-transport flag is set in wire-format move flags. + // The flag bit position differs between expansions (0x200 for WotLK/TBC, 0x02000000 for Classic/Turtle). + const uint32_t wireTransportFlag = packetParsers_ ? packetParsers_->wireOnTransportFlag() : 0x00000200; + const bool onTransport = (info.flags & wireTransportFlag) != 0; + uint64_t transportGuid = 0; + float tLocalX = 0, tLocalY = 0, tLocalZ = 0, tLocalO = 0; + if (onTransport) { + transportGuid = UpdateObjectParser::readPackedGuid(packet); + tLocalX = packet.readFloat(); + tLocalY = packet.readFloat(); + tLocalZ = packet.readFloat(); + tLocalO = packet.readFloat(); + // TBC and WotLK include a transport timestamp; Classic does not. + if (flags2Size >= 1) { + /*uint32_t transportTime =*/ packet.readUInt32(); + } + // WotLK adds a transport seat byte. + if (flags2Size >= 2) { + /*int8_t transportSeat =*/ packet.readUInt8(); + // Optional second transport time for interpolated movement. + if (info.flags2 & 0x0200) { + /*uint32_t transportTime2 =*/ packet.readUInt32(); + } + } + } + // Update entity position in entity manager auto entity = entityManager.getEntity(moverGuid); if (!entity) { @@ -12102,6 +18116,20 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { // Convert server coords to canonical glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(info.x, info.y, info.z)); float canYaw = core::coords::serverToCanonicalYaw(info.orientation); + + // Handle transport attachment: attach/detach the entity so it follows the transport + // smoothly between movement updates via updateAttachedTransportChildren(). + if (onTransport && transportGuid != 0 && transportManager_) { + glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(tLocalX, tLocalY, tLocalZ)); + setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, true, + core::coords::serverToCanonicalYaw(tLocalO)); + // Derive world position from transport system for best accuracy. + glm::vec3 worldPos = transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + canonical = worldPos; + } else if (!onTransport) { + // Player left transport — clear any stale attachment. + clearTransportAttachment(moverGuid); + } // Compute a smoothed interpolation window for this player. // Using a raw packet delta causes jitter when timing spikes (e.g. 50ms then 300ms). // An exponential moving average of intervals gives a stable playback speed that @@ -12123,21 +18151,85 @@ 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); } } void GameHandler::handleCompressedMoves(network::Packet& packet) { - // Vanilla/Classic SMSG_COMPRESSED_MOVES: raw concatenated sub-packets, NOT zlib. - // Evidence: observed 1-byte "00" packets which are not valid zlib streams. - // Each sub-packet: uint8 size (of opcode[2]+payload), uint16 opcode, uint8[] payload. - // size=0 → invalid/empty, signals end of batch. - const auto& data = packet.getData(); - size_t dataLen = data.size(); + // Vanilla-family SMSG_COMPRESSED_MOVES carries concatenated movement sub-packets. + // Turtle can additionally wrap the batch in the same uint32 decompressedSize + zlib + // envelope used by other compressed world packets. + // + // Within the decompressed stream, some realms encode the leading uint8 size as: + // - opcode(2) + payload bytes + // - payload bytes only + // Try both framing modes and use the one that cleanly consumes the batch. + std::vector decompressedStorage; + const std::vector* dataPtr = &packet.getData(); + + const auto& rawData = packet.getData(); + const bool hasCompressedWrapper = + rawData.size() >= 6 && + rawData[4] == 0x78 && + (rawData[5] == 0x01 || rawData[5] == 0x9C || + rawData[5] == 0xDA || rawData[5] == 0x5E); + if (hasCompressedWrapper) { + uint32_t decompressedSize = static_cast(rawData[0]) | + (static_cast(rawData[1]) << 8) | + (static_cast(rawData[2]) << 16) | + (static_cast(rawData[3]) << 24); + if (decompressedSize == 0 || decompressedSize > 65536) { + LOG_WARNING("SMSG_COMPRESSED_MOVES: bad decompressedSize=", decompressedSize); + return; + } + + decompressedStorage.resize(decompressedSize); + uLongf destLen = decompressedSize; + int ret = uncompress(decompressedStorage.data(), &destLen, + rawData.data() + 4, rawData.size() - 4); + if (ret != Z_OK) { + LOG_WARNING("SMSG_COMPRESSED_MOVES: zlib error ", ret); + return; + } + + decompressedStorage.resize(destLen); + dataPtr = &decompressedStorage; + } + + const auto& data = *dataPtr; + const size_t dataLen = data.size(); // Wire opcodes for sub-packet routing uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); @@ -12145,7 +18237,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 kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12161,49 +18253,152 @@ 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), + wireOpcode(Opcode::MSG_MOVE_START_DESCEND), + wireOpcode(Opcode::MSG_MOVE_SET_PITCH), + wireOpcode(Opcode::MSG_MOVE_GRAVITY_CHNG), + wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_FLY), + wireOpcode(Opcode::MSG_MOVE_UPDATE_CAN_TRANSITION_BETWEEN_SWIM_AND_FLY), + wireOpcode(Opcode::MSG_MOVE_ROOT), + wireOpcode(Opcode::MSG_MOVE_UNROOT), }; + struct CompressedMoveSubPacket { + uint16_t opcode = 0; + std::vector payload; + }; + struct DecodeResult { + bool ok = false; + bool overrun = false; + bool usedPayloadOnlySize = false; + size_t endPos = 0; + size_t recognizedCount = 0; + size_t subPacketCount = 0; + std::vector packets; + }; + + auto isRecognizedSubOpcode = [&](uint16_t subOpcode) { + return subOpcode == monsterMoveWire || + subOpcode == monsterMoveTransportWire || + std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end(); + }; + + auto decodeSubPackets = [&](bool payloadOnlySize) -> DecodeResult { + DecodeResult result; + result.usedPayloadOnlySize = payloadOnlySize; + size_t pos = 0; + while (pos < dataLen) { + if (pos + 1 > dataLen) break; + uint8_t subSize = data[pos]; + if (subSize == 0) { + result.ok = true; + result.endPos = pos + 1; + return result; + } + + const size_t payloadLen = payloadOnlySize + ? static_cast(subSize) + : (subSize >= 2 ? static_cast(subSize) - 2 : 0); + if (!payloadOnlySize && subSize < 2) { + result.endPos = pos; + return result; + } + + const size_t packetLen = 1 + 2 + payloadLen; + if (pos + packetLen > dataLen) { + result.overrun = true; + result.endPos = pos; + return result; + } + + uint16_t subOpcode = static_cast(data[pos + 1]) | + (static_cast(data[pos + 2]) << 8); + size_t payloadStart = pos + 3; + + CompressedMoveSubPacket subPacket; + subPacket.opcode = subOpcode; + subPacket.payload.assign(data.begin() + payloadStart, + data.begin() + payloadStart + payloadLen); + result.packets.push_back(std::move(subPacket)); + ++result.subPacketCount; + if (isRecognizedSubOpcode(subOpcode)) { + ++result.recognizedCount; + } + + pos += packetLen; + } + result.ok = (result.endPos == 0 || result.endPos == dataLen); + result.endPos = dataLen; + return result; + }; + + DecodeResult decoded = decodeSubPackets(false); + if (!decoded.ok || decoded.overrun) { + DecodeResult payloadOnlyDecoded = decodeSubPackets(true); + const bool preferPayloadOnly = + payloadOnlyDecoded.ok && + (!decoded.ok || decoded.overrun || payloadOnlyDecoded.recognizedCount > decoded.recognizedCount); + if (preferPayloadOnly) { + decoded = std::move(payloadOnlyDecoded); + static uint32_t payloadOnlyFallbackCount = 0; + ++payloadOnlyFallbackCount; + if (payloadOnlyFallbackCount <= 10 || (payloadOnlyFallbackCount % 100) == 0) { + LOG_WARNING("SMSG_COMPRESSED_MOVES decoded via payload-only size fallback", + " (occurrence=", payloadOnlyFallbackCount, ")"); + } + } + } + + if (!decoded.ok || decoded.overrun) { + LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", decoded.endPos); + return; + } + // Track unhandled sub-opcodes once per compressed packet (avoid log spam) std::unordered_set unhandledSeen; - size_t pos = 0; - while (pos < dataLen) { - if (pos + 1 > dataLen) break; - uint8_t subSize = data[pos]; - if (subSize < 2) break; // size=0 or 1 → empty/end-of-batch sentinel - if (pos + 1 + subSize > dataLen) { - LOG_WARNING("SMSG_COMPRESSED_MOVES: sub-packet overruns buffer at pos=", pos); - break; - } - uint16_t subOpcode = static_cast(data[pos + 1]) | - (static_cast(data[pos + 2]) << 8); - size_t payloadLen = subSize - 2; - size_t payloadStart = pos + 3; + for (const auto& entry : decoded.packets) { + network::Packet subPacket(entry.opcode, entry.payload); - std::vector subPayload(data.begin() + payloadStart, - data.begin() + payloadStart + payloadLen); - network::Packet subPacket(subOpcode, subPayload); - - if (subOpcode == monsterMoveWire) { + if (entry.opcode == monsterMoveWire) { handleMonsterMove(subPacket); - } else if (subOpcode == monsterMoveTransportWire) { + } else if (entry.opcode == monsterMoveTransportWire) { handleMonsterMoveTransport(subPacket); } else if (state == WorldState::IN_WORLD && - std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end()) { + std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), entry.opcode) != kMoveOpcodes.end()) { // Player/NPC movement update packed in SMSG_MULTIPLE_MOVES handleOtherPlayerMovement(subPacket); } else { - if (unhandledSeen.insert(subOpcode).second) { + if (unhandledSeen.insert(entry.opcode).second) { LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x", - std::hex, subOpcode, std::dec, " payloadLen=", payloadLen); + std::hex, entry.opcode, std::dec, " payloadLen=", entry.payload.size()); } } - - pos = payloadStart + payloadLen; } } void GameHandler::handleMonsterMove(network::Packet& packet) { + if (isActiveExpansion("classic") || isActiveExpansion("turtle")) { + constexpr uint32_t kMaxMonsterMovesPerTick = 256; + ++monsterMovePacketsThisTick_; + if (monsterMovePacketsThisTick_ > kMaxMonsterMovesPerTick) { + ++monsterMovePacketsDroppedThisTick_; + if (monsterMovePacketsDroppedThisTick_ <= 3 || + (monsterMovePacketsDroppedThisTick_ % 100) == 0) { + LOG_WARNING("SMSG_MONSTER_MOVE: per-tick cap exceeded, dropping packet", + " (processed=", monsterMovePacketsThisTick_, + " dropped=", monsterMovePacketsDroppedThisTick_, ")"); + } + return; + } + } + MonsterMoveData data; auto logMonsterMoveParseFailure = [&](const std::string& msg) { static uint32_t failCount = 0; @@ -12212,6 +18407,14 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { LOG_WARNING(msg, " (occurrence=", failCount, ")"); } }; + auto logWrappedUncompressedFallbackUsed = [&]() { + static uint32_t wrappedUncompressedFallbackCount = 0; + ++wrappedUncompressedFallbackCount; + if (wrappedUncompressedFallbackCount <= 10 || (wrappedUncompressedFallbackCount % 100) == 0) { + LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback", + " (occurrence=", wrappedUncompressedFallbackCount, ")"); + } + }; auto stripWrappedSubpacket = [&](const std::vector& bytes, std::vector& stripped) -> bool { if (bytes.size() < 3) return false; uint8_t subSize = bytes[0]; @@ -12250,35 +18453,33 @@ 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 stripped; bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped); - // Try unwrapped payload first (common form), then wrapped-subpacket fallback. - network::Packet decompPacket(packet.getOpcode(), decompressed); - if (!packetParsers_->parseMonsterMove(decompPacket, data)) { - if (!hasWrappedForm) { - logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + - std::to_string(destLen) + " bytes)"); - return; - } + bool parsed = false; + if (hasWrappedForm) { network::Packet wrappedPacket(packet.getOpcode(), stripped); - if (!packetParsers_->parseMonsterMove(wrappedPacket, data)) { + if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { + parsed = true; + } + } + if (!parsed) { + network::Packet decompPacket(packet.getOpcode(), decompressed); + if (packetParsers_->parseMonsterMove(decompPacket, data)) { + parsed = true; + } + } + + if (!parsed) { + if (hasWrappedForm) { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + std::to_string(destLen) + " bytes, wrapped payload " + std::to_string(stripped.size()) + " bytes)"); - return; + } else { + logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE (decompressed " + + std::to_string(destLen) + " bytes)"); } - LOG_WARNING("SMSG_MONSTER_MOVE parsed via wrapped-subpacket fallback"); + return; } } else if (!packetParsers_->parseMonsterMove(packet, data)) { // Some realms occasionally embed an extra [size|opcode] wrapper even when the @@ -12287,7 +18488,7 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { if (stripWrappedSubpacket(rawData, stripped)) { network::Packet wrappedPacket(packet.getOpcode(), stripped); if (packetParsers_->parseMonsterMove(wrappedPacket, data)) { - LOG_WARNING("SMSG_MONSTER_MOVE parsed via uncompressed wrapped-subpacket fallback"); + logWrappedUncompressedFallbackUsed(); } else { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); return; @@ -12378,6 +18579,27 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { creatureMoveCallback_(data.guid, posCanonical.x, posCanonical.y, posCanonical.z, 0); } + } else if (data.moveType == 4) { + // FacingAngle without movement — rotate NPC in place + float orientation = core::coords::serverToCanonicalYaw(data.facingAngle); + glm::vec3 posCanonical = core::coords::serverToCanonical( + glm::vec3(data.x, data.y, data.z)); + entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, orientation); + if (creatureMoveCallback_) { + creatureMoveCallback_(data.guid, + posCanonical.x, posCanonical.y, posCanonical.z, 0); + } + } else if (data.moveType == 3 && data.facingTarget != 0) { + // FacingTarget without movement — rotate NPC to face a target + auto target = entityManager.getEntity(data.facingTarget); + if (target) { + float dx = target->getX() - entity->getX(); + float dy = target->getY() - entity->getY(); + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { + float orientation = std::atan2(-dy, dx); + entity->setOrientation(orientation); + } + } } } @@ -12467,6 +18689,12 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { if (packet.getReadPos() + 4 > packet.getSize()) return; uint32_t pointCount = packet.readUInt32(); + constexpr uint32_t kMaxTransportSplinePoints = 1000; + if (pointCount > kMaxTransportSplinePoints) { + LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: pointCount=", pointCount, + " clamped to ", kMaxTransportSplinePoints); + pointCount = kMaxTransportSplinePoints; + } // Read destination point (transport-local server coords) float destLocalX = localX, destLocalY = localY, destLocalZ = localZ; @@ -12540,8 +18768,11 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { bool isPlayerTarget = (data.targetGuid == playerGuid); if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat - if (isPlayerAttacker && meleeSwingCallback_) { - meleeSwingCallback_(); + if (isPlayerAttacker) { + lastMeleeSwingMs_ = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + if (meleeSwingCallback_) meleeSwingCallback_(); } if (!isPlayerAttacker && npcSwingCallback_) { npcSwingCallback_(data.attackerGuid); @@ -12579,16 +18810,46 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { } if (data.isMiss()) { - addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 1) { - addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::DODGE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 2) { - addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else if (data.victimState == 4) { - addCombatText(CombatTextEntry::BLOCK, 0, 0, isPlayerAttacker); + // VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount + if (data.totalDamage > 0) + addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + } else if (data.victimState == 5) { + // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). + addCombatText(CombatTextEntry::EVADE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + } else if (data.victimState == 6) { + // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. + addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + } else if (data.victimState == 7) { + // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). + addCombatText(CombatTextEntry::DEFLECT, 0, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } else { - auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; - addCombatText(type, data.totalDamage, 0, isPlayerAttacker); + CombatTextEntry::Type type; + if (data.isCrit()) + type = CombatTextEntry::CRIT_DAMAGE; + else if (data.isCrushing()) + type = CombatTextEntry::CRUSHING; + else if (data.isGlancing()) + type = CombatTextEntry::GLANCING; + else + type = CombatTextEntry::MELEE_DAMAGE; + addCombatText(type, data.totalDamage, 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + // Show partial absorb/resist from sub-damage entries + uint32_t totalAbsorbed = 0, totalResisted = 0; + for (const auto& sub : data.subDamages) { + totalAbsorbed += sub.absorbed; + totalResisted += sub.resisted; + } + if (totalAbsorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); + if (totalResisted > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker, 0, data.attackerGuid, data.targetGuid); } (void)isPlayerTarget; @@ -12608,7 +18869,12 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { } auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE; - addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + if (data.damage > 0) + addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); + if (data.absorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); + if (data.resisted > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource, 0, data.attackerGuid, data.targetGuid); } void GameHandler::handleSpellHealLog(network::Packet& packet) { @@ -12620,7 +18886,9 @@ void GameHandler::handleSpellHealLog(network::Packet& packet) { if (!isPlayerSource && !isPlayerTarget) return; // Not our combat auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; - addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource); + addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); + if (data.absorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource, 0, data.casterGuid, data.targetGuid); } // ============================================================ @@ -12649,7 +18917,17 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { return; } - if (casting) return; // Already casting + if (casting) { + // Spell queue: if we're within 400ms of the cast completing (and not channeling), + // store the spell so it fires automatically when the cast finishes. + if (!castIsChannel && castTimeRemaining > 0.0f && castTimeRemaining <= 0.4f) { + queuedSpellId_ = spellId; + queuedSpellTarget_ = targetGuid != 0 ? targetGuid : this->targetGuid; + LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining * 1000.0f, + "ms remaining)"); + } + return; + } // Hearthstone: cast spell directly (server checks item in inventory) // Using CMSG_CAST_SPELL is more reliable than CMSG_USE_ITEM which @@ -12693,22 +18971,16 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { } // Instant melee abilities: client-side range + facing check to avoid server "not in front" errors + // Detected via physical school mask (1) from DBC cache — covers warrior, rogue, DK, paladin, + // feral druid, and hunter melee abilities generically. { - uint32_t sid = spellId; - bool isMeleeAbility = - sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike - sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 || - sid == 25286 || sid == 29707 || sid == 30324 || - sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend - sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 || - sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge - sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 || - sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave - sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 || - sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike - sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 || - sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam - sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488; + loadSpellNameCache(); + bool isMeleeAbility = false; + auto cacheIt = spellNameCache_.find(spellId); + if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + // Physical school and no cast time (instant) — treat as melee ability + isMeleeAbility = true; + } if (isMeleeAbility && target != 0) { auto entity = entityManager.getEntity(target); if (entity) { @@ -12733,6 +19005,20 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { : CastSpellPacket::build(spellId, target, ++castCount); socket->send(packet); LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); + + // Fire UNIT_SPELLCAST_SENT for cast bar addons (fires on client intent, before server confirms) + if (addonEventCallback_) { + std::string targetName; + if (target != 0) targetName = lookupName(target); + addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)}); + } + + // Optimistically start GCD immediately on cast, but do not restart it while + // already active (prevents timeout animation reset on repeated key presses). + if (!isGCDActive()) { + gcdTotal_ = 1.5f; + gcdStartedAt_ = std::chrono::steady_clock::now(); + } } void GameHandler::cancelCast() { @@ -12745,9 +19031,30 @@ void GameHandler::cancelCast() { socket->send(packet); } pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + // Cancel craft queue and spell queue when player manually cancels cast + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; + if (addonEventCallback_) + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"}); +} + +void GameHandler::startCraftQueue(uint32_t spellId, int count) { + craftQueueSpellId_ = spellId; + craftQueueRemaining_ = count; + // Cast the first one immediately + castSpell(spellId, 0); +} + +void GameHandler::cancelCraftQueue() { + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; } void GameHandler::cancelAura(uint32_t spellId) { @@ -12756,6 +19063,19 @@ void GameHandler::cancelAura(uint32_t spellId) { socket->send(packet); } +uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + for (const auto& t : tempEnchantTimers_) { + if (t.slot == slot) { + return (t.expireMs > nowMs) + ? static_cast(t.expireMs - nowMs) : 0u; + } + } + return 0u; +} + void GameHandler::handlePetSpells(network::Packet& packet) { const size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 8) { @@ -12765,6 +19085,7 @@ void GameHandler::handlePetSpells(network::Packet& packet) { petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); LOG_INFO("SMSG_PET_SPELLS: pet cleared"); + if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); return; } @@ -12774,6 +19095,7 @@ void GameHandler::handlePetSpells(network::Packet& packet) { petAutocastSpells_.clear(); memset(petActionSlots_, 0, sizeof(petActionSlots_)); LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)"); + if (addonEventCallback_) addonEventCallback_("UNIT_PET", {"player"}); return; } @@ -12815,6 +19137,10 @@ done: LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec, " react=", (int)petReact_, " command=", (int)petCommand_, " spells=", petSpellList_.size()); + if (addonEventCallback_) { + addonEventCallback_("UNIT_PET", {"player"}); + addonEventCallback_("PET_BAR_UPDATE", {}); + } } void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { @@ -12831,11 +19157,123 @@ void GameHandler::dismissPet() { socket->send(packet); } +void GameHandler::togglePetSpellAutocast(uint32_t spellId) { + if (petGuid_ == 0 || spellId == 0 || state != WorldState::IN_WORLD || !socket) return; + bool currentlyOn = petAutocastSpells_.count(spellId) != 0; + uint8_t newState = currentlyOn ? 0 : 1; + // CMSG_PET_SPELL_AUTOCAST: petGuid(8) + spellId(4) + state(1) + network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST)); + pkt.writeUInt64(petGuid_); + pkt.writeUInt32(spellId); + pkt.writeUInt8(newState); + socket->send(pkt); + // Optimistically update local state; server will confirm via SMSG_PET_SPELLS + if (newState) + petAutocastSpells_.insert(spellId); + else + petAutocastSpells_.erase(spellId); + LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", (int)newState); +} + +void GameHandler::renamePet(const std::string& newName) { + if (petGuid_ == 0 || state != WorldState::IN_WORLD || !socket) return; + if (newName.empty() || newName.size() > 12) return; // Server enforces max 12 chars + auto packet = PetRenamePacket::build(petGuid_, newName, 0); + socket->send(packet); + LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, petGuid_, std::dec, " name='", newName, "'"); +} + +void GameHandler::requestStabledPetList() { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; + auto pkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(pkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, stableMasterGuid_, std::dec); +} + +void GameHandler::stablePet(uint8_t slot) { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0) return; + if (petGuid_ == 0) { + addSystemChatMessage("You do not have an active pet to stable."); + return; + } + auto pkt = StablePetPacket::build(stableMasterGuid_, slot); + socket->send(pkt); + LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast(slot)); +} + +void GameHandler::unstablePet(uint32_t petNumber) { + if (state != WorldState::IN_WORLD || !socket || stableMasterGuid_ == 0 || petNumber == 0) return; + auto pkt = UnstablePetPacket::build(stableMasterGuid_, petNumber); + socket->send(pkt); + LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber); +} + +void GameHandler::handleListStabledPets(network::Packet& packet) { + // SMSG MSG_LIST_STABLED_PETS: + // uint64 stableMasterGuid + // uint8 petCount + // uint8 numSlots + // per pet: + // uint32 petNumber + // uint32 entry + // uint32 level + // string name (null-terminated) + // uint32 displayId + // uint8 isActive (1 = active/summoned, 0 = stabled) + constexpr size_t kMinHeader = 8 + 1 + 1; + if (packet.getSize() - packet.getReadPos() < kMinHeader) { + LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")"); + return; + } + stableMasterGuid_ = packet.readUInt64(); + uint8_t petCount = packet.readUInt8(); + stableNumSlots_ = packet.readUInt8(); + + stabledPets_.clear(); + stabledPets_.reserve(petCount); + + for (uint8_t i = 0; i < petCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 4 + 4 + 4) break; + StabledPet pet; + pet.petNumber = packet.readUInt32(); + pet.entry = packet.readUInt32(); + pet.level = packet.readUInt32(); + pet.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 4 + 1) break; + pet.displayId = packet.readUInt32(); + pet.isActive = (packet.readUInt8() != 0); + stabledPets_.push_back(std::move(pet)); + } + + stableWindowOpen_ = true; + LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, stableMasterGuid_, std::dec, + " petCount=", (int)petCount, " numSlots=", (int)stableNumSlots_); + for (const auto& p : stabledPets_) { + LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry, + " level=", p.level, " name='", p.name, "' displayId=", p.displayId, + " active=", p.isActive); + } +} + void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) { if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; actionBar[slot].type = type; actionBar[slot].id = id; + // Pre-query item information so action bar displays item name instead of "Item" placeholder + if (type == ActionBarSlot::ITEM && id != 0) { + queryItemInfo(id, 0); + } saveCharacterConfig(); + // Notify the server so the action bar persists across relogs. + if (state == WorldState::IN_WORLD && socket) { + const bool classic = isClassicLikeExpansion(); + auto pkt = SetActionButtonPacket::build( + static_cast(slot), + static_cast(type), + id, + classic); + socket->send(pkt); + } } float GameHandler::getSpellCooldown(uint32_t spellId) const { @@ -12856,10 +19294,12 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { knownSpells.insert(6603u); knownSpells.insert(8690u); - // Set initial cooldowns + // Set initial cooldowns — use the longer of individual vs category cooldown. + // Spells like potions have cooldownMs=0 but categoryCooldownMs=120000. for (const auto& cd : data.cooldowns) { - if (cd.cooldownMs > 0) { - spellCooldowns[cd.spellId] = cd.cooldownMs / 1000.0f; + uint32_t effectiveMs = std::max(cd.cooldownMs, cd.categoryCooldownMs); + if (effectiveMs > 0) { + spellCooldowns[cd.spellId] = effectiveMs / 1000.0f; } } @@ -12870,7 +19310,44 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { actionBar[11].id = 8690; // Hearthstone loadCharacterConfig(); + // Sync login-time cooldowns into action bar slot overlays. Without this, spells + // that are still on cooldown when the player logs in show no cooldown timer on the + // action bar even though spellCooldowns has the right remaining time. + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { + auto it = spellCooldowns.find(slot.id); + if (it != spellCooldowns.end() && it->second > 0.0f) { + slot.cooldownTotal = it->second; + slot.cooldownRemaining = it->second; + } + } else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) { + const auto* qi = getItemInfo(slot.id); + if (qi && qi->valid) { + for (const auto& sp : qi->spells) { + if (sp.spellId == 0) continue; + auto it = spellCooldowns.find(sp.spellId); + if (it != spellCooldowns.end() && it->second > 0.0f) { + slot.cooldownTotal = it->second; + slot.cooldownRemaining = it->second; + break; + } + } + } + } + } + + // Pre-load skill line DBCs so isProfessionSpell() works immediately + // (not just after opening a trainer window) + loadSkillLineDbc(); + loadSkillLineAbilityDbc(); + LOG_INFO("Learned ", knownSpells.size(), " spells"); + + // Notify addons that the full spell list is now available + if (addonEventCallback_) { + addonEventCallback_("SPELLS_CHANGED", {}); + addonEventCallback_("LEARNED_SPELL_IN_TAB", {}); + } } void GameHandler::handleCastFailed(network::Packet& packet) { @@ -12880,8 +19357,14 @@ void GameHandler::handleCastFailed(network::Packet& packet) { if (!ok) return; casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + lastInteractedGoGuid_ = 0; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Stop precast sound — spell failed before completing if (auto* renderer = core::Application::getInstance().getRenderer()) { @@ -12890,22 +19373,34 @@ void GameHandler::handleCastFailed(network::Packet& packet) { } } - // Add system message about failed cast with readable reason + // Show failure reason in the UIError overlay and in chat int powerType = -1; auto playerEntity = entityManager.getEntity(playerGuid); if (auto playerUnit = std::dynamic_pointer_cast(playerEntity)) { powerType = playerUnit->getPowerType(); } const char* reason = getSpellCastResultString(data.result, powerType); + std::string errMsg = reason ? reason + : ("Spell cast failed (error " + std::to_string(data.result) + ")"); + addUIError(errMsg); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - if (reason) { - msg.message = reason; - } else { - msg.message = "Spell cast failed (error " + std::to_string(data.result) + ")"; - } + msg.message = errMsg; addLocalChatMessage(msg); + + // Play error sound for cast failure feedback + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playError(); + } + + // Fire UNIT_SPELLCAST_FAILED + UNIT_SPELLCAST_STOP so Lua addons can react + if (addonEventCallback_) { + addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(data.spellId)}); + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); + } + if (spellCastFailedCallback_) spellCastFailedCallback_(data.spellId); } static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) { @@ -12925,31 +19420,54 @@ void GameHandler::handleSpellStart(network::Packet& packet) { // Track cast bar for any non-player caster (target frame + boss frames) if (data.casterUnit != playerGuid && data.castTime > 0) { auto& s = unitCastStates_[data.casterUnit]; - s.casting = true; - s.spellId = data.spellId; - s.timeTotal = data.castTime / 1000.0f; - s.timeRemaining = s.timeTotal; + s.casting = true; + s.isChannel = false; + s.spellId = data.spellId; + s.timeTotal = data.castTime / 1000.0f; + s.timeRemaining = s.timeTotal; + s.interruptible = isSpellInterruptible(data.spellId); + // Trigger cast animation on the casting unit + if (spellCastAnimCallback_) { + spellCastAnimCallback_(data.casterUnit, true, false); + } } // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { + // CMSG_GAMEOBJ_USE was accepted — cancel pending USE retries so we don't + // re-send GAMEOBJ_USE mid-gather-cast and get SPELL_FAILED_BAD_TARGETS. + // Keep entries that only have sendLoot (no-cast chests that still need looting). + pendingGameObjectLootRetries_.erase( + std::remove_if(pendingGameObjectLootRetries_.begin(), pendingGameObjectLootRetries_.end(), + [](const PendingLootRetry&) { return true; /* cancel all retries once a gather cast starts */ }), + pendingGameObjectLootRetries_.end()); + casting = true; + castIsChannel = false; currentCastSpellId = data.spellId; castTimeTotal = data.castTime / 1000.0f; castTimeRemaining = castTimeTotal; // Play precast (channeling) sound with correct magic school - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* ssm = renderer->getSpellSoundManager()) { - loadSpellNameCache(); - auto it = spellNameCache_.find(data.spellId); - auto school = (it != spellNameCache_.end() && it->second.schoolMask) - ? schoolMaskToMagicSchool(it->second.schoolMask) - : audio::SpellSoundManager::MagicSchool::ARCANE; - ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); + // Skip sound for profession/tradeskill spells (crafting should be silent) + if (!isProfessionSpell(data.spellId)) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); + } } } + // 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 @@ -12958,6 +19476,17 @@ void GameHandler::handleSpellStart(network::Packet& packet) { hearthstonePreloadCallback_(homeBindMapId_, homeBindPos_.x, homeBindPos_.y, homeBindPos_.z); } } + + // Fire UNIT_SPELLCAST_START for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (data.casterUnit == playerGuid) unitId = "player"; + else if (data.casterUnit == targetGuid) unitId = "target"; + else if (data.casterUnit == focusGuid) unitId = "focus"; + else if (data.casterUnit == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)}); + } } void GameHandler::handleSpellGo(network::Packet& packet) { @@ -12967,71 +19496,149 @@ void GameHandler::handleSpellGo(network::Packet& packet) { // Cast completed if (data.casterUnit == playerGuid) { // Play cast-complete sound with correct magic school - if (auto* renderer = core::Application::getInstance().getRenderer()) { - if (auto* ssm = renderer->getSpellSoundManager()) { - loadSpellNameCache(); - auto it = spellNameCache_.find(data.spellId); - auto school = (it != spellNameCache_.end() && it->second.schoolMask) - ? schoolMaskToMagicSchool(it->second.schoolMask) - : audio::SpellSoundManager::MagicSchool::ARCANE; - ssm->playCast(school); + // Skip sound for profession/tradeskill spells (crafting should be silent) + if (!isProfessionSpell(data.spellId)) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playCast(school); + } } } // Instant melee abilities → trigger attack animation + // Detect via physical school mask (1 = Physical) from the spell DBC cache. + // This covers warrior, rogue, DK, paladin, feral druid, and hunter melee + // abilities generically instead of maintaining a brittle per-spell-ID list. uint32_t sid = data.spellId; - bool isMeleeAbility = - sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike ranks - sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 || - sid == 25286 || sid == 29707 || sid == 30324 || - sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend ranks - sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 || - sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge ranks - sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 || - sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave ranks - sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 || - sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike ranks - sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 || - sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam ranks - sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488; - if (isMeleeAbility && meleeSwingCallback_) { - meleeSwingCallback_(); + bool isMeleeAbility = false; + { + loadSpellNameCache(); + auto cacheIt = spellNameCache_.find(sid); + if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + // Physical school — treat as instant melee ability if cast time is zero. + // We don't store cast time in the cache; use the fact that if we were not + // in a cast (casting == true with this spellId) then it was instant. + isMeleeAbility = (currentCastSpellId != sid); + } + } + if (isMeleeAbility) { + if (meleeSwingCallback_) meleeSwingCallback_(); + // Play weapon swing + impact sound for instant melee abilities (Sinister Strike, etc.) + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* csm = renderer->getCombatSoundManager()) { + csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false); + csm->playImpact(audio::CombatSoundManager::WeaponSize::MEDIUM, + audio::CombatSoundManager::ImpactType::FLESH, false); + } + } } + // Capture cast state before clearing. Guard with spellId match so that + // proc/triggered spells (which fire SMSG_SPELL_GO while a gather cast is + // still active and casting == true) do NOT trigger premature CMSG_LOOT. + // Only the spell that originally started the cast bar (currentCastSpellId) + // should count as "gather cast completed". + const bool wasInTimedCast = casting && (data.spellId == currentCastSpellId); + casting = false; + castIsChannel = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + + // If we were gathering a node (mining/herbalism), send CMSG_LOOT now that + // the gather cast completed and the server has made the node lootable. + // Guard with wasInTimedCast to avoid firing on instant casts / procs. + if (wasInTimedCast && lastInteractedGoGuid_ != 0) { + lootTarget(lastInteractedGoGuid_); + lastInteractedGoGuid_ = 0; + } + + // End cast animation on player character + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, false, false); + } + + // Fire UNIT_SPELLCAST_STOP — cast bar should disappear + if (addonEventCallback_) + addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); + + // Spell queue: fire the next queued spell now that casting has ended + if (queuedSpellId_ != 0) { + uint32_t nextSpell = queuedSpellId_; + uint64_t nextTarget = queuedSpellTarget_; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; + LOG_INFO("Spell queue: firing queued spellId=", nextSpell); + castSpell(nextSpell, nextTarget); + } + } else { + if (spellCastAnimCallback_) { + // End cast animation on other unit + spellCastAnimCallback_(data.casterUnit, false, false); + } + // Play cast-complete sound for enemy spells targeting the player + bool targetsPlayer = false; + for (const auto& tgt : data.hitTargets) { + if (tgt == playerGuid) { targetsPlayer = true; break; } + } + if (targetsPlayer) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playCast(school); + } + } + } } // Clear unit cast bar when the spell lands (for any tracked unit) unitCastStates_.erase(data.casterUnit); - // Show miss/dodge/parry/etc combat text when player's spells miss targets - if (data.casterUnit == playerGuid && !data.missTargets.empty()) { - static const CombatTextEntry::Type missTypes[] = { - CombatTextEntry::MISS, // 0=MISS - CombatTextEntry::DODGE, // 1=DODGE - CombatTextEntry::PARRY, // 2=PARRY - CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE → show as MISS - CombatTextEntry::MISS, // 5=IMMUNE → show as MISS - CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::MISS, // 7=ABSORB - CombatTextEntry::MISS, // 8=RESIST - }; - // Show text for each miss (usually just 1 target per spell go) + // Preserve spellId and actual participants for spell-go miss results. + // This keeps the persistent combat log aligned with the later GUID fixes. + if (!data.missTargets.empty()) { + const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; + const bool playerIsCaster = (spellCasterGuid == playerGuid); + for (const auto& m : data.missTargets) { - CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; - addCombatText(ct, 0, 0, true); + if (!playerIsCaster && m.targetGuid != playerGuid) { + continue; + } + CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(m.missType); + addCombatText(ct, 0, data.spellId, playerIsCaster, 0, spellCasterGuid, m.targetGuid); } } - // Play impact sound when player is hit by any spell (from self or others) + // Play impact sound for spell hits involving the player + // - When player is hit by an enemy spell + // - When player's spell hits an enemy target bool playerIsHit = false; + bool playerHitEnemy = false; for (const auto& tgt : data.hitTargets) { - if (tgt == playerGuid) { playerIsHit = true; break; } + if (tgt == playerGuid) { playerIsHit = true; } + if (data.casterUnit == playerGuid && tgt != playerGuid && tgt != 0) { playerHitEnemy = true; } } - if (playerIsHit && data.casterUnit != playerGuid) { + // Fire UNIT_SPELLCAST_SUCCEEDED for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (data.casterUnit == playerGuid) unitId = "player"; + else if (data.casterUnit == targetGuid) unitId = "target"; + else if (data.casterUnit == focusGuid) unitId = "focus"; + else if (data.casterUnit == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)}); + } + + if (playerIsHit || playerHitEnemy) { if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { loadSpellNameCache(); @@ -13046,24 +19653,69 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } void GameHandler::handleSpellCooldown(network::Packet& packet) { - SpellCooldownData data; - if (!SpellCooldownParser::parse(packet, data)) return; + // Classic 1.12: guid(8) + N×[spellId(4) + itemId(4) + cooldown(4)] — no flags byte, 12 bytes/entry + // TBC 2.4.3 / WotLK 3.3.5a: guid(8) + flags(1) + N×[spellId(4) + cooldown(4)] — 8 bytes/entry + const bool isClassicFormat = isClassicLikeExpansion(); + + if (packet.getSize() - packet.getReadPos() < 8) return; + /*data.guid =*/ packet.readUInt64(); // guid (not used further) + + if (!isClassicFormat) { + if (packet.getSize() - packet.getReadPos() < 1) return; + /*data.flags =*/ packet.readUInt8(); // flags (consumed but not stored) + } + + const size_t entrySize = isClassicFormat ? 12u : 8u; + while (packet.getSize() - packet.getReadPos() >= entrySize) { + uint32_t spellId = packet.readUInt32(); + uint32_t cdItemId = 0; + if (isClassicFormat) cdItemId = packet.readUInt32(); // itemId in Classic format + uint32_t cooldownMs = packet.readUInt32(); - for (const auto& [spellId, cooldownMs] : data.cooldowns) { float seconds = cooldownMs / 1000.0f; - spellCooldowns[spellId] = seconds; - // Update action bar cooldowns + + // spellId=0 is the Global Cooldown marker (server sends it for GCD triggers) + if (spellId == 0 && cooldownMs > 0 && cooldownMs <= 2000) { + gcdTotal_ = seconds; + gcdStartedAt_ = std::chrono::steady_clock::now(); + continue; + } + + auto it = spellCooldowns.find(spellId); + if (it == spellCooldowns.end()) { + spellCooldowns[spellId] = seconds; + } else { + it->second = mergeCooldownSeconds(it->second, seconds); + } for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { - slot.cooldownTotal = seconds; - slot.cooldownRemaining = seconds; + bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) + || (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId); + if (match) { + float prevRemaining = slot.cooldownRemaining; + float merged = mergeCooldownSeconds(slot.cooldownRemaining, seconds); + slot.cooldownRemaining = merged; + if (slot.cooldownTotal <= 0.0f || prevRemaining <= 0.0f) { + slot.cooldownTotal = seconds; + } else { + slot.cooldownTotal = std::max(slot.cooldownTotal, merged); + } } } } + LOG_DEBUG("handleSpellCooldown: parsed for ", + isClassicFormat ? "Classic" : "TBC/WotLK", " format"); + if (addonEventCallback_) { + addonEventCallback_("SPELL_UPDATE_COOLDOWN", {}); + addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {}); + } } void GameHandler::handleCooldownEvent(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); + // WotLK appends the target unit guid (8 bytes) — skip it + if (packet.getSize() - packet.getReadPos() >= 8) + packet.readUInt64(); // Cooldown finished spellCooldowns.erase(spellId); for (auto& slot : actionBar) { @@ -13071,6 +19723,10 @@ void GameHandler::handleCooldownEvent(network::Packet& packet) { slot.cooldownRemaining = 0.0f; } } + if (addonEventCallback_) { + addonEventCallback_("SPELL_UPDATE_COOLDOWN", {}); + addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {}); + } } void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { @@ -13084,6 +19740,10 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } else if (data.guid == targetGuid) { auraList = &targetAuras; } + // Also maintain a per-unit cache for any unit (party members, etc.) + if (data.guid != 0 && data.guid != playerGuid && data.guid != targetGuid) { + auraList = &unitAurasCache_[data.guid]; + } if (auraList) { if (isAll) { @@ -13104,6 +19764,17 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { (*auraList)[slot] = aura; } + // Fire UNIT_AURA event for Lua addons + if (addonEventCallback_) { + std::string unitId; + if (data.guid == playerGuid) unitId = "player"; + else if (data.guid == targetGuid) unitId = "target"; + else if (data.guid == focusGuid) unitId = "focus"; + else if (data.guid == petGuid_) unitId = "pet"; + if (!unitId.empty()) + addonEventCallback_("UNIT_AURA", {unitId}); + } + // If player is mounted but we haven't identified the mount aura yet, // check newly added auras (aura update may arrive after mountDisplayId) if (data.guid == playerGuid && currentMountDisplayId_ != 0 && mountAuraSpellId_ == 0) { @@ -13118,11 +19789,22 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } void GameHandler::handleLearnedSpell(network::Packet& packet) { - uint32_t spellId = packet.readUInt32(); + // Classic 1.12: uint16 spellId; TBC 2.4.3 / WotLK 3.3.5a: uint32 spellId + const bool classicSpellId = isClassicLikeExpansion(); + const size_t minSz = classicSpellId ? 2u : 4u; + if (packet.getSize() - packet.getReadPos() < minSz) return; + uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); + + // Track whether we already knew this spell before inserting. + // SMSG_TRAINER_BUY_SUCCEEDED pre-inserts the spell and shows its own "You have + // learned X" message, so when the accompanying SMSG_LEARNED_SPELL arrives we + // must not duplicate it. + const bool alreadyKnown = knownSpells.count(spellId) > 0; knownSpells.insert(spellId); - LOG_INFO("Learned spell: ", spellId); + LOG_INFO("Learned spell: ", spellId, alreadyKnown ? " (already known, skipping chat)" : ""); // Check if this spell corresponds to a talent rank + bool isTalentSpell = false; for (const auto& [talentId, talent] : talentCache_) { for (int rank = 0; rank < 5; ++rank) { if (talent.rankSpells[rank] == spellId) { @@ -13131,55 +19813,135 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { learnedTalents_[activeTalentSpec_][talentId] = newRank; LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank, " (spell ", spellId, ") in spec ", (int)activeTalentSpec_); - return; + isTalentSpell = true; + if (addonEventCallback_) { + addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); + addonEventCallback_("PLAYER_TALENT_UPDATE", {}); + } + break; } } + if (isTalentSpell) break; } - // Show chat message for non-talent spells - const std::string& name = getSpellName(spellId); - if (!name.empty()) { - addSystemChatMessage("You have learned a new spell: " + name + "."); - } else { - addSystemChatMessage("You have learned a new spell."); + // Fire LEARNED_SPELL_IN_TAB / SPELLS_CHANGED for Lua addons + if (!alreadyKnown && addonEventCallback_) { + addonEventCallback_("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)}); + addonEventCallback_("SPELLS_CHANGED", {}); + } + + if (isTalentSpell) return; // talent spells don't show chat message + + // Show chat message for non-talent spells, but only if not already announced by + // SMSG_TRAINER_BUY_SUCCEEDED (which pre-inserts into knownSpells). + if (!alreadyKnown) { + const std::string& name = getSpellName(spellId); + if (!name.empty()) { + addSystemChatMessage("You have learned a new spell: " + name + "."); + } else { + addSystemChatMessage("You have learned a new spell."); + } } } void GameHandler::handleRemovedSpell(network::Packet& packet) { - uint32_t spellId = packet.readUInt32(); + // Classic 1.12: uint16 spellId; TBC 2.4.3 / WotLK 3.3.5a: uint32 spellId + const bool classicSpellId = isClassicLikeExpansion(); + const size_t minSz = classicSpellId ? 2u : 4u; + if (packet.getSize() - packet.getReadPos() < minSz) return; + uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO("Removed spell: ", spellId); + if (addonEventCallback_) addonEventCallback_("SPELLS_CHANGED", {}); + + const std::string& name = getSpellName(spellId); + if (!name.empty()) + addSystemChatMessage("You have unlearned: " + name + "."); + else + addSystemChatMessage("A spell has been removed."); + + // Clear any action bar slots referencing this spell + bool barChanged = false; + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot = ActionBarSlot{}; + barChanged = true; + } + } + if (barChanged) saveCharacterConfig(); } void GameHandler::handleSupercededSpell(network::Packet& packet) { // Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2) - uint32_t oldSpellId = packet.readUInt32(); - uint32_t newSpellId = packet.readUInt32(); + // Classic 1.12: uint16 oldSpellId + uint16 newSpellId (4 bytes total) + // TBC 2.4.3 / WotLK 3.3.5a: uint32 oldSpellId + uint32 newSpellId (8 bytes total) + const bool classicSpellId = isClassicLikeExpansion(); + const size_t minSz = classicSpellId ? 4u : 8u; + if (packet.getSize() - packet.getReadPos() < minSz) return; + uint32_t oldSpellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); + uint32_t newSpellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); // Remove old spell knownSpells.erase(oldSpellId); + // Track whether the new spell was already announced via SMSG_TRAINER_BUY_SUCCEEDED. + // If it was pre-inserted there, that handler already showed "You have learned X" so + // we should skip the "Upgraded to X" message to avoid a duplicate. + const bool newSpellAlreadyAnnounced = knownSpells.count(newSpellId) > 0; + // Add new spell knownSpells.insert(newSpellId); LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId); - const std::string& newName = getSpellName(newSpellId); - if (!newName.empty()) { - addSystemChatMessage("Upgraded to " + newName); + // Update all action bar slots that reference the old spell rank to the new rank. + // This matches the WoW client behaviour: the action bar automatically upgrades + // to the new rank when you train it. + bool barChanged = false; + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) { + slot.id = newSpellId; + slot.cooldownRemaining = 0.0f; + slot.cooldownTotal = 0.0f; + barChanged = true; + LOG_DEBUG("Action bar slot upgraded: spell ", oldSpellId, " -> ", newSpellId); + } + } + if (barChanged) { + saveCharacterConfig(); + if (addonEventCallback_) addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); + } + + // Show "Upgraded to X" only when the new spell wasn't already announced by the + // trainer-buy handler. For non-trainer supersedes (e.g. quest rewards), the new + // spell won't be pre-inserted so we still show the message. + if (!newSpellAlreadyAnnounced) { + const std::string& newName = getSpellName(newSpellId); + if (!newName.empty()) { + addSystemChatMessage("Upgraded to " + newName); + } } } void GameHandler::handleUnlearnSpells(network::Packet& packet) { // Sent when unlearning multiple spells (e.g., spec change, respec) + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellCount = packet.readUInt32(); LOG_INFO("Unlearning ", spellCount, " spells"); + bool barChanged = false; for (uint32_t i = 0; i < spellCount && packet.getSize() - packet.getReadPos() >= 4; ++i) { uint32_t spellId = packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO(" Unlearned spell: ", spellId); + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { + slot = ActionBarSlot{}; + barChanged = true; + } + } } + if (barChanged) saveCharacterConfig(); if (spellCount > 0) { addSystemChatMessage("Unlearned " + std::to_string(spellCount) + " spells"); @@ -13191,45 +19953,74 @@ void GameHandler::handleUnlearnSpells(network::Packet& packet) { // ============================================================ void GameHandler::handleTalentsInfo(network::Packet& packet) { - TalentsInfoData data; - if (!TalentsInfoParser::parse(packet, data)) return; + // SMSG_TALENTS_INFO (WotLK 3.3.5a) correct wire format: + // uint8 talentType (0 = own talents, 1 = inspect result — own talent packets always 0) + // uint32 unspentTalents + // uint8 talentGroupCount + // uint8 activeTalentGroup + // Per group: uint8 talentCount, [uint32 talentId + uint8 rank] × count, + // uint8 glyphCount, [uint16 glyphId] × count + + if (packet.getSize() - packet.getReadPos() < 1) return; + uint8_t talentType = packet.readUInt8(); + if (talentType != 0) { + // type 1 = inspect result; handled by handleInspectResults — ignore here + return; + } + if (packet.getSize() - packet.getReadPos() < 6) { + LOG_WARNING("handleTalentsInfo: packet too short for header"); + return; + } + + uint32_t unspentTalents = packet.readUInt32(); + uint8_t talentGroupCount = packet.readUInt8(); + uint8_t activeTalentGroup = packet.readUInt8(); + if (activeTalentGroup > 1) activeTalentGroup = 0; // Ensure talent DBCs are loaded loadTalentDbc(); - // Validate spec number - if (data.talentSpec > 1) { - LOG_WARNING("Invalid talent spec: ", (int)data.talentSpec); - return; + activeTalentSpec_ = activeTalentGroup; + + for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t talentCount = packet.readUInt8(); + learnedTalents_[g].clear(); + for (uint8_t t = 0; t < talentCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t talentId = packet.readUInt32(); + uint8_t rank = packet.readUInt8(); + learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed + } + learnedGlyphs_[g].fill(0); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t glyphCount = packet.readUInt8(); + for (uint8_t gl = 0; gl < glyphCount; ++gl) { + if (packet.getSize() - packet.getReadPos() < 2) break; + uint16_t glyphId = packet.readUInt16(); + if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; + } } - // Store talents for this spec - unspentTalentPoints_[data.talentSpec] = data.unspentPoints; + unspentTalentPoints_[activeTalentGroup] = + static_cast(unspentTalents > 255 ? 255 : unspentTalents); - // Clear and rebuild learned talents map for this spec - // Note: If a talent appears in the packet, it's learned (ranks are 0-indexed) - learnedTalents_[data.talentSpec].clear(); - for (const auto& talent : data.talents) { - learnedTalents_[data.talentSpec][talent.talentId] = talent.currentRank; + LOG_INFO("handleTalentsInfo: unspent=", unspentTalents, + " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, + " learned=", learnedTalents_[activeTalentGroup].size()); + + // Fire talent-related events for addons + if (addonEventCallback_) { + addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); + addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {}); + addonEventCallback_("PLAYER_TALENT_UPDATE", {}); } - LOG_INFO("Talents loaded: spec=", (int)data.talentSpec, - " 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; - activeTalentSpec_ = data.talentSpec; - - // Show message to player about active spec - if (unspentTalentPoints_[data.talentSpec] > 0) { - std::string msg = "You have " + std::to_string(unspentTalentPoints_[data.talentSpec]) + - " unspent talent point"; - if (unspentTalentPoints_[data.talentSpec] > 1) msg += "s"; - msg += " in spec " + std::to_string(data.talentSpec + 1); - addSystemChatMessage(msg); + if (!talentsInitialized_) { + talentsInitialized_ = true; + if (unspentTalents > 0) { + addSystemChatMessage("You have " + std::to_string(unspentTalents) + + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); } } } @@ -13282,6 +20073,45 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { addSystemChatMessage(msg); } +void GameHandler::confirmPetUnlearn() { + if (!petUnlearnPending_) return; + petUnlearnPending_ = false; + if (state != WorldState::IN_WORLD || !socket) return; + + // Respond with CMSG_PET_UNLEARN_TALENTS (no payload in 3.3.5a) + network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS)); + socket->send(pkt); + LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS"); + addSystemChatMessage("Pet talent reset confirmed."); + petUnlearnGuid_ = 0; + petUnlearnCost_ = 0; +} + +void GameHandler::confirmTalentWipe() { + if (!talentWipePending_) return; + talentWipePending_ = false; + + if (state != WorldState::IN_WORLD || !socket) return; + + // Respond to MSG_TALENT_WIPE_CONFIRM with the trainer GUID to trigger the reset. + // Packet: opcode(2) + uint64 npcGuid = 10 bytes. + network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); + pkt.writeUInt64(talentWipeNpcGuid_); + socket->send(pkt); + + LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec); + addSystemChatMessage("Talent reset confirmed. The server will update your talents."); + talentWipeNpcGuid_ = 0; + talentWipeCost_ = 0; +} + +void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { + if (state != WorldState::IN_WORLD || !socket) return; + auto pkt = AlterAppearancePacket::build(hairStyle, hairColor, facialHair); + socket->send(pkt); + LOG_INFO("sendAlterAppearance: hair=", hairStyle, " color=", hairColor, " facial=", facialHair); +} + // ============================================================ // Phase 4: Group/Party // ============================================================ @@ -13315,6 +20145,10 @@ void GameHandler::leaveGroup() { socket->send(packet); partyData = GroupListData{}; LOG_INFO("Left group"); + if (addonEventCallback_) { + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + } } void GameHandler::handleGroupInvite(network::Packet& packet) { @@ -13327,6 +20161,12 @@ void GameHandler::handleGroupInvite(network::Packet& packet) { if (!data.inviterName.empty()) { addSystemChatMessage(data.inviterName + " has invited you to a group."); } + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playTargetSelect(); + } + if (addonEventCallback_) + addonEventCallback_("PARTY_INVITE_REQUEST", {data.inviterName}); } void GameHandler::handleGroupDecline(network::Packet& packet) { @@ -13344,17 +20184,37 @@ 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"); + // Snapshot state before reset so we can detect transitions. + const uint32_t prevCount = partyData.memberCount; + const uint8_t prevLootMethod = partyData.lootMethod; + const bool wasInGroup = !partyData.isEmpty(); // 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()) { + const bool nowInGroup = !partyData.isEmpty(); + if (!nowInGroup && wasInGroup) { LOG_INFO("No longer in a group"); addSystemChatMessage("You are no longer in a group."); - } else { - LOG_INFO("In group with ", partyData.memberCount, " members"); - addSystemChatMessage("You are now in a group with " + std::to_string(partyData.memberCount) + " members."); + } else if (nowInGroup && !wasInGroup) { + LOG_INFO("Joined group with ", partyData.memberCount, " members"); + addSystemChatMessage("You are now in a group."); + } else if (nowInGroup && partyData.memberCount != prevCount) { + LOG_INFO("Group updated: ", partyData.memberCount, " members"); + } + // Loot method change notification + if (wasInGroup && nowInGroup && partyData.lootMethod != prevLootMethod) { + static const char* kLootMethods[] = { + "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" + }; + const char* methodName = (partyData.lootMethod < 5) ? kLootMethods[partyData.lootMethod] : "Unknown"; + addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); + } + // Fire GROUP_ROSTER_UPDATE / PARTY_MEMBERS_CHANGED for Lua addons + if (addonEventCallback_) { + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); } } @@ -13363,10 +20223,16 @@ void GameHandler::handleGroupUninvite(network::Packet& packet) { partyData = GroupListData{}; LOG_INFO("Removed from group"); + if (addonEventCallback_) { + addonEventCallback_("GROUP_ROSTER_UPDATE", {}); + addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + } + MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = "You have been removed from the group."; + addUIError("You have been removed from the group."); addLocalChatMessage(msg); } @@ -13375,11 +20241,37 @@ void GameHandler::handlePartyCommandResult(network::Packet& packet) { if (!PartyCommandResultParser::parse(packet, data)) return; if (data.result != PartyResult::OK) { + const char* errText = nullptr; + switch (data.result) { + case PartyResult::BAD_PLAYER_NAME: errText = "No player named \"%s\" is currently online."; break; + case PartyResult::TARGET_NOT_IN_GROUP: errText = "%s is not in your group."; break; + case PartyResult::TARGET_NOT_IN_INSTANCE:errText = "%s is not in your instance."; break; + case PartyResult::GROUP_FULL: errText = "Your party is full."; break; + case PartyResult::ALREADY_IN_GROUP: errText = "%s is already in a group."; break; + case PartyResult::NOT_IN_GROUP: errText = "You are not in a group."; break; + case PartyResult::NOT_LEADER: errText = "You are not the group leader."; break; + case PartyResult::PLAYER_WRONG_FACTION: errText = "%s is the wrong faction for this group."; break; + case PartyResult::IGNORING_YOU: errText = "%s is ignoring you."; break; + case PartyResult::LFG_PENDING: errText = "You cannot do that while in a LFG queue."; break; + case PartyResult::INVITE_RESTRICTED: errText = "Target is not accepting group invites."; break; + default: errText = "Party command failed."; break; + } + + char buf[256]; + if (!data.name.empty() && errText && std::strstr(errText, "%s")) { + std::snprintf(buf, sizeof(buf), errText, data.name.c_str()); + } else if (errText) { + std::snprintf(buf, sizeof(buf), "%s", errText); + } else { + std::snprintf(buf, sizeof(buf), "Party command failed (error %u).", + static_cast(data.result)); + } + + addUIError(buf); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; - msg.message = "Party command failed (error " + std::to_string(static_cast(data.result)) + ")"; - if (!data.name.empty()) msg.message += " for " + data.name; + msg.message = buf; addLocalChatMessage(msg); } } @@ -13471,20 +20363,34 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { if (updateFlags & 0x0200) { // AURAS if (remaining() >= 8) { uint64_t auraMask = packet.readUInt64(); + // Collect aura updates for this member and store in unitAurasCache_ + // so party frame debuff dots can use them. + std::vector newAuras; for (int i = 0; i < 64; ++i) { if (auraMask & (uint64_t(1) << i)) { + AuraSlot a; + a.level = static_cast(i); // use slot index if (isWotLK) { // WotLK: uint32 spellId + uint8 auraFlags if (remaining() < 5) break; - packet.readUInt32(); - packet.readUInt8(); + a.spellId = packet.readUInt32(); + a.flags = packet.readUInt8(); } else { - // Classic/TBC: uint16 spellId only + // Classic/TBC: uint16 spellId only; negative auras not indicated here if (remaining() < 2) break; - packet.readUInt16(); + a.spellId = packet.readUInt16(); + // Infer negative/positive from dispel type: non-zero dispel → debuff + uint8_t dt = getSpellDispelType(a.spellId); + if (dt > 0) a.flags = 0x80; // mark as debuff } + if (a.spellId != 0) newAuras.push_back(a); } } + // Populate unitAurasCache_ for this member (merge: keep existing per-GUID data + // only if we already have a richer source; otherwise replace with stats data) + if (memberGuid != 0 && memberGuid != playerGuid && memberGuid != targetGuid) { + unitAurasCache_[memberGuid] = std::move(newAuras); + } } } if (updateFlags & 0x0400) { // PET_GUID @@ -13555,6 +20461,40 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { LOG_DEBUG("Party member stats for ", member->name, ": HP=", member->curHealth, "/", member->maxHealth, " Level=", member->level); + + // Fire addon events for party/raid member health/power/aura changes + if (addonEventCallback_) { + // Resolve unit ID for this member (party1..4 or raid1..40) + std::string unitId; + if (partyData.groupType == 1) { + // Raid: find 1-based index + for (size_t i = 0; i < partyData.members.size(); ++i) { + if (partyData.members[i].guid == memberGuid) { + unitId = "raid" + std::to_string(i + 1); + break; + } + } + } else { + // Party: find 1-based index excluding self + int found = 0; + for (const auto& m : partyData.members) { + if (m.guid == playerGuid) continue; + ++found; + if (m.guid == memberGuid) { + unitId = "party" + std::to_string(found); + break; + } + } + } + if (!unitId.empty()) { + if (updateFlags & (0x0002 | 0x0004)) // CUR_HP or MAX_HP + addonEventCallback_("UNIT_HEALTH", {unitId}); + if (updateFlags & (0x0010 | 0x0020)) // CUR_POWER or MAX_POWER + addonEventCallback_("UNIT_POWER", {unitId}); + if (updateFlags & 0x0200) // AURAS + addonEventCallback_("UNIT_AURA", {unitId}); + } + } } // ============================================================ @@ -13612,6 +20552,44 @@ void GameHandler::declineGuildInvite() { LOG_INFO("Declined guild invite"); } +void GameHandler::submitGmTicket(const std::string& text) { + if (state != WorldState::IN_WORLD || !socket) return; + + // CMSG_GMTICKET_CREATE (WotLK 3.3.5a): + // string ticket_text + // float[3] position (server coords) + // float facing + // uint32 mapId + // uint8 need_response (1 = yes) + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE)); + pkt.writeString(text); + pkt.writeFloat(movementInfo.x); + pkt.writeFloat(movementInfo.y); + pkt.writeFloat(movementInfo.z); + pkt.writeFloat(movementInfo.orientation); + pkt.writeUInt32(currentMapId_); + pkt.writeUInt8(1); // need_response = yes + socket->send(pkt); + LOG_INFO("Submitted GM ticket: '", text, "'"); +} + +void GameHandler::deleteGmTicket() { + if (state != WorldState::IN_WORLD || !socket) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); + socket->send(pkt); + gmTicketActive_ = false; + gmTicketText_.clear(); + LOG_INFO("Deleting GM ticket"); +} + +void GameHandler::requestGmTicket() { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_GMTICKET_GETTICKET has no payload — server responds with SMSG_GMTICKET_GETTICKET + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_GETTICKET)); + socket->send(pkt); + LOG_DEBUG("Sent CMSG_GMTICKET_GETTICKET — querying open ticket status"); +} + void GameHandler::queryGuildInfo(uint32_t guildId) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildQueryPacket::build(guildId); @@ -13619,6 +20597,28 @@ void GameHandler::queryGuildInfo(uint32_t guildId) { LOG_INFO("Querying guild info: guildId=", guildId); } +static const std::string kEmptyString; + +const std::string& GameHandler::lookupGuildName(uint32_t guildId) { + if (guildId == 0) return kEmptyString; + auto it = guildNameCache_.find(guildId); + if (it != guildNameCache_.end()) return it->second; + // Query the server if we haven't already + if (pendingGuildNameQueries_.insert(guildId).second) { + queryGuildInfo(guildId); + } + return kEmptyString; +} + +uint32_t GameHandler::getEntityGuildId(uint64_t guid) const { + auto entity = entityManager.getEntity(guid); + if (!entity || entity->getType() != ObjectType::PLAYER) return 0; + // PLAYER_GUILDID = UNIT_END + 3 across all expansions + const uint16_t ufUnitEnd = fieldIndex(UF::UNIT_END); + if (ufUnitEnd == 0xFFFF) return 0; + return entity->getField(ufUnitEnd + 3); +} + void GameHandler::createGuild(const std::string& guildName) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildCreatePacket::build(guildName); @@ -13667,6 +20667,118 @@ void GameHandler::handlePetitionShowlist(network::Packet& packet) { LOG_INFO("Petition showlist: cost=", data.cost); } +void GameHandler::handlePetitionQueryResponse(network::Packet& packet) { + // SMSG_PETITION_QUERY_RESPONSE (3.3.5a): + // uint32 petitionEntry, uint64 petitionGuid, string guildName, + // string bodyText (empty), uint32 flags, uint32 minSignatures, + // uint32 maxSignatures, ...plus more fields we can skip + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 12) return; + + /*uint32_t entry =*/ packet.readUInt32(); + uint64_t petGuid = packet.readUInt64(); + std::string guildName = packet.readString(); + /*std::string body =*/ packet.readString(); + + // Update petition info if it matches our current petition + if (petitionInfo_.petitionGuid == petGuid) { + petitionInfo_.guildName = guildName; + } + + LOG_INFO("SMSG_PETITION_QUERY_RESPONSE: guid=", petGuid, " name=", guildName); + packet.setReadPos(packet.getSize()); // skip remaining fields +} + +void GameHandler::handlePetitionShowSignatures(network::Packet& packet) { + // SMSG_PETITION_SHOW_SIGNATURES (3.3.5a): + // uint64 itemGuid (petition item in inventory) + // uint64 ownerGuid + // uint32 petitionGuid (low part / entry) + // uint8 signatureCount + // For each signature: + // uint64 playerGuid + // uint32 unk (always 0) + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 21) return; + + petitionInfo_ = PetitionInfo{}; + petitionInfo_.petitionGuid = packet.readUInt64(); + petitionInfo_.ownerGuid = packet.readUInt64(); + /*uint32_t petEntry =*/ packet.readUInt32(); + uint8_t sigCount = packet.readUInt8(); + + petitionInfo_.signatureCount = sigCount; + petitionInfo_.signatures.reserve(sigCount); + + for (uint8_t i = 0; i < sigCount; ++i) { + if (rem() < 12) break; + PetitionSignature sig; + sig.playerGuid = packet.readUInt64(); + /*uint32_t unk =*/ packet.readUInt32(); + petitionInfo_.signatures.push_back(sig); + } + + petitionInfo_.showUI = true; + LOG_INFO("SMSG_PETITION_SHOW_SIGNATURES: petGuid=", petitionInfo_.petitionGuid, + " owner=", petitionInfo_.ownerGuid, + " sigs=", sigCount); +} + +void GameHandler::handlePetitionSignResults(network::Packet& packet) { + // SMSG_PETITION_SIGN_RESULTS (3.3.5a): + // uint64 petitionGuid, uint64 playerGuid, uint32 result + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 20) return; + + uint64_t petGuid = packet.readUInt64(); + uint64_t playerGuid = packet.readUInt64(); + uint32_t result = packet.readUInt32(); + + switch (result) { + case 0: // PETITION_SIGN_OK + addSystemChatMessage("Petition signed successfully."); + // Increment local count + if (petitionInfo_.petitionGuid == petGuid) { + petitionInfo_.signatureCount++; + PetitionSignature sig; + sig.playerGuid = playerGuid; + petitionInfo_.signatures.push_back(sig); + } + break; + case 1: // PETITION_SIGN_ALREADY_SIGNED + addSystemChatMessage("You have already signed that petition."); + break; + case 2: // PETITION_SIGN_ALREADY_IN_GUILD + addSystemChatMessage("You are already in a guild."); + break; + case 3: // PETITION_SIGN_CANT_SIGN_OWN + addSystemChatMessage("You cannot sign your own petition."); + break; + default: + addSystemChatMessage("Cannot sign petition (error " + std::to_string(result) + ")."); + break; + } + LOG_INFO("SMSG_PETITION_SIGN_RESULTS: pet=", petGuid, " player=", playerGuid, + " result=", result); +} + +void GameHandler::signPetition(uint64_t petitionGuid) { + if (!socket || state != WorldState::IN_WORLD) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_PETITION_SIGN)); + pkt.writeUInt64(petitionGuid); + pkt.writeUInt8(0); // unk + socket->send(pkt); + LOG_INFO("Signing petition: ", petitionGuid); +} + +void GameHandler::turnInPetition(uint64_t petitionGuid) { + if (!socket || state != WorldState::IN_WORLD) return; + network::Packet pkt(wireOpcode(Opcode::CMSG_TURN_IN_PETITION)); + pkt.writeUInt64(petitionGuid); + socket->send(pkt); + LOG_INFO("Turning in petition: ", petitionGuid); +} + void GameHandler::handleTurnInPetitionResults(network::Packet& packet) { uint32_t result = 0; if (!TurnInPetitionResultsParser::parse(packet, result)) return; @@ -13697,20 +20809,39 @@ void GameHandler::handleGuildRoster(network::Packet& packet) { guildRoster_ = std::move(data); hasGuildRoster_ = true; LOG_INFO("Guild roster received: ", guildRoster_.members.size(), " members"); + if (addonEventCallback_) addonEventCallback_("GUILD_ROSTER_UPDATE", {}); } void GameHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; if (!packetParsers_->parseGuildQueryResponse(packet, data)) return; - guildName_ = data.guildName; - guildQueryData_ = data; - guildRankNames_.clear(); - for (uint32_t i = 0; i < 10; ++i) { - guildRankNames_.push_back(data.rankNames[i]); + // Always cache the guild name for nameplate lookups + if (data.guildId != 0 && !data.guildName.empty()) { + guildNameCache_[data.guildId] = data.guildName; + pendingGuildNameQueries_.erase(data.guildId); + } + + // Check if this is the local player's guild + const Character* ch = getActiveCharacter(); + bool isLocalGuild = (ch && ch->hasGuild() && ch->guildId == data.guildId); + + if (isLocalGuild) { + const bool wasUnknown = guildName_.empty(); + guildName_ = data.guildName; + guildQueryData_ = data; + guildRankNames_.clear(); + for (uint32_t i = 0; i < 10; ++i) { + guildRankNames_.push_back(data.rankNames[i]); + } + LOG_INFO("Guild name set to: ", guildName_); + if (wasUnknown && !guildName_.empty()) { + addSystemChatMessage("Guild: <" + guildName_ + ">"); + if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {}); + } + } else { + LOG_INFO("Cached guild name: id=", data.guildId, " name=", data.guildName); } - LOG_INFO("Guild name set to: ", guildName_); - addSystemChatMessage("Guild: <" + guildName_ + ">"); } void GameHandler::handleGuildEvent(network::Packet& packet) { @@ -13757,6 +20888,7 @@ void GameHandler::handleGuildEvent(network::Packet& packet) { guildRankNames_.clear(); guildRoster_ = GuildRosterData{}; hasGuildRoster_ = false; + if (addonEventCallback_) addonEventCallback_("PLAYER_GUILD_UPDATE", {}); break; case GuildEvent::SIGNED_ON: if (data.numStrings >= 1) @@ -13779,6 +20911,28 @@ void GameHandler::handleGuildEvent(network::Packet& packet) { addLocalChatMessage(chatMsg); } + // Fire addon events for guild state changes + if (addonEventCallback_) { + switch (data.eventType) { + case GuildEvent::MOTD: + addonEventCallback_("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""}); + break; + case GuildEvent::SIGNED_ON: + case GuildEvent::SIGNED_OFF: + case GuildEvent::PROMOTION: + case GuildEvent::DEMOTION: + case GuildEvent::JOINED: + case GuildEvent::LEFT: + case GuildEvent::REMOVED: + case GuildEvent::LEADER_CHANGED: + case GuildEvent::DISBANDED: + addonEventCallback_("GUILD_ROSTER_UPDATE", {}); + break; + default: + break; + } + } + // Auto-refresh roster after membership/rank changes switch (data.eventType) { case GuildEvent::PROMOTION: @@ -13803,18 +20957,81 @@ void GameHandler::handleGuildInvite(network::Packet& packet) { pendingGuildInviteGuildName_ = data.guildName; LOG_INFO("Guild invite from: ", data.inviterName, " to guild: ", data.guildName); addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + "."); + if (addonEventCallback_) + addonEventCallback_("GUILD_INVITE_REQUEST", {data.inviterName, data.guildName}); } void GameHandler::handleGuildCommandResult(network::Packet& packet) { GuildCommandResultData data; if (!GuildCommandResultParser::parse(packet, data)) return; - if (data.errorCode != 0) { - std::string msg = "Guild command failed"; + // command: 0=CREATE, 1=INVITE, 2=QUIT, 3=FOUNDER + if (data.errorCode == 0) { + switch (data.command) { + case 0: // CREATE + addSystemChatMessage("Guild created."); + break; + case 1: // INVITE — invited another player + if (!data.name.empty()) + addSystemChatMessage("You have invited " + data.name + " to the guild."); + break; + case 2: // QUIT — player successfully left + addSystemChatMessage("You have left the guild."); + guildName_.clear(); + guildRankNames_.clear(); + guildRoster_ = GuildRosterData{}; + hasGuildRoster_ = false; + break; + default: + break; + } + return; + } + + // Error codes from AzerothCore SharedDefines.h GuildCommandError + const char* errStr = nullptr; + switch (data.errorCode) { + case 2: errStr = "You are not in a guild."; break; + case 3: errStr = "That player is not in a guild."; break; + case 4: errStr = "No player named \"%s\" is online."; break; + case 7: errStr = "You are the guild leader."; break; + case 8: errStr = "You must transfer leadership before leaving."; break; + case 11: errStr = "\"%s\" is already in a guild."; break; + case 13: errStr = "You are already in a guild."; break; + case 14: errStr = "\"%s\" has already been invited to a guild."; break; + case 15: errStr = "You cannot invite yourself."; break; + case 16: + case 17: errStr = "You are not the guild leader."; break; + case 18: errStr = "That player's rank is too high to remove."; break; + case 19: errStr = "You cannot remove someone with a higher rank."; break; + case 20: errStr = "Guild ranks are locked."; break; + case 21: errStr = "That rank is in use."; break; + case 22: errStr = "That player is ignoring you."; break; + case 25: errStr = "Insufficient guild bank withdrawal quota."; break; + case 26: errStr = "Guild doesn't have enough money."; break; + case 28: errStr = "Guild bank is full."; break; + case 31: errStr = "Too many guild ranks."; break; + case 37: errStr = "That player is the guild leader."; break; + case 49: errStr = "Guild reputation is too low."; break; + default: break; + } + + std::string msg; + if (errStr) { + // Substitute %s with player name where applicable + std::string fmt = errStr; + auto pos = fmt.find("%s"); + if (pos != std::string::npos && !data.name.empty()) + fmt.replace(pos, 2, data.name); + else if (pos != std::string::npos) + fmt.replace(pos, 2, "that player"); + msg = fmt; + } else { + msg = "Guild command failed"; if (!data.name.empty()) msg += " for " + data.name; msg += " (error " + std::to_string(data.errorCode) + ")"; - addSystemChatMessage(msg); } + addSystemChatMessage(msg); } // ============================================================ @@ -13836,6 +21053,8 @@ void GameHandler::lootItem(uint8_t slotIndex) { void GameHandler::closeLoot() { if (!lootWindowOpen) return; lootWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {}); + masterLootCandidates_.clear(); if (currentLoot.lootGuid != 0 && targetGuid == currentLoot.lootGuid) { clearTarget(); } @@ -13846,6 +21065,16 @@ void GameHandler::closeLoot() { currentLoot = LootResponseData{}; } +void GameHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_LOOT_MASTER_GIVE: uint64 lootGuid + uint8 slotIndex + uint64 targetGuid + network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_MASTER_GIVE)); + pkt.writeUInt64(currentLoot.lootGuid); + pkt.writeUInt8(lootSlot); + pkt.writeUInt64(targetGuid); + socket->send(pkt); +} + void GameHandler::interactWithNpc(uint64_t guid) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GossipHelloPacket::build(guid); @@ -13867,14 +21096,12 @@ void GameHandler::interactWithGameObject(uint64_t guid) { void GameHandler::performGameObjectInteractionNow(uint64_t guid) { if (guid == 0) return; if (state != WorldState::IN_WORLD || !socket) return; - bool turtleMode = isActiveExpansion("turtle"); - // Rate-limit to prevent spamming the server static uint64_t lastInteractGuid = 0; static std::chrono::steady_clock::time_point lastInteractTime{}; auto now = std::chrono::steady_clock::now(); // Keep duplicate suppression, but allow quick retry clicks. - int64_t minRepeatMs = turtleMode ? 150 : 150; + constexpr int64_t minRepeatMs = 150; if (guid == lastInteractGuid && std::chrono::duration_cast(now - lastInteractTime).count() < minRepeatMs) { return; @@ -13910,10 +21137,21 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { float dy = entity->getY() - movementInfo.y; float dz = entity->getZ() - movementInfo.z; float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); - if (dist3d > 6.0f) { + if (dist3d > 10.0f) { addSystemChatMessage("Too far away."); return; } + // Stop movement before interacting — servers may reject GO use or + // immediately cancel the resulting spell cast if the player is moving. + const uint32_t moveFlags = movementInfo.flags; + const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD + (moveFlags & 0x00000002u) || // BACKWARD + (moveFlags & 0x00000004u) || // STRAFE_LEFT + (moveFlags & 0x00000008u); // STRAFE_RIGHT + if (isMoving) { + movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags + sendMovement(Opcode::MSG_MOVE_STOP); + } if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { movementInfo.orientation = std::atan2(-dy, dx); sendMovement(Opcode::MSG_MOVE_SET_FACING); @@ -13921,17 +21159,27 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } + LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec, + " entry=", goEntry, " type=", goType, + " name='", goName, "' dist=", entity ? std::sqrt( + (entity->getX() - movementInfo.x) * (entity->getX() - movementInfo.x) + + (entity->getY() - movementInfo.y) * (entity->getY() - movementInfo.y) + + (entity->getZ() - movementInfo.z) * (entity->getZ() - movementInfo.z)) : -1.0f); auto packet = GameObjectUsePacket::build(guid); socket->send(packet); + lastInteractedGoGuid_ = guid; // For mailbox GameObjects (type 19), open mail UI and request mail list. // In Vanilla/Classic there is no SMSG_SHOW_MAILBOX — the server just sends // animation/sound and expects the client to request the mail list. bool isMailbox = false; bool chestLike = false; - // Stock-like behavior: GO use opens GO loot context. Keep eager CMSG_LOOT only - // as Classic/Turtle fallback behavior. - bool shouldSendLoot = isActiveExpansion("classic") || isActiveExpansion("turtle"); + // Always send CMSG_LOOT after CMSG_GAMEOBJ_USE for any gameobject that could be + // lootable. The server silently ignores CMSG_LOOT for non-lootable objects + // (doors, buttons, etc.), so this is safe. Not sending it is the main reason + // chests fail to open when their GO type is not yet cached or their name doesn't + // contain the word "chest" (e.g. lockboxes, coffers, strongboxes, caches). + bool shouldSendLoot = true; if (entity && entity->getType() == ObjectType::GAMEOBJECT) { auto go = std::static_pointer_cast(entity); auto* info = getCachedGameObjectInfo(go->getEntry()); @@ -13947,35 +21195,50 @@ void GameHandler::performGameObjectInteractionNow(uint64_t guid) { refreshMailList(); } else if (info && info->type == 3) { chestLike = true; - } else if (turtleMode) { - // Turtle compatibility: keep eager loot open behavior. - shouldSendLoot = true; } } if (!chestLike && !goName.empty()) { std::string lower = goName; std::transform(lower.begin(), lower.end(), lower.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - chestLike = (lower.find("chest") != std::string::npos); + chestLike = (lower.find("chest") != std::string::npos || + lower.find("lockbox") != std::string::npos || + lower.find("strongbox") != std::string::npos || + lower.find("coffer") != std::string::npos || + lower.find("cache") != std::string::npos); } - // For WotLK chest-like gameobjects, report use but let server open loot. - if (!isMailbox && chestLike) { - if (isActiveExpansion("wotlk")) { + // Some servers require CMSG_GAMEOBJ_REPORT_USE for lootable gameobjects. + // Only send it when the active opcode table actually supports it. + if (!isMailbox) { + const auto* table = getActiveOpcodeTable(); + if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); reportUse.writeUInt64(guid); socket->send(reportUse); } } if (shouldSendLoot) { - lootTarget(guid); - } - // Retry use briefly to survive packet loss/order races. Keep loot retries only - // when we intentionally use eager loot-open mode. - const bool retryLoot = shouldSendLoot && (turtleMode || isActiveExpansion("classic")); - const bool retryUse = turtleMode || isActiveExpansion("classic"); - if (retryUse || retryLoot) { - pendingGameObjectLootRetries_.push_back(PendingLootRetry{guid, 0.15f, 2, retryLoot}); + // Don't send CMSG_LOOT immediately — give the server time to process + // CMSG_GAMEOBJ_USE first (chests need to transition to lootable state, + // gathering nodes start a spell cast). A premature CMSG_LOOT can cause + // an empty SMSG_LOOT_RESPONSE that clears our gather-cast loot state. + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == guid; }), + pendingGameObjectLootOpens_.end()); + // Short delay for chests (server makes them lootable quickly after USE), + // plus a longer retry to catch slow state transitions. + pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.20f}); + pendingGameObjectLootOpens_.push_back(PendingLootOpen{guid, 0.75f}); + } else { + // Non-lootable interaction (mailbox, door, button, etc.) — no CMSG_LOOT will be + // sent, and no SMSG_LOOT_RESPONSE will arrive to clear it. Clear the gather-loot + // guid now so a subsequent timed cast completion can't fire a spurious CMSG_LOOT. + lastInteractedGoGuid_ = 0; } + // Don't retry CMSG_GAMEOBJ_USE — resending can toggle chest state on some + // servers (opening→closing the chest). The delayed CMSG_LOOT retries above + // handle the case where the first loot attempt arrives too early. } void GameHandler::selectGossipOption(uint32_t optionId) { @@ -14018,12 +21281,39 @@ void GameHandler::selectGossipOption(uint32_t optionId) { LOG_INFO("Sent CMSG_BANKER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec); } + // Vendor / repair: some servers require an explicit CMSG_LIST_INVENTORY after gossip select. + const bool isVendor = (text == "GOSSIP_OPTION_VENDOR" || + (textLower.find("browse") != std::string::npos && + (textLower.find("goods") != std::string::npos || textLower.find("wares") != std::string::npos))); + const bool isArmorer = (text == "GOSSIP_OPTION_ARMORER" || textLower.find("repair") != std::string::npos); + if (isVendor || isArmorer) { + if (isArmorer) { + setVendorCanRepair(true); + } + auto pkt = ListInventoryPacket::build(currentGossip.npcGuid); + socket->send(pkt); + LOG_INFO("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip.npcGuid, std::dec, + " vendor=", (int)isVendor, " repair=", (int)isArmorer); + } + if (textLower.find("make this inn your home") != std::string::npos || textLower.find("set your home") != std::string::npos) { auto bindPkt = BinderActivatePacket::build(currentGossip.npcGuid); socket->send(bindPkt); LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip.npcGuid, std::dec); } + + // Stable master detection: GOSSIP_OPTION_STABLE or text keywords + if (text == "GOSSIP_OPTION_STABLE" || + textLower.find("stable") != std::string::npos || + textLower.find("my pet") != std::string::npos) { + stableMasterGuid_ = currentGossip.npcGuid; + stableWindowOpen_ = false; // will open when list arrives + auto listPkt = ListStabledPetsPacket::build(currentGossip.npcGuid); + socket->send(listPkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to npc=0x", + std::hex, currentGossip.npcGuid, std::dec); + } break; } } @@ -14097,9 +21387,97 @@ bool GameHandler::requestQuestQuery(uint32_t questId, bool force) { pkt.writeUInt32(questId); socket->send(pkt); pendingQuestQueryIds_.insert(questId); + + // WotLK supports CMSG_QUEST_POI_QUERY to get objective map locations. + // Only send if the opcode is mapped (stride==5 means WotLK). + if (packetParsers_ && packetParsers_->questLogStride() == 5) { + const uint32_t wirePoiQuery = wireOpcode(Opcode::CMSG_QUEST_POI_QUERY); + if (wirePoiQuery != 0xFFFF) { + network::Packet poiPkt(static_cast(wirePoiQuery)); + poiPkt.writeUInt32(1); // count = 1 + poiPkt.writeUInt32(questId); + socket->send(poiPkt); + } + } return true; } +void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { + // WotLK 3.3.5a SMSG_QUEST_POI_QUERY_RESPONSE format: + // uint32 questCount + // per quest: + // uint32 questId + // uint32 poiCount + // per poi: + // uint32 poiId + // int32 objIndex (-1 = no specific objective) + // uint32 mapId + // uint32 areaId + // uint32 floorId + // uint32 unk1 + // uint32 unk2 + // uint32 pointCount + // per point: int32 x, int32 y + if (packet.getSize() - packet.getReadPos() < 4) return; + const uint32_t questCount = packet.readUInt32(); + for (uint32_t qi = 0; qi < questCount; ++qi) { + if (packet.getSize() - packet.getReadPos() < 8) return; + const uint32_t questId = packet.readUInt32(); + const uint32_t poiCount = packet.readUInt32(); + + // Remove any previously added POI markers for this quest to avoid duplicates + // on repeated queries (e.g. zone change or force-refresh). + gossipPois_.erase( + std::remove_if(gossipPois_.begin(), gossipPois_.end(), + [questId, this](const GossipPoi& p) { + // Match by questId embedded in data field (set below). + return p.data == questId; + }), + gossipPois_.end()); + + // Find the quest title for the marker label. + std::string questTitle; + for (const auto& q : questLog_) { + if (q.questId == questId) { questTitle = q.title; break; } + } + + for (uint32_t pi = 0; pi < poiCount; ++pi) { + if (packet.getSize() - packet.getReadPos() < 28) return; + packet.readUInt32(); // poiId + packet.readUInt32(); // objIndex (int32) + const uint32_t mapId = packet.readUInt32(); + packet.readUInt32(); // areaId + packet.readUInt32(); // floorId + packet.readUInt32(); // unk1 + packet.readUInt32(); // unk2 + const uint32_t pointCount = packet.readUInt32(); + if (pointCount == 0) continue; + if (packet.getSize() - packet.getReadPos() < pointCount * 8) return; + // Compute centroid of the poi region to place a minimap marker. + float sumX = 0.0f, sumY = 0.0f; + for (uint32_t pt = 0; pt < pointCount; ++pt) { + const int32_t px = static_cast(packet.readUInt32()); + const int32_t py = static_cast(packet.readUInt32()); + sumX += static_cast(px); + sumY += static_cast(py); + } + // Skip POIs for maps other than the player's current map. + if (mapId != currentMapId_) continue; + // Add as a GossipPoi; use data field to carry questId for later dedup. + GossipPoi poi; + poi.x = sumX / static_cast(pointCount); // WoW canonical X + poi.y = sumY / static_cast(pointCount); // WoW canonical Y + poi.icon = 6; // generic quest POI icon + poi.data = questId; // used for dedup on subsequent queries + poi.name = questTitle.empty() ? "Quest objective" : questTitle; + LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId, + " centroid=(", poi.x, ",", poi.y, ") title=", poi.name); + if (gossipPois_.size() >= 200) gossipPois_.erase(gossipPois_.begin()); + gossipPois_.push_back(std::move(poi)); + } + } +} + void GameHandler::handleQuestDetails(network::Packet& packet) { QuestDetailsData data; bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data) @@ -14119,8 +21497,14 @@ void GameHandler::handleQuestDetails(network::Packet& packet) { } break; } - questDetailsOpen = true; + // Pre-fetch item info for all reward items so icons and names are ready + // both in this details window and later in the offer-reward dialog (after the player turns in). + for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0); + for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0); + // Delay opening the window slightly to allow item queries to complete + questDetailsOpenTime = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("QUEST_DETAIL", {}); } bool GameHandler::hasQuestInLog(uint32_t questId) const { @@ -14151,6 +21535,10 @@ void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::strin entry.title = title.empty() ? ("Quest #" + std::to_string(questId)) : title; entry.objectives = objectives; questLog_.push_back(std::move(entry)); + if (addonEventCallback_) { + addonEventCallback_("QUEST_ACCEPTED", {std::to_string(questId)}); + addonEventCallback_("QUEST_LOG_UPDATE", {}); + } } bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { @@ -14158,15 +21546,38 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; - std::unordered_set serverQuestIds; - serverQuestIds.reserve(25); + + // Collect quest IDs and their completion state from update fields. + // State field (slot*stride+1) uses the same QuestStatus enum across all expansions: + // 0 = none, 1 = complete (ready to turn in), 3 = incomplete/active, etc. + static constexpr uint32_t kQuestStatusComplete = 1; + + std::unordered_map serverQuestComplete; // questId → complete + serverQuestComplete.reserve(25); for (uint16_t slot = 0; slot < 25; ++slot) { - const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t stateField = ufQuestStart + slot * qStride + 1; auto it = lastPlayerFields_.find(idField); if (it == lastPlayerFields_.end()) continue; - if (it->second != 0) serverQuestIds.insert(it->second); + uint32_t questId = it->second; + if (questId == 0) continue; + + bool complete = false; + if (qStride >= 2) { + auto stateIt = lastPlayerFields_.find(stateField); + if (stateIt != lastPlayerFields_.end()) { + // Lower byte is the quest state; treat any variant of "complete" as done. + uint32_t state = stateIt->second & 0xFF; + complete = (state == kQuestStatusComplete); + } + } + serverQuestComplete[questId] = complete; } + std::unordered_set serverQuestIds; + serverQuestIds.reserve(serverQuestComplete.size()); + for (const auto& [qid, _] : serverQuestComplete) serverQuestIds.insert(qid); + const size_t localBefore = questLog_.size(); std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == 0 || serverQuestIds.count(q.questId) == 0; @@ -14180,6 +21591,20 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { ++added; } + // Apply server-authoritative completion state to all tracked quests. + // This initialises quest.complete correctly on login for quests that were + // already complete before the current session started. + size_t marked = 0; + for (auto& quest : questLog_) { + auto it = serverQuestComplete.find(quest.questId); + if (it == serverQuestComplete.end()) continue; + if (it->second && !quest.complete) { + quest.complete = true; + ++marked; + LOG_DEBUG("Quest ", quest.questId, " marked complete from update fields"); + } + } + if (forceQueryMetadata) { for (uint32_t questId : serverQuestIds) { requestQuestQuery(questId, false); @@ -14187,10 +21612,119 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { } LOG_INFO("Quest log resync from server slots: server=", serverQuestIds.size(), - " localBefore=", localBefore, " removed=", removed, " added=", added); + " localBefore=", localBefore, " removed=", removed, " added=", added, + " markedComplete=", marked); return true; } +// Apply quest completion state from player update fields to already-tracked local quests. +// Called from VALUES update handler so quests that complete mid-session (or that were +// complete on login) get quest.complete=true without waiting for SMSG_QUESTUPDATE_COMPLETE. +void GameHandler::applyQuestStateFromFields(const std::map& fields) { + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + if (ufQuestStart == 0xFFFF || questLog_.empty()) return; + + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + if (qStride < 2) return; // Need at least 2 fields per slot (id + state) + + static constexpr uint32_t kQuestStatusComplete = 1; + + for (uint16_t slot = 0; slot < 25; ++slot) { + const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t stateField = idField + 1; + auto idIt = fields.find(idField); + if (idIt == fields.end()) continue; + uint32_t questId = idIt->second; + if (questId == 0) continue; + + auto stateIt = fields.find(stateField); + if (stateIt == fields.end()) continue; + bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete); + if (!serverComplete) continue; + + for (auto& quest : questLog_) { + if (quest.questId == questId && !quest.complete) { + quest.complete = true; + LOG_INFO("Quest ", questId, " marked complete from VALUES update field state"); + break; + } + } + } +} + +// Extract packed 6-bit kill/objective counts from WotLK/TBC/Classic quest-log update fields +// and populate quest.killCounts + quest.itemCounts using the structured objectives obtained +// from a prior SMSG_QUEST_QUERY_RESPONSE. Silently does nothing if objectives are absent. +void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { + if (lastPlayerFields_.empty()) return; + + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + if (ufQuestStart == 0xFFFF) return; + + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + if (qStride < 3) return; // Need at least id + state + packed-counts field + + // Find which server slot this quest occupies. + int slot = findQuestLogSlotIndexFromServer(quest.questId); + if (slot < 0) return; + + // Packed count fields: stride+2 (all expansions), stride+3 (WotLK only, stride==5) + const uint16_t countField1 = ufQuestStart + static_cast(slot) * qStride + 2; + const uint16_t countField2 = (qStride >= 5) + ? static_cast(countField1 + 1) + : static_cast(0xFFFF); + + auto f1It = lastPlayerFields_.find(countField1); + if (f1It == lastPlayerFields_.end()) return; + const uint32_t packed1 = f1It->second; + + uint32_t packed2 = 0; + if (countField2 != 0xFFFF) { + auto f2It = lastPlayerFields_.find(countField2); + if (f2It != lastPlayerFields_.end()) packed2 = f2It->second; + } + + // Unpack six 6-bit counts (bit fields 0-5, 6-11, 12-17, 18-23 in packed1; + // bits 0-5, 6-11 in packed2 for objectives 4 and 5). + auto unpack6 = [](uint32_t word, int idx) -> uint8_t { + return static_cast((word >> (idx * 6)) & 0x3F); + }; + const uint8_t counts[6] = { + unpack6(packed1, 0), unpack6(packed1, 1), + unpack6(packed1, 2), unpack6(packed1, 3), + unpack6(packed2, 0), unpack6(packed2, 1), + }; + + // Apply kill objective counts (indices 0-3). + for (int i = 0; i < 4; ++i) { + const auto& obj = quest.killObjectives[i]; + if (obj.npcOrGoId == 0 || obj.required == 0) continue; + // Negative npcOrGoId means game object; use absolute value as the map key + // (SMSG_QUESTUPDATE_ADD_KILL always sends a positive entry regardless of type). + const uint32_t entryKey = static_cast( + obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); + // Don't overwrite live kill count with stale packed data if already non-zero. + if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue; + quest.killCounts[entryKey] = {counts[i], obj.required}; + LOG_DEBUG("Quest ", quest.questId, " objective[", i, "]: npcOrGo=", + obj.npcOrGoId, " count=", (int)counts[i], "/", obj.required); + } + + // Apply item objective counts (only available in WotLK stride+3 positions 4-5). + // Item counts also arrive live via SMSG_QUESTUPDATE_ADD_ITEM; just initialise here. + for (int i = 0; i < 6; ++i) { + const auto& obj = quest.itemObjectives[i]; + if (obj.itemId == 0 || obj.required == 0) continue; + if (i < 2 && qStride >= 5) { + uint8_t cnt = counts[4 + i]; + if (cnt > 0) { + quest.itemCounts[obj.itemId] = std::max(quest.itemCounts[obj.itemId], static_cast(cnt)); + } + } + quest.requiredItemCounts.emplace(obj.itemId, obj.required); + } +} + void GameHandler::clearPendingQuestAccept(uint32_t questId) { pendingQuestAcceptTimeouts_.erase(questId); pendingQuestAcceptNpcGuids_.erase(questId); @@ -14223,6 +21757,7 @@ void GameHandler::acceptQuest() { LOG_DEBUG("Ignoring duplicate quest accept while pending: questId=", questId); triggerQuestAcceptResync(questId, npcGuid, "duplicate-accept"); questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; currentQuestDetails = QuestDetailsData{}; return; } @@ -14232,6 +21767,7 @@ void GameHandler::acceptQuest() { LOG_INFO("Ignoring duplicate quest accept already in server quest log: questId=", questId, " slot=", serverSlot); questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; currentQuestDetails = QuestDetailsData{}; return; } @@ -14247,7 +21783,14 @@ void GameHandler::acceptQuest() { pendingQuestAcceptTimeouts_[questId] = 5.0f; pendingQuestAcceptNpcGuids_[questId] = npcGuid; + // Play quest-accept sound + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playQuestActivate(); + } + questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; currentQuestDetails = QuestDetailsData{}; // Re-query quest giver status so marker updates (! → ?) @@ -14260,6 +21803,7 @@ void GameHandler::acceptQuest() { void GameHandler::declineQuest() { questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; currentQuestDetails = QuestDetailsData{}; } @@ -14293,6 +21837,34 @@ void GameHandler::abandonQuest(uint32_t questId) { if (localIndex >= 0) { questLog_.erase(questLog_.begin() + static_cast(localIndex)); } + + // Remove any quest POI minimap markers for this quest. + gossipPois_.erase( + std::remove_if(gossipPois_.begin(), gossipPois_.end(), + [questId](const GossipPoi& p) { return p.data == questId; }), + gossipPois_.end()); +} + +void GameHandler::shareQuestWithParty(uint32_t questId) { + if (state != WorldState::IN_WORLD || !socket) { + addSystemChatMessage("Cannot share quest: not in world."); + return; + } + if (!isInGroup()) { + addSystemChatMessage("You must be in a group to share a quest."); + return; + } + network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY)); + pkt.writeUInt32(questId); + socket->send(pkt); + // Local feedback: find quest title + for (const auto& q : questLog_) { + if (q.questId == questId && !q.title.empty()) { + addSystemChatMessage("Sharing quest: " + q.title); + return; + } + } + addSystemChatMessage("Quest shared."); } void GameHandler::handleQuestRequestItems(network::Packet& packet) { @@ -14320,6 +21892,7 @@ void GameHandler::handleQuestRequestItems(network::Packet& packet) { questRequestItemsOpen_ = true; gossipWindowOpen = false; questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; // Query item names for required items for (const auto& item : data.requiredItems) { @@ -14376,6 +21949,8 @@ void GameHandler::handleQuestOfferReward(network::Packet& packet) { questRequestItemsOpen_ = false; gossipWindowOpen = false; questDetailsOpen = false; + questDetailsOpenTime = std::chrono::steady_clock::time_point{}; + if (addonEventCallback_) addonEventCallback_("QUEST_COMPLETE", {}); // Query item names for reward items for (const auto& item : data.choiceRewards) @@ -14434,9 +22009,38 @@ void GameHandler::closeQuestOfferReward() { void GameHandler::closeGossip() { gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {}); currentGossip = GossipMessageData{}; } +void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { + if (state != WorldState::IN_WORLD || !socket) return; + if (itemGuid == 0 || questId == 0) { + addSystemChatMessage("Cannot start quest right now."); + return; + } + // Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver." + // The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() + // picks up and opens the Accept/Decline dialog. + auto queryPkt = packetParsers_ + ? packetParsers_->buildQueryQuestPacket(itemGuid, questId) + : QuestgiverQueryQuestPacket::build(itemGuid, questId); + socket->send(queryPkt); + LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec, + " questId=", questId); +} + +uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0; + if (slotIndex < 0) return 0; + uint64_t bagGuid = equipSlotGuids_[19 + bagIndex]; + if (bagGuid == 0) return 0; + auto it = containerContents_.find(bagGuid); + if (it == containerContents_.end()) return 0; + if (slotIndex >= static_cast(it->second.numSlots)) return 0; + return it->second.slotGuids[slotIndex]; +} + void GameHandler::openVendor(uint64_t npcGuid) { if (state != WorldState::IN_WORLD || !socket) return; buybackItems_.clear(); @@ -14445,6 +22049,7 @@ void GameHandler::openVendor(uint64_t npcGuid) { } void GameHandler::closeVendor() { + bool wasOpen = vendorWindowOpen; vendorWindowOpen = false; currentVendorItems = ListInventoryData{}; buybackItems_.clear(); @@ -14453,6 +22058,7 @@ void GameHandler::closeVendor() { pendingBuybackWireSlot_ = 0; pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; + if (wasOpen && addonEventCallback_) addonEventCallback_("MERCHANT_CLOSED", {}); } void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { @@ -14468,8 +22074,11 @@ void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, u packet.writeUInt32(itemId); // item entry packet.writeUInt32(slot); // vendor slot index packet.writeUInt32(count); - // WotLK/AzerothCore expects a trailing byte here. - packet.writeUInt8(0); + // WotLK/AzerothCore expects a trailing byte; Classic/TBC do not + const bool isWotLk = isActiveExpansion("wotlk"); + if (isWotLk) { + packet.writeUInt8(0); + } socket->send(packet); } @@ -14497,6 +22106,26 @@ void GameHandler::buyBackItem(uint32_t buybackSlot) { socket->send(packet); } +void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_REPAIR_ITEM: npcGuid(8) + itemGuid(8) + useGuildBank(uint8) + network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(itemGuid); + packet.writeUInt8(0); // do not use guild bank + socket->send(packet); +} + +void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { + if (state != WorldState::IN_WORLD || !socket) return; + // itemGuid = 0 signals "repair all equipped" to the server + network::Packet packet(wireOpcode(Opcode::CMSG_REPAIR_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(0); + packet.writeUInt8(useGuildBank ? 1 : 0); + socket->send(packet); +} + void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { if (state != WorldState::IN_WORLD || !socket) return; LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid, @@ -14690,6 +22319,40 @@ void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) { socket->send(packet); } +void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) { + if (state != WorldState::IN_WORLD || !socket) return; + if (count == 0) return; + + // Find a free slot for the split destination: try backpack first, then bags + int freeBp = inventory.findFreeBackpackSlot(); + if (freeBp >= 0) { + uint8_t dstBag = 0xFF; + uint8_t dstSlot = static_cast(23 + freeBp); + LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, + ") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")"); + auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); + socket->send(packet); + return; + } + // Try equipped bags + for (int b = 0; b < inventory.NUM_BAG_SLOTS; b++) { + int bagSize = inventory.getBagSize(b); + for (int s = 0; s < bagSize; s++) { + if (inventory.getBagSlot(b, s).empty()) { + uint8_t dstBag = static_cast(19 + b); + uint8_t dstSlot = static_cast(s); + LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, + ") count=", (int)count, " -> dst(bag=", (int)dstBag, + " slot=", (int)dstSlot, ")"); + auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); + socket->send(packet); + return; + } + } + } + addSystemChatMessage("Cannot split: no free inventory slots."); +} + void GameHandler::useItemBySlot(int backpackIndex) { if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; const auto& slot = inventory.getBackpackSlot(backpackIndex); @@ -14772,6 +22435,26 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) { } } +void GameHandler::openItemBySlot(int backpackIndex) { + if (backpackIndex < 0 || backpackIndex >= inventory.getBackpackSize()) return; + if (inventory.getBackpackSlot(backpackIndex).empty()) return; + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = OpenItemPacket::build(0xFF, static_cast(23 + backpackIndex)); + LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (23 + backpackIndex)); + socket->send(packet); +} + +void GameHandler::openItemInBag(int bagIndex, int slotIndex) { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= inventory.getBagSize(bagIndex)) return; + if (inventory.getBagSlot(bagIndex, slotIndex).empty()) return; + if (state != WorldState::IN_WORLD || !socket) return; + uint8_t wowBag = static_cast(19 + bagIndex); + auto packet = OpenItemPacket::build(wowBag, static_cast(slotIndex)); + LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex); + socket->send(packet); +} + void GameHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; LOG_DEBUG("useItemById: searching for itemId=", itemId); @@ -14823,9 +22506,26 @@ void GameHandler::unstuckHearth() { } void GameHandler::handleLootResponse(network::Packet& packet) { - if (!LootResponseParser::parse(packet, currentLoot)) return; + // All expansions use 22 bytes/item (slot+itemId+count+displayInfo+randSuffix+randProp+slotType). + // WotLK adds a quest item list after the regular items. + const bool wotlkLoot = isActiveExpansion("wotlk"); + if (!LootResponseParser::parse(packet, currentLoot, wotlkLoot)) return; + const bool hasLoot = !currentLoot.items.empty() || currentLoot.gold > 0; + // If we're mid-gather-cast and got an empty loot response (premature CMSG_LOOT + // before the node became lootable), ignore it — don't clear our gather state. + if (!hasLoot && casting && currentCastSpellId != 0 && lastInteractedGoGuid_ != 0) { + LOG_DEBUG("Ignoring empty SMSG_LOOT_RESPONSE during gather cast"); + return; + } lootWindowOpen = true; - localLootState_[currentLoot.lootGuid] = LocalLootState{currentLoot, false}; + if (addonEventCallback_) addonEventCallback_("LOOT_OPENED", {}); + lastInteractedGoGuid_ = 0; // loot opened — no need to re-send in handleSpellGo + pendingGameObjectLootOpens_.erase( + std::remove_if(pendingGameObjectLootOpens_.begin(), pendingGameObjectLootOpens_.end(), + [&](const PendingLootOpen& p) { return p.guid == currentLoot.lootGuid; }), + pendingGameObjectLootOpens_.end()); + auto& localLoot = localLootState_[currentLoot.lootGuid]; + localLoot.data = currentLoot; // Query item info so loot window can show names instead of IDs for (const auto& item : currentLoot.items) { @@ -14850,11 +22550,12 @@ void GameHandler::handleLootResponse(network::Packet& packet) { } // Auto-loot items when enabled - if (autoLoot_ && state == WorldState::IN_WORLD && socket) { + if (autoLoot_ && state == WorldState::IN_WORLD && socket && !localLoot.itemAutoLootSent) { for (const auto& item : currentLoot.items) { auto pkt = AutostoreLootItemPacket::build(item.slotIndex); socket->send(pkt); } + localLoot.itemAutoLootSent = true; } } @@ -14862,6 +22563,7 @@ void GameHandler::handleLootReleaseResponse(network::Packet& packet) { (void)packet; localLootState_.erase(currentLoot.lootGuid); lootWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("LOOT_CLOSED", {}); currentLoot = LootResponseData{}; } @@ -14870,18 +22572,22 @@ void GameHandler::handleLootRemoved(network::Packet& packet) { for (auto it = currentLoot.items.begin(); it != currentLoot.items.end(); ++it) { if (it->slotIndex == slotIndex) { std::string itemName = "item #" + std::to_string(it->itemId); + uint32_t quality = 1; if (const ItemQueryResponseData* info = getItemInfo(it->itemId)) { - if (!info->name.empty()) { - itemName = info->name; - } + if (!info->name.empty()) itemName = info->name; + quality = info->quality; } - std::ostringstream msg; - msg << "Looted: " << itemName; - if (it->count > 1) { - msg << " x" << it->count; + std::string link = buildItemLink(it->itemId, quality, itemName); + std::string msgStr = "Looted: " + link; + if (it->count > 1) msgStr += " x" + std::to_string(it->count); + addSystemChatMessage(msgStr); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playLootItem(); } - addSystemChatMessage(msg.str()); currentLoot.items.erase(it); + if (addonEventCallback_) + addonEventCallback_("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)}); break; } } @@ -14893,6 +22599,7 @@ void GameHandler::handleGossipMessage(network::Packet& packet) { if (!ok) return; if (questDetailsOpen) return; // Don't reopen gossip while viewing quest gossipWindowOpen = true; + if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {}); vendorWindowOpen = false; // Close vendor if gossip opens // Update known quest-log entries based on gossip quests. @@ -15006,6 +22713,7 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) { currentGossip = std::move(data); gossipWindowOpen = true; + if (addonEventCallback_) addonEventCallback_("GOSSIP_SHOW", {}); vendorWindowOpen = false; bool hasAvailableQuest = false; @@ -15056,13 +22764,110 @@ void GameHandler::handleGossipComplete(network::Packet& packet) { } gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("GOSSIP_CLOSED", {}); currentGossip = GossipMessageData{}; } void GameHandler::handleListInventory(network::Packet& packet) { + bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path if (!ListInventoryParser::parse(packet, currentVendorItems)) return; + + // Check NPC_FLAG_REPAIR (0x1000) on the vendor entity — this handles vendors that open + // directly without going through the gossip armorer option. + if (!savedCanRepair && currentVendorItems.vendorGuid != 0) { + auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); + if (entity && entity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + // MaNGOS/Trinity: UNIT_NPC_FLAG_REPAIR = 0x00001000. + if (unit->getNpcFlags() & 0x1000) { + savedCanRepair = true; + } + } + } + currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens + if (addonEventCallback_) addonEventCallback_("MERCHANT_SHOW", {}); + + // Auto-sell grey items if enabled + if (autoSellGrey_ && currentVendorItems.vendorGuid != 0) { + uint32_t totalSellPrice = 0; + int itemsSold = 0; + + // Helper lambda to attempt selling a poor-quality slot + auto tryAutoSell = [&](const ItemSlot& slot, uint64_t itemGuid) { + if (slot.empty()) return; + if (slot.item.quality != ItemQuality::POOR) return; + // Determine sell price (slot cache first, then item info fallback) + uint32_t sp = slot.item.sellPrice; + if (sp == 0) { + if (auto* info = getItemInfo(slot.item.itemId); info && info->valid) + sp = info->sellPrice; + } + if (sp == 0 || itemGuid == 0) return; + BuybackItem sold; + sold.itemGuid = itemGuid; + sold.item = slot.item; + sold.count = 1; + buybackItems_.push_front(sold); + if (buybackItems_.size() > 12) buybackItems_.pop_back(); + pendingSellToBuyback_[itemGuid] = sold; + sellItem(currentVendorItems.vendorGuid, itemGuid, 1); + totalSellPrice += sp; + ++itemsSold; + }; + + // Backpack slots + for (int i = 0; i < inventory.getBackpackSize(); ++i) { + uint64_t guid = backpackSlotGuids_[i]; + if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBackpackSlot(i).item.itemId); + tryAutoSell(inventory.getBackpackSlot(i), guid); + } + + // Extra bag slots + for (int b = 0; b < inventory.NUM_BAG_SLOTS; ++b) { + uint64_t bagGuid = equipSlotGuids_[19 + b]; + for (int s = 0; s < inventory.getBagSize(b); ++s) { + uint64_t guid = 0; + if (bagGuid != 0) { + auto it = containerContents_.find(bagGuid); + if (it != containerContents_.end() && s < static_cast(it->second.numSlots)) + guid = it->second.slotGuids[s]; + } + if (guid == 0) guid = resolveOnlineItemGuid(inventory.getBagSlot(b, s).item.itemId); + tryAutoSell(inventory.getBagSlot(b, s), guid); + } + } + + if (itemsSold > 0) { + uint32_t gold = totalSellPrice / 10000; + uint32_t silver = (totalSellPrice % 10000) / 100; + uint32_t copper = totalSellPrice % 100; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "|cffaaaaaaAuto-sold %d grey item%s for %ug %us %uc.|r", + itemsSold, itemsSold == 1 ? "" : "s", gold, silver, copper); + addSystemChatMessage(buf); + } + } + + // Auto-repair all items if enabled and vendor can repair + if (autoRepair_ && currentVendorItems.canRepair && currentVendorItems.vendorGuid != 0) { + // Check that at least one equipped item is actually damaged to avoid no-op + bool anyDamaged = false; + for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inventory.getEquipSlot(static_cast(i)); + if (!slot.empty() && slot.item.maxDurability > 0 + && slot.item.curDurability < slot.item.maxDurability) { + anyDamaged = true; + break; + } + } + if (anyDamaged) { + repairAll(currentVendorItems.vendorGuid, false); + addSystemChatMessage("|cffaaaaaaAuto-repair triggered.|r"); + } + } // Play vendor sound if (npcVendorCallback_ && currentVendorItems.vendorGuid != 0) { @@ -15088,6 +22893,7 @@ void GameHandler::handleTrainerList(network::Packet& packet) { if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return; trainerWindowOpen_ = true; gossipWindowOpen = false; + if (addonEventCallback_) addonEventCallback_("TRAINER_SHOW", {}); LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells"); LOG_DEBUG("Known spells count: ", knownSpells.size()); @@ -15145,6 +22951,7 @@ void GameHandler::trainSpell(uint32_t spellId) { void GameHandler::closeTrainer() { trainerWindowOpen_ = false; + if (addonEventCallback_) addonEventCallback_("TRAINER_CLOSED", {}); currentTrainerList_ = TrainerListData{}; trainerTabs_.clear(); } @@ -15162,8 +22969,10 @@ void GameHandler::loadSpellNameCache() { return; } - if (dbc->getFieldCount() < 154) { - LOG_WARNING("Trainer: Spell.dbc has too few fields"); + // Classic 1.12 Spell.dbc has 148 fields; TBC/WotLK have more. + // Require at least 148 so Classic trainers can resolve spell names. + if (dbc->getFieldCount() < 148) { + LOG_WARNING("Trainer: Spell.dbc has too few fields (", dbc->getFieldCount(), ")"); return; } @@ -15179,6 +22988,29 @@ void GameHandler::loadSpellNameCache() { if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; } } + // DispelType field (0=none,1=magic,2=curse,3=disease,4=poison,5=stealth,…) + uint32_t dispelField = 0xFFFFFFFF; + bool hasDispelField = false; + if (spellL) { + uint32_t f = spellL->field("DispelType"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { dispelField = f; hasDispelField = true; } + } + + // AttributesEx field (bit 4 = SPELL_ATTR_EX_NOT_INTERRUPTIBLE) + uint32_t attrExField = 0xFFFFFFFF; + bool hasAttrExField = false; + if (spellL) { + uint32_t f = spellL->field("AttributesEx"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { attrExField = f; hasAttrExField = true; } + } + + // Tooltip/description field + uint32_t tooltipField = 0xFFFFFFFF; + if (spellL) { + uint32_t f = spellL->field("Tooltip"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) tooltipField = f; + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -15186,7 +23018,10 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - SpellNameEntry entry{std::move(name), std::move(rank), 0}; + SpellNameEntry entry{std::move(name), std::move(rank), {}, 0, 0, 0}; + if (tooltipField != 0xFFFFFFFF) { + entry.description = dbc->getString(i, tooltipField); + } if (hasSchoolMask) { entry.schoolMask = dbc->getUInt32(i, schoolMaskField); } else if (hasSchoolEnum) { @@ -15195,6 +23030,12 @@ void GameHandler::loadSpellNameCache() { uint32_t e = dbc->getUInt32(i, schoolEnumField); entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0; } + if (hasDispelField) { + entry.dispelType = static_cast(dbc->getUInt32(i, dispelField)); + } + if (hasAttrExField) { + entry.attrEx = dbc->getUInt32(i, attrExField); + } spellNameCache_[id] = std::move(entry); } } @@ -15222,6 +23063,62 @@ void GameHandler::loadSkillLineAbilityDbc() { } } +const std::vector& GameHandler::getSpellBookTabs() { + // Rebuild when spell count changes (learns/unlearns) + static size_t lastSpellCount = 0; + if (lastSpellCount == knownSpells.size() && !spellBookTabsDirty_) + return spellBookTabs_; + lastSpellCount = knownSpells.size(); + spellBookTabsDirty_ = false; + spellBookTabs_.clear(); + + static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7; + + // Group known spells by class skill line + std::map> bySkillLine; + std::vector general; + + for (uint32_t spellId : knownSpells) { + auto slIt = spellToSkillLine_.find(spellId); + if (slIt != spellToSkillLine_.end()) { + uint32_t skillLineId = slIt->second; + auto catIt = skillLineCategories_.find(skillLineId); + if (catIt != skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { + bySkillLine[skillLineId].push_back(spellId); + continue; + } + } + general.push_back(spellId); + } + + // Sort spells within each group by name + auto byName = [this](uint32_t a, uint32_t b) { + return getSpellName(a) < getSpellName(b); + }; + + // "General" tab first (spells not in a class skill line) + if (!general.empty()) { + std::sort(general.begin(), general.end(), byName); + spellBookTabs_.push_back({"General", "Interface\\Icons\\INV_Misc_Book_09", std::move(general)}); + } + + // Class skill line tabs, sorted by name + std::vector>> named; + for (auto& [skillLineId, spells] : bySkillLine) { + auto nameIt = skillLineNames_.find(skillLineId); + std::string tabName = (nameIt != skillLineNames_.end()) ? nameIt->second : "Unknown"; + std::sort(spells.begin(), spells.end(), byName); + named.emplace_back(std::move(tabName), std::move(spells)); + } + std::sort(named.begin(), named.end(), [](const auto& a, const auto& b) { return a.first < b.first; }); + + for (auto& [name, spells] : named) { + spellBookTabs_.push_back({std::move(name), "Interface\\Icons\\INV_Misc_Book_09", std::move(spells)}); + } + + return spellBookTabs_; +} + void GameHandler::categorizeTrainerSpells() { trainerTabs_.clear(); @@ -15388,6 +23285,34 @@ const std::string& GameHandler::getSpellRank(uint32_t spellId) const { return (it != spellNameCache_.end()) ? it->second.rank : EMPTY_STRING; } +const std::string& GameHandler::getSpellDescription(uint32_t spellId) const { + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.description : EMPTY_STRING; +} + +uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.dispelType : 0; +} + +bool GameHandler::isSpellInterruptible(uint32_t spellId) const { + if (spellId == 0) return true; + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + if (it == spellNameCache_.end()) return true; // assume interruptible if unknown + // SPELL_ATTR_EX_NOT_INTERRUPTIBLE = bit 4 of AttributesEx (0x00000010) + return (it->second.attrEx & 0x00000010u) == 0; +} + +uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const { + if (spellId == 0) return 0; + const_cast(this)->loadSpellNameCache(); + auto it = spellNameCache_.find(spellId); + return (it != spellNameCache_.end()) ? it->second.schoolMask : 0; +} + const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { auto slIt = spellToSkillLine_.find(spellId); if (slIt == spellToSkillLine_.end()) return EMPTY_STRING; @@ -15463,11 +23388,24 @@ void GameHandler::handleXpGain(network::Packet& packet) { // but we can show combat text for XP gains addCombatText(CombatTextEntry::XP_GAIN, static_cast(data.totalXp), 0, true); - std::string msg = "You gain " + std::to_string(data.totalXp) + " experience."; + // Build XP message with source creature name when available + std::string msg; + if (data.victimGuid != 0 && data.type == 0) { + // Kill XP — resolve creature name + std::string victimName = lookupName(data.victimGuid); + if (!victimName.empty()) + msg = victimName + " dies, you gain " + std::to_string(data.totalXp) + " experience."; + else + msg = "You gain " + std::to_string(data.totalXp) + " experience."; + } else { + msg = "You gain " + std::to_string(data.totalXp) + " experience."; + } if (data.groupBonus > 0) { msg += " (+" + std::to_string(data.groupBonus) + " group bonus)"; } addSystemChatMessage(msg); + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(data.totalXp)}); } @@ -15482,6 +23420,8 @@ void GameHandler::addMoneyCopper(uint32_t amount) { msg += std::to_string(silver) + "s "; msg += std::to_string(copper) + "c."; addSystemChatMessage(msg); + if (addonEventCallback_) + addonEventCallback_("CHAT_MSG_MONEY", {msg}); } void GameHandler::addSystemChatMessage(const std::string& message) { @@ -15512,15 +23452,20 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); - // Read the movement info embedded in the teleport - // Format: u32 flags, u16 flags2, u32 time, float x, float y, float z, float o - if (packet.getSize() - packet.getReadPos() < 4 + 2 + 4 + 4 * 4) { + // Read the movement info embedded in the teleport. + // WotLK: moveFlags(4) + moveFlags2(2) + time(4) + x(4) + y(4) + z(4) + o(4) = 26 bytes + // Classic 1.12 / TBC 2.4.3: moveFlags(4) + time(4) + x(4) + y(4) + z(4) + o(4) = 24 bytes + // (Classic and TBC have no moveFlags2 field in movement packets) + const bool taNoFlags2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const size_t minMoveSz = taNoFlags2 ? (4 + 4 + 4 * 4) : (4 + 2 + 4 + 4 * 4); + if (packet.getSize() - packet.getReadPos() < minMoveSz) { LOG_WARNING("MSG_MOVE_TELEPORT_ACK: not enough data for movement info"); return; } packet.readUInt32(); // moveFlags - packet.readUInt16(); // moveFlags2 + if (!taNoFlags2) + packet.readUInt16(); // moveFlags2 (WotLK only) uint32_t moveTime = packet.readUInt32(); float serverX = packet.readFloat(); float serverY = packet.readFloat(); @@ -15560,7 +23505,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); } } @@ -15605,9 +23550,19 @@ void GameHandler::handleNewWorld(network::Packet& packet) { repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; + corpseMapId_ = 0; + corpseGuid_ = 0; hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true; + casting = false; + castIsChannel = false; + currentCastSpellId = 0; + castTimeRemaining = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; if (socket) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK)); @@ -15618,6 +23573,10 @@ void GameHandler::handleNewWorld(network::Packet& packet) { } currentMapId_ = mapId; + inInstance_ = false; // cleared on map change; re-set if SMSG_INSTANCE_DIFFICULTY follows + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "new_world"); + } // Update player position glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); @@ -15643,10 +23602,31 @@ void GameHandler::handleNewWorld(network::Packet& packet) { mountCallback_(0); } - // Clear world state for the new map + // Invoke despawn callbacks for all entities before clearing, so the renderer + // can release M2 instances, character models, and associated resources. + for (const auto& [guid, entity] : entityManager.getEntities()) { + if (guid == playerGuid) continue; // skip self + if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { + creatureDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + playerDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { + gameObjectDespawnCallback_(guid); + } + } + otherPlayerVisibleItemEntries_.clear(); + otherPlayerVisibleDirty_.clear(); + otherPlayerMoveTimeMs_.clear(); + unitCastStates_.clear(); + unitAurasCache_.clear(); + combatText.clear(); entityManager.clear(); hostileAttackers_.clear(); worldStates_.clear(); + // Quest POI markers are map-specific; remove those that don't apply to the new map. + // Markers without a questId tag (data==0) are gossip-window POIs — keep them cleared + // here since gossipWindowOpen is reset on teleport anyway. + gossipPois_.clear(); worldStateMapId_ = mapId; worldStateZoneId_ = 0; activeAreaTriggers_.clear(); @@ -15654,9 +23634,15 @@ void GameHandler::handleNewWorld(network::Packet& packet) { areaTriggerSuppressFirst_ = true; // first check just marks active triggers, doesn't fire stopAutoAttack(); casting = false; + castIsChannel = false; currentCastSpellId = 0; pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; castTimeRemaining = 0.0f; + craftQueueSpellId_ = 0; + craftQueueRemaining_ = 0; + queuedSpellId_ = 0; + queuedSpellTarget_ = 0; // Send MSG_MOVE_WORLDPORT_ACK to tell the server we're ready if (socket) { @@ -15665,9 +23651,25 @@ void GameHandler::handleNewWorld(network::Packet& packet) { LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK"); } - // Reload terrain at new position + timeSinceLastPing = 0.0f; + if (socket) { + LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK"); + sendPing(); + } + + // Reload terrain at new position. + // Pass isSameMap as isInitialEntry so the application despawns and + // re-registers renderer instances before the server resends CREATE_OBJECTs. + // Without this, same-map SMSG_NEW_WORLD (dungeon wing teleporters, etc.) + // leaves zombie renderer instances that block fresh entity spawns. if (worldEntryCallback_) { - worldEntryCallback_(mapId, serverX, serverY, serverZ); + worldEntryCallback_(mapId, serverX, serverY, serverZ, isSameMap); + } + + // Fire PLAYER_ENTERING_WORLD for teleports / zone transitions + if (addonEventCallback_) { + addonEventCallback_("PLAYER_ENTERING_WORLD", {"0"}); + addonEventCallback_("ZONE_CHANGED_NEW_AREA", {}); } } @@ -16266,7 +24268,16 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { dismount(); } - addSystemChatMessage("Taxi: requesting flight..."); + { + auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) { + taxiDestName_ = destIt->second.name; + addSystemChatMessage("Requesting flight to " + destIt->second.name + "..."); + } else { + taxiDestName_.clear(); + addSystemChatMessage("Taxi: requesting flight..."); + } + } // BFS to find path from startNode to destNodeId std::unordered_map> adj; @@ -16384,10 +24395,13 @@ void GameHandler::activateTaxi(uint32_t destNodeId) { taxiActivateTimer_ = 0.0f; } - addSystemChatMessage("Flight started."); - // Save recovery target in case of disconnect during taxi. auto destIt = taxiNodes_.find(destNodeId); + if (destIt != taxiNodes_.end() && !destIt->second.name.empty()) + addSystemChatMessage("Flight to " + destIt->second.name + " started."); + else + addSystemChatMessage("Flight started."); + if (destIt != taxiNodes_.end()) { taxiRecoverMapId_ = destIt->second.mapId; taxiRecoverPos_ = core::coords::serverToCanonical( @@ -16425,6 +24439,9 @@ void GameHandler::handlePlayedTime(network::Packet& packet) { return; } + totalTimePlayed_ = data.totalTimePlayed; + levelTimePlayed_ = data.levelTimePlayed; + if (data.triggerMessage) { // Format total time played uint32_t totalDays = data.totalTimePlayed / 86400; @@ -16463,13 +24480,15 @@ void GameHandler::handleWho(network::Packet& packet) { LOG_INFO("WHO response: ", displayCount, " players displayed, ", onlineCount, " total online"); + // Store structured results for the who-results window + whoResults_.clear(); + whoOnlineCount_ = onlineCount; + if (displayCount == 0) { addSystemChatMessage("No players found."); return; } - addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:"); - for (uint32_t i = 0; i < displayCount; ++i) { if (packet.getReadPos() >= packet.getSize()) break; std::string playerName = packet.readString(); @@ -16480,16 +24499,22 @@ 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); + // Store structured entry + WhoEntry entry; + entry.name = playerName; + entry.guildName = guildName; + entry.level = level; + entry.classId = classId; + entry.raceId = raceId; + entry.zoneId = zoneId; + whoResults_.push_back(std::move(entry)); - addSystemChatMessage(msg); - LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId); + LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, + " Race:", raceId, " Zone:", zoneId); } } @@ -16544,6 +24569,7 @@ void GameHandler::handleFriendList(network::Packet& packet) { entry.classId = classId; contacts_.push_back(std::move(entry)); } + if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {}); } void GameHandler::handleContactList(network::Packet& packet) { @@ -16607,6 +24633,11 @@ void GameHandler::handleContactList(network::Packet& packet) { } LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, " count=", lastContactListCount_); + if (addonEventCallback_) { + addonEventCallback_("FRIENDLIST_UPDATE", {}); + if (lastContactListMask_ & 0x2) // ignore list + addonEventCallback_("IGNORELIST_UPDATE", {}); + } } void GameHandler::handleFriendStatus(network::Packet& packet) { @@ -16616,13 +24647,17 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { return; } - // Look up player name from GUID + // Look up player name: contacts_ (populated by SMSG_FRIEND_LIST) > playerNameCache std::string playerName; - auto it = playerNameCache.find(data.guid); - if (it != playerNameCache.end()) { - playerName = it->second; - } else { - playerName = "Unknown"; + { + auto cit2 = std::find_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }); + if (cit2 != contacts_.end() && !cit2->name.empty()) { + playerName = cit2->name; + } else { + auto it = playerNameCache.find(data.guid); + if (it != playerNameCache.end()) playerName = it->second; + } } // Update friends cache @@ -16686,6 +24721,7 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { } LOG_INFO("Friend status update: ", playerName, " status=", (int)data.status); + if (addonEventCallback_) addonEventCallback_("FRIENDLIST_UPDATE", {}); } void GameHandler::handleRandomRoll(network::Packet& packet) { @@ -16733,14 +24769,18 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { // Success - logout initiated if (data.instant) { addSystemChatMessage("Logging out..."); + logoutCountdown_ = 0.0f; } else { addSystemChatMessage("Logging out in 20 seconds..."); + logoutCountdown_ = 20.0f; } LOG_INFO("Logout response: success, instant=", (int)data.instant); + if (addonEventCallback_) addonEventCallback_("PLAYER_LOGOUT", {}); } else { // Failure addSystemChatMessage("Cannot logout right now."); loggingOut_ = false; + logoutCountdown_ = 0.0f; LOG_WARNING("Logout failed, result=", data.result); } } @@ -16748,6 +24788,7 @@ void GameHandler::handleLogoutResponse(network::Packet& packet) { void GameHandler::handleLogoutComplete(network::Packet& /*packet*/) { addSystemChatMessage("Logout complete."); loggingOut_ = false; + logoutCountdown_ = 0.0f; LOG_INFO("Logout complete"); // Server will disconnect us } @@ -16793,6 +24834,15 @@ uint32_t GameHandler::getSkillCategory(uint32_t skillId) const { return (it != skillLineCategories_.end()) ? it->second : 0; } +bool GameHandler::isProfessionSpell(uint32_t spellId) const { + auto slIt = spellToSkillLine_.find(spellId); + if (slIt == spellToSkillLine_.end()) return false; + auto catIt = skillLineCategories_.find(slIt->second); + if (catIt == skillLineCategories_.end()) return false; + // Category 11 = profession (Blacksmithing, etc.), 9 = secondary (Cooking, First Aid, Fishing) + return catIt->second == 11 || catIt->second == 9; +} + void GameHandler::loadSkillLineDbc() { if (skillLineDbcLoaded_) return; skillLineDbcLoaded_ = true; @@ -16844,10 +24894,20 @@ void GameHandler::extractSkillFields(const std::map& fields) uint16_t value = raw1 & 0xFFFF; uint16_t maxValue = (raw1 >> 16) & 0xFFFF; + uint16_t bonusTemp = 0; + uint16_t bonusPerm = 0; + auto bonusIt = fields.find(static_cast(baseField + 2)); + if (bonusIt != fields.end()) { + bonusTemp = bonusIt->second & 0xFFFF; + bonusPerm = (bonusIt->second >> 16) & 0xFFFF; + } + PlayerSkill skill; skill.skillId = skillId; skill.value = value; skill.maxValue = maxValue; + skill.bonusTemp = bonusTemp; + skill.bonusPerm = bonusPerm; newSkills[skillId] = skill; } @@ -16874,22 +24934,47 @@ void GameHandler::extractSkillFields(const std::map& fields) } } + bool skillsChanged = (newSkills.size() != playerSkills_.size()); + if (!skillsChanged) { + for (const auto& [id, sk] : newSkills) { + auto it = playerSkills_.find(id); + if (it == playerSkills_.end() || it->second.value != sk.value) { + skillsChanged = true; + break; + } + } + } playerSkills_ = std::move(newSkills); + if (skillsChanged && addonEventCallback_) + addonEventCallback_("SKILL_LINES_CHANGED", {}); } void GameHandler::extractExploredZoneFields(const std::map& fields) { + // Number of explored-zone uint32 fields varies by expansion: + // Classic/Turtle = 64, TBC/WotLK = 128. Always allocate 128 for world-map + // bit lookups, but only read the expansion-specific count to avoid reading + // player money or rest-XP fields as zone flags. + const size_t zoneCount = packetParsers_ + ? static_cast(packetParsers_->exploredZonesCount()) + : PLAYER_EXPLORED_ZONES_COUNT; + if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) { playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u); } bool foundAny = false; - for (size_t i = 0; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { + for (size_t i = 0; i < zoneCount; i++) { const uint16_t fieldIdx = static_cast(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i); auto it = fields.find(fieldIdx); if (it == fields.end()) continue; playerExploredZones_[i] = it->second; foundAny = true; } + // Zero out slots beyond the expansion's zone count to prevent stale data + // from polluting the fog-of-war display. + for (size_t i = zoneCount; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { + playerExploredZones_[i] = 0u; + } if (foundAny) { hasPlayerExploredZones_ = true; @@ -16908,6 +24993,21 @@ std::string GameHandler::getCharacterConfigDir() { return dir; } +static const std::string EMPTY_MACRO_TEXT; + +const std::string& GameHandler::getMacroText(uint32_t macroId) const { + auto it = macros_.find(macroId); + return (it != macros_.end()) ? it->second : EMPTY_MACRO_TEXT; +} + +void GameHandler::setMacroText(uint32_t macroId, const std::string& text) { + if (text.empty()) + macros_.erase(macroId); + else + macros_[macroId] = text; + saveCharacterConfig(); +} + void GameHandler::saveCharacterConfig() { const Character* ch = getActiveCharacter(); if (!ch || ch->name.empty()) return; @@ -16934,6 +25034,21 @@ void GameHandler::saveCharacterConfig() { out << "action_bar_" << i << "_id=" << actionBar[i].id << "\n"; } + // Save client-side macro text (escape newlines as \n literal) + for (const auto& [id, text] : macros_) { + if (!text.empty()) { + std::string escaped; + escaped.reserve(text.size()); + for (char c : text) { + if (c == '\n') { escaped += "\\n"; } + else if (c == '\r') { /* skip CR */ } + else if (c == '\\') { escaped += "\\\\"; } + else { escaped += c; } + } + out << "macro_" << id << "_text=" << escaped << "\n"; + } + } + // Save quest log out << "quest_log_count=" << questLog_.size() << "\n"; for (size_t i = 0; i < questLog_.size(); i++) { @@ -16943,6 +25058,16 @@ void GameHandler::saveCharacterConfig() { out << "quest_" << i << "_complete=" << (quest.complete ? 1 : 0) << "\n"; } + // Save tracked quest IDs so the quest tracker restores on login + if (!trackedQuestIds_.empty()) { + std::string ids; + for (uint32_t qid : trackedQuestIds_) { + if (!ids.empty()) ids += ','; + ids += std::to_string(qid); + } + out << "tracked_quests=" << ids << "\n"; + } + LOG_INFO("Character config saved to ", path); } @@ -16974,6 +25099,43 @@ void GameHandler::loadCharacterConfig() { try { savedGender = std::stoi(val); } catch (...) {} } else if (key == "use_female_model") { try { savedUseFemaleModel = std::stoi(val); } catch (...) {} + } else if (key.rfind("macro_", 0) == 0) { + // Parse macro_N_text + size_t firstUnder = 6; // length of "macro_" + size_t secondUnder = key.find('_', firstUnder); + if (secondUnder == std::string::npos) continue; + uint32_t macroId = 0; + try { macroId = static_cast(std::stoul(key.substr(firstUnder, secondUnder - firstUnder))); } catch (...) { continue; } + if (key.substr(secondUnder + 1) == "text" && !val.empty()) { + // Unescape \n and \\ sequences + std::string unescaped; + unescaped.reserve(val.size()); + for (size_t i = 0; i < val.size(); ++i) { + if (val[i] == '\\' && i + 1 < val.size()) { + if (val[i+1] == 'n') { unescaped += '\n'; ++i; } + else if (val[i+1] == '\\') { unescaped += '\\'; ++i; } + else { unescaped += val[i]; } + } else { + unescaped += val[i]; + } + } + macros_[macroId] = std::move(unescaped); + } + } else if (key == "tracked_quests" && !val.empty()) { + // Parse comma-separated quest IDs + trackedQuestIds_.clear(); + size_t tqPos = 0; + while (tqPos <= val.size()) { + size_t comma = val.find(',', tqPos); + std::string idStr = (comma != std::string::npos) + ? val.substr(tqPos, comma - tqPos) : val.substr(tqPos); + try { + uint32_t qid = static_cast(std::stoul(idStr)); + if (qid != 0) trackedQuestIds_.insert(qid); + } catch (...) {} + if (comma == std::string::npos) break; + tqPos = comma + 1; + } } else if (key.rfind("action_bar_", 0) == 0) { // Parse action_bar_N_type or action_bar_N_id size_t firstUnderscore = 11; // length of "action_bar_" @@ -17116,11 +25278,13 @@ void GameHandler::updateAttachedTransportChildren(float /*deltaTime*/) { // ============================================================ void GameHandler::closeMailbox() { + bool wasOpen = mailboxOpen_; mailboxOpen_ = false; mailboxGuid_ = 0; mailInbox_.clear(); selectedMailIndex_ = -1; showMailCompose_ = false; + if (wasOpen && addonEventCallback_) addonEventCallback_("MAIL_CLOSED", {}); } void GameHandler::refreshMailList() { @@ -17259,9 +25423,9 @@ void GameHandler::mailTakeMoney(uint32_t mailId) { socket->send(packet); } -void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemIndex) { +void GameHandler::mailTakeItem(uint32_t mailId, uint32_t itemGuidLow) { if (state != WorldState::IN_WORLD || !socket || mailboxGuid_ == 0) return; - auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemIndex); + auto packet = packetParsers_->buildMailTakeItem(mailboxGuid_, mailId, itemGuidLow); socket->send(packet); } @@ -17297,6 +25461,7 @@ void GameHandler::handleShowMailbox(network::Packet& packet) { hasNewMail_ = false; selectedMailIndex_ = -1; showMailCompose_ = false; + if (addonEventCallback_) addonEventCallback_("MAIL_SHOW", {}); // Request inbox contents refreshMailList(); } @@ -17337,6 +25502,7 @@ void GameHandler::handleMailListResult(network::Packet& packet) { selectedMailIndex_ = -1; showMailCompose_ = false; } + if (addonEventCallback_) addonEventCallback_("MAIL_INBOX_UPDATE", {}); } void GameHandler::handleSendMailResult(network::Packet& packet) { @@ -17411,6 +25577,7 @@ void GameHandler::handleReceivedMail(network::Packet& packet) { LOG_INFO("SMSG_RECEIVED_MAIL: New mail arrived!"); hasNewMail_ = true; addSystemChatMessage("New mail has arrived."); + if (addonEventCallback_) addonEventCallback_("UPDATE_PENDING_MAIL", {}); // If mailbox is open, refresh if (mailboxOpen_) { refreshMailList(); @@ -17455,8 +25622,10 @@ void GameHandler::openBank(uint64_t guid) { } void GameHandler::closeBank() { + bool wasOpen = bankOpen_; bankOpen_ = false; bankerGuid_ = 0; + if (wasOpen && addonEventCallback_) addonEventCallback_("BANKFRAME_CLOSED", {}); } void GameHandler::buyBankSlot() { @@ -17487,6 +25656,7 @@ void GameHandler::handleShowBank(network::Packet& packet) { bankerGuid_ = packet.readUInt64(); bankOpen_ = true; gossipWindowOpen = false; // Close gossip when bank opens + if (addonEventCallback_) addonEventCallback_("BANKFRAME_OPENED", {}); // Bank items are already tracked via update fields (bank slot GUIDs) // Trigger rebuild to populate bank slots in inventory rebuildOnlineInventory(); @@ -17605,8 +25775,10 @@ void GameHandler::openAuctionHouse(uint64_t guid) { } void GameHandler::closeAuctionHouse() { + bool wasOpen = auctionOpen_; auctionOpen_ = false; auctioneerGuid_ = 0; + if (wasOpen && addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_CLOSED", {}); } void GameHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8_t levelMax, @@ -17687,6 +25859,7 @@ void GameHandler::handleAuctionHello(network::Packet& packet) { auctionHouseId_ = data.auctionHouseId; auctionOpen_ = true; gossipWindowOpen = false; // Close gossip when auction house opens + if (addonEventCallback_) addonEventCallback_("AUCTION_HOUSE_SHOW", {}); auctionActiveTab_ = 0; auctionBrowseResults_ = AuctionListResult{}; auctionOwnerResults_ = AuctionListResult{}; @@ -17772,6 +25945,7 @@ void GameHandler::handleAuctionCommandResult(network::Packet& packet) { "DB error", "Restricted account"}; const char* errName = (result.errorCode < 9) ? errors[result.errorCode] : "Unknown"; std::string msg = std::string("Auction ") + actionName + " failed: " + errName; + addUIError(msg); addSystemChatMessage(msg); } LOG_INFO("SMSG_AUCTION_COMMAND_RESULT: action=", actionName, @@ -17825,6 +25999,11 @@ void GameHandler::handleQuestConfirmAccept(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { sharedQuestSharerName_ = unit->getName(); } + if (sharedQuestSharerName_.empty()) { + auto nit = playerNameCache.find(sharedQuestSharerGuid_); + if (nit != playerNameCache.end()) + sharedQuestSharerName_ = nit->second; + } if (sharedQuestSharerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -17862,7 +26041,7 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 16) return; summonerGuid_ = packet.readUInt64(); - /*uint32_t zoneId =*/ packet.readUInt32(); + uint32_t zoneId = packet.readUInt32(); uint32_t timeoutMs = packet.readUInt32(); summonTimeoutSec_ = timeoutMs / 1000.0f; pendingSummonRequest_= true; @@ -17872,6 +26051,11 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { summonerName_ = unit->getName(); } + if (summonerName_.empty()) { + auto nit = playerNameCache.find(summonerGuid_); + if (nit != playerNameCache.end()) + summonerName_ = nit->second; + } if (summonerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -17879,9 +26063,16 @@ void GameHandler::handleSummonRequest(network::Packet& packet) { summonerName_ = tmp; } - addSystemChatMessage(summonerName_ + " is summoning you."); + std::string msg = summonerName_ + " is summoning you"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + msg += " to " + zoneName; + msg += '.'; + addSystemChatMessage(msg); LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", summonerName_, - " timeout=", summonTimeoutSec_, "s"); + " zoneId=", zoneId, " timeout=", summonTimeoutSec_, "s"); + if (addonEventCallback_) + addonEventCallback_("CONFIRM_SUMMON", {}); } void GameHandler::acceptSummon() { @@ -17927,6 +26118,11 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { tradePeerName_ = unit->getName(); } + if (tradePeerName_.empty()) { + auto nit = playerNameCache.find(tradePeerGuid_); + if (nit != playerNameCache.end()) + tradePeerName_ = nit->second; + } if (tradePeerName_.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -17935,26 +26131,38 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { } tradeStatus_ = TradeStatus::PendingIncoming; addSystemChatMessage(tradePeerName_ + " wants to trade with you."); + if (addonEventCallback_) addonEventCallback_("TRADE_REQUEST", {}); break; } case 2: // OPEN_WINDOW + myTradeSlots_.fill(TradeSlot{}); + peerTradeSlots_.fill(TradeSlot{}); + myTradeGold_ = 0; + peerTradeGold_ = 0; tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); + if (addonEventCallback_) addonEventCallback_("TRADE_SHOW", {}); break; - case 3: // CANCELLED - case 9: // REJECTED + case 3: // CANCELLED case 12: // CLOSE_WINDOW - tradeStatus_ = TradeStatus::None; + resetTradeState(); addSystemChatMessage("Trade cancelled."); + if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); + break; + case 9: // REJECTED — other player clicked Decline + resetTradeState(); + addSystemChatMessage("Trade declined."); + if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); break; case 4: // ACCEPTED (partner accepted) tradeStatus_ = TradeStatus::Accepted; addSystemChatMessage("Trade accepted. Awaiting other player..."); + if (addonEventCallback_) addonEventCallback_("TRADE_ACCEPT_UPDATE", {}); break; case 8: // COMPLETE - tradeStatus_ = TradeStatus::Complete; addSystemChatMessage("Trade complete!"); - tradeStatus_ = TradeStatus::None; // reset after notification + if (addonEventCallback_) addonEventCallback_("TRADE_CLOSED", {}); + resetTradeState(); break; case 7: // BACK_TO_TRADE (unaccepted after a change) tradeStatus_ = TradeStatus::Open; @@ -17986,34 +26194,137 @@ void GameHandler::declineTradeRequest() { } void GameHandler::acceptTrade() { - if (tradeStatus_ != TradeStatus::Open || !socket) return; + if (!isTradeOpen() || !socket) return; tradeStatus_ = TradeStatus::Accepted; socket->send(AcceptTradePacket::build()); } void GameHandler::cancelTrade() { if (!socket) return; - tradeStatus_ = TradeStatus::None; + resetTradeState(); socket->send(CancelTradePacket::build()); } +void GameHandler::setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; + socket->send(SetTradeItemPacket::build(tradeSlot, bag, bagSlot)); +} + +void GameHandler::clearTradeItem(uint8_t tradeSlot) { + if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; + myTradeSlots_[tradeSlot] = TradeSlot{}; + socket->send(ClearTradeItemPacket::build(tradeSlot)); +} + +void GameHandler::setTradeGold(uint64_t copper) { + if (!isTradeOpen() || !socket) return; + myTradeGold_ = copper; + socket->send(SetTradeGoldPacket::build(copper)); +} + +void GameHandler::resetTradeState() { + tradeStatus_ = TradeStatus::None; + myTradeGold_ = 0; + peerTradeGold_ = 0; + myTradeSlots_.fill(TradeSlot{}); + peerTradeSlots_.fill(TradeSlot{}); +} + +void GameHandler::handleTradeStatusExtended(network::Packet& packet) { + // SMSG_TRADE_STATUS_EXTENDED format differs by expansion: + // + // Classic/TBC: + // uint8 isSelf + uint32 slotCount + [slots] + uint64 coins + // Per slot tail (after isWrapped): giftCreatorGuid(8) + enchants(24) + + // randomPropertyId(4) + suffixFactor(4) + durability(4) + maxDurability(4) = 48 bytes + // + // WotLK 3.3.5a adds: + // uint32 tradeId (after isSelf, before slotCount) + // Per slot: + createPlayedTime(4) at end of trail → trail = 52 bytes + // + // Minimum: isSelf(1) + [tradeId(4)] + slotCount(4) = 5 or 9 bytes + const bool isWotLK = isActiveExpansion("wotlk"); + size_t minHdr = isWotLK ? 9u : 5u; + if (packet.getSize() - packet.getReadPos() < minHdr) return; + + uint8_t isSelf = packet.readUInt8(); + if (isWotLK) { + /*uint32_t tradeId =*/ packet.readUInt32(); // WotLK-only field + } + uint32_t slotCount = packet.readUInt32(); + + // Per-slot tail bytes after isWrapped: + // Classic/TBC: giftCreatorGuid(8) + enchants(24) + stats(16) = 48 + // WotLK: same + createPlayedTime(4) = 52 + const size_t SLOT_TRAIL = isWotLK ? 52u : 48u; + + auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_; + + for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) { + uint8_t slotIdx = packet.readUInt8(); + uint32_t itemId = packet.readUInt32(); + uint32_t displayId = packet.readUInt32(); + uint32_t stackCount = packet.readUInt32(); + + bool isWrapped = false; + if (packet.getSize() - packet.getReadPos() >= 1) { + isWrapped = (packet.readUInt8() != 0); + } + if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { + packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); + } else { + packet.setReadPos(packet.getSize()); + return; + } + (void)isWrapped; + + if (slotIdx < TRADE_SLOT_COUNT) { + TradeSlot& s = slots[slotIdx]; + s.itemId = itemId; + s.displayId = displayId; + s.stackCount = stackCount; + s.occupied = (itemId != 0); + } + } + + // Gold offered (uint64 copper) + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t coins = packet.readUInt64(); + if (isSelf) myTradeGold_ = coins; + else peerTradeGold_ = coins; + } + + // Prefetch item info for all occupied trade slots so names show immediately + for (const auto& s : slots) { + if (s.occupied && s.itemId != 0) queryItemInfo(s.itemId, 0); + } + + LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf, + " myGold=", myTradeGold_, " peerGold=", peerTradeGold_); +} + // --------------------------------------------------------------------------- // Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL) // --------------------------------------------------------------------------- void GameHandler::handleLootRoll(network::Packet& packet) { - // uint64 objectGuid, uint32 slot, uint64 playerGuid, - // uint32 itemId, uint32 randomSuffix, uint32 randomPropId, - // uint8 rollNumber, uint8 rollType + // WotLK 3.3.5a: uint64 objectGuid, uint32 slot, uint64 playerGuid, + // uint32 itemId, uint32 randomSuffix, uint32 randomPropId, uint8 rollNumber, uint8 rollType (34 bytes) + // Classic/TBC: uint64 objectGuid, uint32 slot, uint64 playerGuid, + // uint32 itemId, uint8 rollNumber, uint8 rollType (26 bytes) + const bool isWotLK = isActiveExpansion("wotlk"); + const size_t minSize = isWotLK ? 34u : 26u; size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 26) return; // minimum: 8+4+8+4+4+4+1+1 = 34, be lenient + if (rem < minSize) return; uint64_t objectGuid = packet.readUInt64(); uint32_t slot = packet.readUInt32(); uint64_t rollerGuid = packet.readUInt64(); uint32_t itemId = packet.readUInt32(); - /*uint32_t randSuffix =*/ packet.readUInt32(); - /*uint32_t randProp =*/ packet.readUInt32(); + if (isWotLK) { + /*uint32_t randSuffix =*/ packet.readUInt32(); + /*uint32_t randProp =*/ packet.readUInt32(); + } uint8_t rollNum = packet.readUInt8(); uint8_t rollType = packet.readUInt8(); @@ -18024,10 +26335,15 @@ void GameHandler::handleLootRoll(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + pendingLootRoll_.playerRolls.clear(); + // Ensure item info is in cache; query if not + queryItemInfo(itemId, 0); // Look up item name from cache auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); pendingLootRoll_.itemQuality = info ? static_cast(info->quality) : 0; + pendingLootRoll_.rollCountdownMs = 60000; + pendingLootRoll_.rollStartedAt = std::chrono::steady_clock::now(); LOG_INFO("SMSG_LOOT_ROLL: need/greed prompt for item=", itemId, " (", pendingLootRoll_.itemName, ") slot=", slot); return; @@ -18044,13 +26360,34 @@ void GameHandler::handleLootRoll(network::Packet& packet) { } if (rollerName.empty()) rollerName = "Someone"; - auto* info = getItemInfo(itemId); - std::string iName = info ? info->name : std::to_string(itemId); + // Track in the live roll list while our popup is open for the same item + if (pendingLootRollActive_ && + pendingLootRoll_.objectGuid == objectGuid && + pendingLootRoll_.slot == slot) { + bool found = false; + for (auto& r : pendingLootRoll_.playerRolls) { + if (r.playerName == rollerName) { + r.rollNum = rollNum; + r.rollType = rollType; + found = true; + break; + } + } + if (!found) { + LootRollEntry::PlayerRollResult prr; + prr.playerName = rollerName; + prr.rollNum = rollNum; + prr.rollType = rollType; + pendingLootRoll_.playerRolls.push_back(std::move(prr)); + } + } - char buf[256]; - std::snprintf(buf, sizeof(buf), "%s rolls %s (%d) on [%s]", - rollerName.c_str(), rollName, static_cast(rollNum), iName.c_str()); - addSystemChatMessage(buf); + auto* info = getItemInfo(itemId); + std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId); + uint32_t rollItemQuality = info ? info->quality : 1u; + std::string rollItemLink = buildItemLink(itemId, rollItemQuality, iName); + + addSystemChatMessage(rollerName + " rolls " + rollName + " (" + std::to_string(rollNum) + ") on " + rollItemLink); LOG_DEBUG("SMSG_LOOT_ROLL: ", rollerName, " rolled ", rollName, " (", rollNum, ") on item ", itemId); @@ -18058,15 +26395,24 @@ void GameHandler::handleLootRoll(network::Packet& packet) { } void GameHandler::handleLootRollWon(network::Packet& packet) { + // WotLK 3.3.5a: uint64 objectGuid, uint32 slot, uint64 winnerGuid, + // uint32 itemId, uint32 randomSuffix, uint32 randomPropId, uint8 rollNumber, uint8 rollType (34 bytes) + // Classic/TBC: uint64 objectGuid, uint32 slot, uint64 winnerGuid, + // uint32 itemId, uint8 rollNumber, uint8 rollType (26 bytes) + const bool isWotLK = isActiveExpansion("wotlk"); + const size_t minSize = isWotLK ? 34u : 26u; size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 26) return; + if (rem < minSize) return; /*uint64_t objectGuid =*/ packet.readUInt64(); /*uint32_t slot =*/ packet.readUInt32(); uint64_t winnerGuid = packet.readUInt64(); uint32_t itemId = packet.readUInt32(); - /*uint32_t randSuffix =*/ packet.readUInt32(); - /*uint32_t randProp =*/ packet.readUInt32(); + int32_t wonRandProp = 0; + if (isWotLK) { + /*uint32_t randSuffix =*/ packet.readUInt32(); + wonRandProp = static_cast(packet.readUInt32()); + } uint8_t rollNum = packet.readUInt8(); uint8_t rollType = packet.readUInt8(); @@ -18083,17 +26429,18 @@ void GameHandler::handleLootRollWon(network::Packet& packet) { } auto* info = getItemInfo(itemId); - std::string iName = info ? info->name : std::to_string(itemId); - - char buf[256]; - std::snprintf(buf, sizeof(buf), "%s wins [%s] (%s %d)!", - winnerName.c_str(), iName.c_str(), rollName, static_cast(rollNum)); - addSystemChatMessage(buf); - - // Clear pending roll if it was ours - if (pendingLootRollActive_ && winnerGuid == playerGuid) { - pendingLootRollActive_ = false; + std::string iName = info && !info->name.empty() ? info->name : std::to_string(itemId); + if (wonRandProp != 0) { + std::string suffix = getRandomPropertyName(wonRandProp); + if (!suffix.empty()) iName += " " + suffix; } + uint32_t wonItemQuality = info ? info->quality : 1u; + std::string wonItemLink = buildItemLink(itemId, wonItemQuality, iName); + + addSystemChatMessage(winnerName + " wins " + wonItemLink + " (" + rollName + " " + std::to_string(rollNum) + ")!"); + + // Dismiss roll popup — roll contest is over regardless of who won + pendingLootRollActive_ = false; LOG_INFO("SMSG_LOOT_ROLL_WON: winner=", winnerName, " item=", itemId, " roll=", rollName, "(", rollNum, ")"); } @@ -18120,6 +26467,58 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT // PackedTime date — uint32 bitfield (seconds since epoch) // uint32 realmFirst — how many on realm also got it (0 = realm first) // --------------------------------------------------------------------------- +void GameHandler::loadTitleNameCache() { + if (titleNameCacheLoaded_) return; + titleNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("CharTitles.dbc"); + if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 5) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("CharTitles") : nullptr; + + uint32_t titleField = layout ? layout->field("Title") : 2; + uint32_t bitField = layout ? layout->field("TitleBit") : 36; + if (titleField == 0xFFFFFFFF) titleField = 2; + if (bitField == 0xFFFFFFFF) bitField = static_cast(dbc->getFieldCount() - 1); + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t bit = dbc->getUInt32(i, bitField); + if (bit == 0) continue; + std::string name = dbc->getString(i, titleField); + if (!name.empty()) titleNameCache_[bit] = std::move(name); + } + LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC"); +} + +std::string GameHandler::getFormattedTitle(uint32_t bit) const { + const_cast(this)->loadTitleNameCache(); + auto it = titleNameCache_.find(bit); + if (it == titleNameCache_.end() || it->second.empty()) return {}; + + static const std::string kUnknown = "unknown"; + auto nameIt = playerNameCache.find(playerGuid); + const std::string& pName = (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown; + + const std::string& fmt = it->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) { + return fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + } + return fmt; +} + +void GameHandler::sendSetTitle(int32_t bit) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = SetTitlePacket::build(bit); + socket->send(packet); + chosenTitleBit_ = bit; + LOG_INFO("sendSetTitle: bit=", bit); +} + void GameHandler::loadAchievementNameCache() { if (achievementNameCacheLoaded_) return; achievementNameCacheLoaded_ = true; @@ -18134,12 +26533,23 @@ void GameHandler::loadAchievementNameCache() { ? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr; uint32_t titleField = achL ? achL->field("Title") : 4; if (titleField == 0xFFFFFFFF) titleField = 4; + uint32_t descField = achL ? achL->field("Description") : 0xFFFFFFFF; + uint32_t ptsField = achL ? achL->field("Points") : 0xFFFFFFFF; + uint32_t fieldCount = dbc->getFieldCount(); for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { uint32_t id = dbc->getUInt32(i, 0); if (id == 0) continue; std::string title = dbc->getString(i, titleField); if (!title.empty()) achievementNameCache_[id] = std::move(title); + if (descField != 0xFFFFFFFF && descField < fieldCount) { + std::string desc = dbc->getString(i, descField); + if (!desc.empty()) achievementDescCache_[id] = std::move(desc); + } + if (ptsField != 0xFFFFFFFF && ptsField < fieldCount) { + uint32_t pts = dbc->getUInt32(i, ptsField); + if (pts > 0) achievementPointsCache_[id] = pts; + } } LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc"); } @@ -18150,7 +26560,7 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { uint64_t guid = packet.readUInt64(); uint32_t achievementId = packet.readUInt32(); - /*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed + uint32_t earnDate = packet.readUInt32(); // WoW PackedTime bitfield loadAchievementNameCache(); auto nameIt = achievementNameCache_.find(achievementId); @@ -18168,8 +26578,14 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { } addSystemChatMessage(buf); + earnedAchievements_.insert(achievementId); + achievementDates_[achievementId] = earnDate; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* sfx = renderer->getUiSoundManager()) + sfx->playAchievementAlert(); + } if (achievementEarnedCallback_) { - achievementEarnedCallback_(achievementId); + achievementEarnedCallback_(achievementId, achName); } } else { // Another player in the zone earned an achievement @@ -18178,6 +26594,11 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { if (auto* unit = dynamic_cast(entity.get())) { senderName = unit->getName(); } + if (senderName.empty()) { + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) + senderName = nit->second; + } if (senderName.empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", @@ -18198,6 +26619,94 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec, " achievementId=", achievementId, " self=", isSelf, achName.empty() ? "" : " name=", achName); + if (addonEventCallback_) + addonEventCallback_("ACHIEVEMENT_EARNED", {std::to_string(achievementId)}); +} + +// --------------------------------------------------------------------------- +// SMSG_ALL_ACHIEVEMENT_DATA (WotLK 3.3.5a) +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 packedDate, ... } until 0xFFFFFFFF +// --------------------------------------------------------------------------- +void GameHandler::handleAllAchievementData(network::Packet& packet) { + loadAchievementNameCache(); + earnedAchievements_.clear(); + achievementDates_.clear(); + + // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t date = packet.readUInt32(); + earnedAchievements_.insert(id); + achievementDates_[id] = date; + } + + // Parse criteria block: id + uint64 counter + uint32 date + uint32 flags, sentinel 0xFFFFFFFF + criteriaProgress_.clear(); + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unknown(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + uint64_t counter = packet.readUInt64(); + packet.readUInt32(); // date + packet.readUInt32(); // unknown / flags + criteriaProgress_[id] = counter; + } + + LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), + " achievements, ", criteriaProgress_.size(), " criteria"); +} + +// --------------------------------------------------------------------------- +// SMSG_RESPOND_INSPECT_ACHIEVEMENTS (WotLK 3.3.5a) +// Wire format: packed_guid (inspected player) + same achievement/criteria +// blocks as SMSG_ALL_ACHIEVEMENT_DATA: +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 date, uint32 unk } +// until 0xFFFFFFFF sentinel +// We store only the earned achievement IDs (not criteria) per inspected player. +// --------------------------------------------------------------------------- +void GameHandler::handleRespondInspectAchievements(network::Packet& packet) { + loadAchievementNameCache(); + + // Read the inspected player's packed guid + if (packet.getSize() - packet.getReadPos() < 1) return; + uint64_t inspectedGuid = UpdateObjectParser::readPackedGuid(packet); + if (inspectedGuid == 0) { + packet.setReadPos(packet.getSize()); + return; + } + + std::unordered_set achievements; + + // Achievement records: { uint32 id, uint32 packedDate } until sentinel 0xFFFFFFFF + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t date =*/ packet.readUInt32(); + achievements.insert(id); + } + + // Criteria records: { uint32 id, uint64 counter, uint32 date, uint32 unk } + // until sentinel 0xFFFFFFFF — consume but don't store for inspect use + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unk(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + packet.readUInt64(); // counter + packet.readUInt32(); // date + packet.readUInt32(); // unk + } + + inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements); + + LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec, + " achievements=", inspectedPlayerAchievements_[inspectedGuid].size()); } // --------------------------------------------------------------------------- @@ -18216,32 +26725,75 @@ void GameHandler::loadFactionNameCache() { // Faction.dbc WotLK 3.3.5a field layout: // 0: ID - // 1-4: ReputationRaceMask[4] - // 5-8: ReputationClassMask[4] - // 9-12: ReputationBase[4] - // 13-16: ReputationFlags[4] - // 17: ParentFactionID - // 18-19: Spillover rates (floats) - // 20-21: MaxRank - // 22: Name (English locale, string ref) - constexpr uint32_t ID_FIELD = 0; - constexpr uint32_t NAME_FIELD = 22; // enUS name string + // 1: ReputationListID (-1 / 0xFFFFFFFF = no reputation tracking) + // 2-5: ReputationRaceMask[4] + // 6-9: ReputationClassMask[4] + // 10-13: ReputationBase[4] + // 14-17: ReputationFlags[4] + // 18: ParentFactionID + // 19-20: SpilloverRateIn, SpilloverRateOut (floats) + // 21-22: SpilloverMaxRankIn, SpilloverMaxRankOut + // 23: Name (English locale, string ref) + constexpr uint32_t ID_FIELD = 0; + constexpr uint32_t REPLIST_FIELD = 1; + constexpr uint32_t NAME_FIELD = 23; // enUS name string + // Classic/TBC have fewer fields; fall back gracefully + const bool hasRepListField = dbc->getFieldCount() > REPLIST_FIELD; if (dbc->getFieldCount() <= NAME_FIELD) { LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount()); - return; + // Don't abort — still try to load names from a shorter layout } + const uint32_t nameField = (dbc->getFieldCount() > NAME_FIELD) ? NAME_FIELD : 22u; uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t factionId = dbc->getUInt32(i, ID_FIELD); if (factionId == 0) continue; - std::string name = dbc->getString(i, NAME_FIELD); - if (!name.empty()) { - factionNameCache_[factionId] = std::move(name); + if (dbc->getFieldCount() > nameField) { + std::string name = dbc->getString(i, nameField); + if (!name.empty()) { + factionNameCache_[factionId] = std::move(name); + } + } + // Build repListId ↔ factionId mapping (WotLK field 1) + if (hasRepListField) { + uint32_t repListId = dbc->getUInt32(i, REPLIST_FIELD); + if (repListId != 0xFFFFFFFFu) { + factionRepListToId_[repListId] = factionId; + factionIdToRepList_[factionId] = repListId; + } } } - LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names"); + LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names, ", + factionRepListToId_.size(), " with reputation tracking"); +} + +uint32_t GameHandler::getFactionIdByRepListId(uint32_t repListId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionRepListToId_.find(repListId); + return (it != factionRepListToId_.end()) ? it->second : 0u; +} + +uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionIdToRepList_.find(factionId); + return (it != factionIdToRepList_.end()) ? it->second : 0xFFFFFFFFu; +} + +void GameHandler::setWatchedFactionId(uint32_t factionId) { + watchedFactionId_ = factionId; + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_SET_WATCHED_FACTION: int32 repListId (-1 = unwatch) + int32_t repListId = -1; + if (factionId != 0) { + uint32_t rl = getRepListIdByFactionId(factionId); + if (rl != 0xFFFFFFFFu) repListId = static_cast(rl); + } + network::Packet pkt(wireOpcode(Opcode::CMSG_SET_WATCHED_FACTION)); + pkt.writeUInt32(static_cast(repListId)); + socket->send(pkt); + LOG_DEBUG("CMSG_SET_WATCHED_FACTION: repListId=", repListId, " (factionId=", factionId, ")"); } std::string GameHandler::getFactionName(uint32_t factionId) const { @@ -18258,6 +26810,110 @@ 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(this)->loadAreaNameCache(); + auto it = areaNameCache_.find(areaId); + return (it != areaNameCache_.end()) ? it->second : std::string{}; +} + +void GameHandler::loadMapNameCache() { + if (mapNameCacheLoaded_) return; + mapNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Map.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + // Field 2 = MapName_enUS (first localized); field 1 = InternalName fallback + std::string name = dbc->getString(i, 2); + if (name.empty()) name = dbc->getString(i, 1); + if (!name.empty() && !mapNameCache_.count(id)) { + mapNameCache_[id] = std::move(name); + } + } + LOG_INFO("Map.dbc: loaded ", mapNameCache_.size(), " map names"); +} + +std::string GameHandler::getMapName(uint32_t mapId) const { + if (mapId == 0) return {}; + const_cast(this)->loadMapNameCache(); + auto it = mapNameCache_.find(mapId); + return (it != mapNameCache_.end()) ? it->second : std::string{}; +} + +// --------------------------------------------------------------------------- +// LFG dungeon name cache (WotLK: LFGDungeons.dbc) +// --------------------------------------------------------------------------- + +void GameHandler::loadLfgDungeonDbc() { + if (lfgDungeonNameCacheLoaded_) return; + lfgDungeonNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("LFGDungeons.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("LFGDungeons") : nullptr; + const uint32_t idField = layout ? (*layout)["ID"] : 0; + const uint32_t nameField = layout ? (*layout)["Name"] : 1; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string name = dbc->getString(i, nameField); + if (!name.empty()) + lfgDungeonNameCache_[id] = std::move(name); + } + LOG_INFO("LFGDungeons.dbc: loaded ", lfgDungeonNameCache_.size(), " dungeon names"); +} + +std::string GameHandler::getLfgDungeonName(uint32_t dungeonId) const { + if (dungeonId == 0) return {}; + const_cast(this)->loadLfgDungeonDbc(); + auto it = lfgDungeonNameCache_.find(dungeonId); + return (it != lfgDungeonNameCache_.end()) ? it->second : std::string{}; +} + // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- @@ -18299,6 +26955,17 @@ void GameHandler::handleEquipmentSetList(network::Packet& packet) { } equipmentSets_.push_back(std::move(es)); } + // Populate public-facing info + equipmentSetInfo_.clear(); + equipmentSetInfo_.reserve(equipmentSets_.size()); + for (const auto& es : equipmentSets_) { + EquipmentSetInfo info; + info.setGuid = es.setGuid; + info.setId = es.setId; + info.name = es.name; + info.iconName = es.iconName; + equipmentSetInfo_.push_back(std::move(info)); + } LOG_INFO("SMSG_EQUIPMENT_SET_LIST: ", equipmentSets_.size(), " equipment sets received"); } @@ -18324,5 +26991,40 @@ void GameHandler::handleSetForcedReactions(network::Packet& packet) { LOG_INFO("SMSG_SET_FORCED_REACTIONS: ", forcedReactions_.size(), " faction overrides"); } +// ---- Battlefield Manager (WotLK Wintergrasp / outdoor battlefields) ---- + +void GameHandler::acceptBfMgrInvite() { + if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; + // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 1 + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); + pkt.writeUInt8(1); // accepted + socket->send(pkt); + bfMgrInvitePending_ = false; + LOG_INFO("acceptBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=1"); +} + +void GameHandler::declineBfMgrInvite() { + if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; + // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 0 + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); + pkt.writeUInt8(0); // declined + socket->send(pkt); + bfMgrInvitePending_ = false; + LOG_INFO("declineBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=0"); +} + +// ---- WotLK Calendar ---- + +void GameHandler::requestCalendar() { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_CALENDAR_GET_CALENDAR has no payload + network::Packet pkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_CALENDAR)); + socket->send(pkt); + LOG_INFO("requestCalendar: sent CMSG_CALENDAR_GET_CALENDAR"); + // Also request pending invite count + network::Packet numPkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_NUM_PENDING)); + socket->send(numPkt); +} + } // namespace game } // namespace wowee diff --git a/src/game/inventory.cpp b/src/game/inventory.cpp index 1750253a..a6de6dcb 100644 --- a/src/game/inventory.cpp +++ b/src/game/inventory.cpp @@ -1,5 +1,6 @@ #include "game/inventory.hpp" #include "core/logger.hpp" +#include namespace wowee { namespace game { @@ -45,6 +46,23 @@ bool Inventory::clearEquipSlot(EquipSlot slot) { return true; } +const ItemSlot& Inventory::getKeyringSlot(int index) const { + if (index < 0 || index >= KEYRING_SLOTS) return EMPTY_SLOT; + return keyring_[index]; +} + +bool Inventory::setKeyringSlot(int index, const ItemDef& item) { + if (index < 0 || index >= KEYRING_SLOTS) return false; + keyring_[index].item = item; + return true; +} + +bool Inventory::clearKeyringSlot(int index) { + if (index < 0 || index >= KEYRING_SLOTS) return false; + keyring_[index].item = ItemDef{}; + return true; +} + int Inventory::getBagSize(int bagIndex) const { if (bagIndex < 0 || bagIndex >= NUM_BAG_SLOTS) return 0; return bags[bagIndex].size; @@ -168,6 +186,44 @@ bool Inventory::addItem(const ItemDef& item) { return true; } +void Inventory::sortBags() { + // Collect all items from backpack and equip bags into a flat list. + std::vector items; + items.reserve(BACKPACK_SLOTS + NUM_BAG_SLOTS * MAX_BAG_SIZE); + + for (int i = 0; i < BACKPACK_SLOTS; ++i) { + if (!backpack[i].empty()) + items.push_back(backpack[i].item); + } + for (int b = 0; b < NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < bags[b].size; ++s) { + if (!bags[b].slots[s].empty()) + items.push_back(bags[b].slots[s].item); + } + } + + // Sort: quality descending → itemId ascending → stackCount descending. + std::stable_sort(items.begin(), items.end(), [](const ItemDef& a, const ItemDef& b) { + if (a.quality != b.quality) + return static_cast(a.quality) > static_cast(b.quality); + if (a.itemId != b.itemId) + return a.itemId < b.itemId; + return a.stackCount > b.stackCount; + }); + + // Write sorted items back, filling backpack first then equip bags. + int idx = 0; + int n = static_cast(items.size()); + + for (int i = 0; i < BACKPACK_SLOTS; ++i) + backpack[i].item = (idx < n) ? items[idx++] : ItemDef{}; + + for (int b = 0; b < NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < bags[b].size; ++s) + bags[b].slots[s].item = (idx < n) ? items[idx++] : ItemDef{}; + } +} + void Inventory::populateTestItems() { // Equipment { @@ -313,6 +369,8 @@ const char* getQualityName(ItemQuality quality) { case ItemQuality::RARE: return "Rare"; case ItemQuality::EPIC: return "Epic"; case ItemQuality::LEGENDARY: return "Legendary"; + case ItemQuality::ARTIFACT: return "Artifact"; + case ItemQuality::HEIRLOOM: return "Heirloom"; default: return "Unknown"; } } diff --git a/src/game/opcode_table.cpp b/src/game/opcode_table.cpp index 8178f0f5..ad9b639f 100644 --- a/src/game/opcode_table.cpp +++ b/src/game/opcode_table.cpp @@ -4,7 +4,9 @@ #include #include #include +#include #include +#include namespace wowee { namespace game { @@ -47,6 +49,155 @@ static std::string_view canonicalOpcodeName(std::string_view name) { return name; } +static std::optional resolveLogicalOpcodeIndex(std::string_view name) { + const std::string_view canonical = canonicalOpcodeName(name); + for (size_t i = 0; i < kOpcodeNameCount; ++i) { + if (canonical == kOpcodeNames[i].name) { + return static_cast(kOpcodeNames[i].op); + } + } + return std::nullopt; +} + +static std::optional parseStringField(const std::string& json, const char* fieldName) { + const std::string needle = std::string("\"") + fieldName + "\""; + size_t keyPos = json.find(needle); + if (keyPos == std::string::npos) return std::nullopt; + + size_t colon = json.find(':', keyPos + needle.size()); + if (colon == std::string::npos) return std::nullopt; + + size_t valueStart = json.find('"', colon + 1); + if (valueStart == std::string::npos) return std::nullopt; + size_t valueEnd = json.find('"', valueStart + 1); + if (valueEnd == std::string::npos) return std::nullopt; + return json.substr(valueStart + 1, valueEnd - valueStart - 1); +} + +static std::vector parseStringArrayField(const std::string& json, const char* fieldName) { + std::vector values; + const std::string needle = std::string("\"") + fieldName + "\""; + size_t keyPos = json.find(needle); + if (keyPos == std::string::npos) return values; + + size_t colon = json.find(':', keyPos + needle.size()); + if (colon == std::string::npos) return values; + + size_t arrayStart = json.find('[', colon + 1); + if (arrayStart == std::string::npos) return values; + size_t arrayEnd = json.find(']', arrayStart + 1); + if (arrayEnd == std::string::npos) return values; + + size_t pos = arrayStart + 1; + while (pos < arrayEnd) { + size_t valueStart = json.find('"', pos); + if (valueStart == std::string::npos || valueStart >= arrayEnd) break; + size_t valueEnd = json.find('"', valueStart + 1); + if (valueEnd == std::string::npos || valueEnd > arrayEnd) break; + values.push_back(json.substr(valueStart + 1, valueEnd - valueStart - 1)); + pos = valueEnd + 1; + } + return values; +} + +static bool loadOpcodeJsonRecursive(const std::filesystem::path& path, + std::unordered_map& logicalToWire, + std::unordered_map& wireToLogical, + std::unordered_set& loadingStack) { + const std::filesystem::path canonicalPath = std::filesystem::weakly_canonical(path); + const std::string canonicalKey = canonicalPath.string(); + if (!loadingStack.insert(canonicalKey).second) { + LOG_WARNING("OpcodeTable: inheritance cycle at ", canonicalKey); + return false; + } + + std::ifstream f(canonicalPath); + if (!f.is_open()) { + LOG_WARNING("OpcodeTable: cannot open ", canonicalPath.string()); + loadingStack.erase(canonicalKey); + return false; + } + + std::string json((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + bool ok = true; + + if (auto extends = parseStringField(json, "_extends")) { + ok = loadOpcodeJsonRecursive(canonicalPath.parent_path() / *extends, + logicalToWire, wireToLogical, loadingStack) && ok; + } + + for (const std::string& removeName : parseStringArrayField(json, "_remove")) { + auto logical = resolveLogicalOpcodeIndex(removeName); + if (!logical) continue; + auto it = logicalToWire.find(*logical); + if (it != logicalToWire.end()) { + const uint16_t oldWire = it->second; + logicalToWire.erase(it); + auto wireIt = wireToLogical.find(oldWire); + if (wireIt != wireToLogical.end() && wireIt->second == *logical) { + wireToLogical.erase(wireIt); + } + } + } + + size_t pos = 0; + while (pos < json.size()) { + size_t keyStart = json.find('"', pos); + if (keyStart == std::string::npos) break; + size_t keyEnd = json.find('"', keyStart + 1); + if (keyEnd == std::string::npos) break; + std::string key = json.substr(keyStart + 1, keyEnd - keyStart - 1); + + size_t colon = json.find(':', keyEnd); + if (colon == std::string::npos) break; + + size_t valStart = colon + 1; + while (valStart < json.size() && (json[valStart] == ' ' || json[valStart] == '\t' || + json[valStart] == '\r' || json[valStart] == '\n' || json[valStart] == '"')) + ++valStart; + + size_t valEnd = json.find_first_of(",}\"\r\n", valStart); + if (valEnd == std::string::npos) valEnd = json.size(); + std::string valStr = json.substr(valStart, valEnd - valStart); + + uint16_t wire = 0; + try { + if (valStr.size() > 2 && (valStr[0] == '0' && (valStr[1] == 'x' || valStr[1] == 'X'))) { + wire = static_cast(std::stoul(valStr, nullptr, 16)); + } else { + wire = static_cast(std::stoul(valStr)); + } + } catch (...) { + pos = valEnd + 1; + continue; + } + + auto logical = resolveLogicalOpcodeIndex(key); + if (logical) { + auto oldLogicalIt = logicalToWire.find(*logical); + if (oldLogicalIt != logicalToWire.end()) { + const uint16_t oldWire = oldLogicalIt->second; + auto oldWireIt = wireToLogical.find(oldWire); + if (oldWireIt != wireToLogical.end() && oldWireIt->second == *logical) { + wireToLogical.erase(oldWireIt); + } + } + auto oldWireIt = wireToLogical.find(wire); + if (oldWireIt != wireToLogical.end() && oldWireIt->second != *logical) { + logicalToWire.erase(oldWireIt->second); + wireToLogical.erase(oldWireIt); + } + logicalToWire[*logical] = wire; + wireToLogical[wire] = *logical; + } + + pos = valEnd + 1; + } + + loadingStack.erase(canonicalKey); + return ok; +} + std::optional OpcodeTable::nameToLogical(const std::string& name) { const std::string_view canonical = canonicalOpcodeName(name); for (size_t i = 0; i < kOpcodeNameCount; ++i) { @@ -64,73 +215,18 @@ const char* OpcodeTable::logicalToName(LogicalOpcode op) { } bool OpcodeTable::loadFromJson(const std::string& path) { - std::ifstream f(path); - if (!f.is_open()) { - LOG_WARNING("OpcodeTable: cannot open ", path, ", using defaults"); - return false; - } - - std::string json((std::istreambuf_iterator(f)), std::istreambuf_iterator()); - - // Start fresh — JSON is the single source of truth for opcode mappings. + // Start fresh — resolved JSON inheritance is the single source of truth for opcode mappings. logicalToWire_.clear(); wireToLogical_.clear(); - - // Parse simple JSON: { "NAME": "0xHEX", ... } or { "NAME": 123, ... } - size_t pos = 0; - size_t loaded = 0; - while (pos < json.size()) { - // Find next quoted key - size_t keyStart = json.find('"', pos); - if (keyStart == std::string::npos) break; - size_t keyEnd = json.find('"', keyStart + 1); - if (keyEnd == std::string::npos) break; - std::string key = json.substr(keyStart + 1, keyEnd - keyStart - 1); - - // Find colon then value - size_t colon = json.find(':', keyEnd); - if (colon == std::string::npos) break; - - // Skip whitespace - size_t valStart = colon + 1; - while (valStart < json.size() && (json[valStart] == ' ' || json[valStart] == '\t' || - json[valStart] == '\r' || json[valStart] == '\n' || json[valStart] == '"')) - ++valStart; - - size_t valEnd = json.find_first_of(",}\"\r\n", valStart); - if (valEnd == std::string::npos) valEnd = json.size(); - std::string valStr = json.substr(valStart, valEnd - valStart); - - // Parse hex or decimal value - uint16_t wire = 0; - try { - if (valStr.size() > 2 && (valStr[0] == '0' && (valStr[1] == 'x' || valStr[1] == 'X'))) { - wire = static_cast(std::stoul(valStr, nullptr, 16)); - } else { - wire = static_cast(std::stoul(valStr)); - } - } catch (...) { - pos = valEnd + 1; - continue; - } - - auto logOp = nameToLogical(key); - if (logOp) { - uint16_t logIdx = static_cast(*logOp); - logicalToWire_[logIdx] = wire; - wireToLogical_[wire] = logIdx; - ++loaded; - } - - pos = valEnd + 1; - } - - if (loaded == 0) { + std::unordered_set loadingStack; + if (!loadOpcodeJsonRecursive(std::filesystem::path(path), + logicalToWire_, wireToLogical_, loadingStack) || + logicalToWire_.empty()) { LOG_WARNING("OpcodeTable: no opcodes loaded from ", path); return false; } - LOG_INFO("OpcodeTable: loaded ", loaded, " opcodes from ", path); + LOG_INFO("OpcodeTable: loaded ", logicalToWire_.size(), " opcodes from ", path); return true; } diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 03e0c5a0..0d4d09e2 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -1,9 +1,137 @@ #include "game/packet_parsers.hpp" #include "core/logger.hpp" +#include +#include +#include namespace wowee { namespace game { +namespace { + +bool hasFullPackedGuid(const network::Packet& packet) { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; +} + +std::string formatPacketBytes(const network::Packet& packet, size_t startPos) { + const auto& rawData = packet.getData(); + if (startPos >= rawData.size()) { + return {}; + } + + std::string hex; + hex.reserve((rawData.size() - startPos) * 3); + for (size_t i = startPos; i < rawData.size(); ++i) { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%02x ", rawData[i]); + hex += buf; + } + if (!hex.empty()) { + hex.pop_back(); + } + return hex; +} + +bool skipClassicSpellCastTargets(network::Packet& packet, uint64_t* primaryTargetGuid = nullptr) { + if (packet.getSize() - packet.getReadPos() < 2) { + return false; + } + + const uint16_t targetFlags = packet.readUInt16(); + + const auto readPackedTargetGuid = [&](bool capture) -> bool { + if (!hasFullPackedGuid(packet)) { + return false; + } + const uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (capture && primaryTargetGuid && *primaryTargetGuid == 0) { + *primaryTargetGuid = guid; + } + return true; + }; + + // Common Classic/Turtle SpellCastTargets payloads. + if ((targetFlags & 0x0002) != 0 && !readPackedTargetGuid(true)) { // UNIT + return false; + } + if ((targetFlags & 0x0004) != 0 && !readPackedTargetGuid(false)) { // UNIT_MINIPET/extra guid + return false; + } + if ((targetFlags & 0x0010) != 0 && !readPackedTargetGuid(false)) { // ITEM + return false; + } + if ((targetFlags & 0x0800) != 0 && !readPackedTargetGuid(true)) { // OBJECT + return false; + } + if ((targetFlags & 0x8000) != 0 && !readPackedTargetGuid(false)) { // CORPSE + return false; + } + + if ((targetFlags & 0x0020) != 0) { // SOURCE_LOCATION + if (packet.getSize() - packet.getReadPos() < 12) { + return false; + } + (void)packet.readFloat(); + (void)packet.readFloat(); + (void)packet.readFloat(); + } + if ((targetFlags & 0x0040) != 0) { // DEST_LOCATION + if (packet.getSize() - packet.getReadPos() < 12) { + return false; + } + (void)packet.readFloat(); + (void)packet.readFloat(); + (void)packet.readFloat(); + } + + if ((targetFlags & 0x1000) != 0) { // TRADE_ITEM + if (packet.getSize() - packet.getReadPos() < 1) { + return false; + } + (void)packet.readUInt8(); + } + + if ((targetFlags & 0x2000) != 0) { // STRING + const auto& rawData = packet.getData(); + size_t pos = packet.getReadPos(); + while (pos < rawData.size() && rawData[pos] != 0) { + ++pos; + } + if (pos >= rawData.size()) { + return false; + } + packet.setReadPos(pos + 1); + } + + return true; +} + +const char* updateTypeName(UpdateType type) { + switch (type) { + case UpdateType::VALUES: return "VALUES"; + case UpdateType::MOVEMENT: return "MOVEMENT"; + case UpdateType::CREATE_OBJECT: return "CREATE_OBJECT"; + case UpdateType::CREATE_OBJECT2: return "CREATE_OBJECT2"; + case UpdateType::OUT_OF_RANGE_OBJECTS: return "OUT_OF_RANGE_OBJECTS"; + case UpdateType::NEAR_OBJECTS: return "NEAR_OBJECTS"; + default: return "UNKNOWN"; + } +} + +} // namespace + // ============================================================================ // Classic 1.12.1 movement flag constants // Key differences from TBC: @@ -21,6 +149,36 @@ namespace ClassicMoveFlags { constexpr uint32_t SPLINE_ELEVATION = 0x04000000; // Same as TBC } +uint32_t classicWireMoveFlags(uint32_t internalFlags) { + uint32_t wireFlags = internalFlags; + + // Internal movement state is tracked with WotLK-era bits. Classic/Turtle + // movement packets still use the older transport/jump flag layout. + const uint32_t kInternalOnTransport = static_cast(MovementFlags::ONTRANSPORT); + const uint32_t kInternalFalling = + static_cast(MovementFlags::FALLING) | + static_cast(MovementFlags::FALLINGFAR); + const uint32_t kClassicConflicts = + static_cast(MovementFlags::ASCENDING) | + static_cast(MovementFlags::CAN_FLY) | + static_cast(MovementFlags::FLYING) | + static_cast(MovementFlags::HOVER); + + wireFlags &= ~kClassicConflicts; + + if ((internalFlags & kInternalOnTransport) != 0) { + wireFlags &= ~kInternalOnTransport; + wireFlags |= ClassicMoveFlags::ONTRANSPORT; + } + + if ((internalFlags & kInternalFalling) != 0) { + wireFlags &= ~kInternalFalling; + wireFlags |= ClassicMoveFlags::JUMPING; + } + + return wireFlags; +} + // ============================================================================ // Classic parseMovementBlock // Key differences from TBC: @@ -31,20 +189,26 @@ namespace ClassicMoveFlags { // Same as TBC: u8 UpdateFlags, JUMPING=0x2000, 8 speeds, no pitchRate // ============================================================================ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; + // Classic: UpdateFlags is uint8 (same as TBC) uint8_t updateFlags = packet.readUInt8(); block.updateFlags = static_cast(updateFlags); LOG_DEBUG(" [Classic] UpdateFlags: 0x", std::hex, (int)updateFlags, std::dec); - const uint8_t UPDATEFLAG_LIVING = 0x20; - const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; - const uint8_t UPDATEFLAG_HAS_TARGET = 0x04; - const uint8_t UPDATEFLAG_TRANSPORT = 0x02; - const uint8_t UPDATEFLAG_LOWGUID = 0x08; - const uint8_t UPDATEFLAG_HIGHGUID = 0x10; + const uint8_t UPDATEFLAG_TRANSPORT = 0x02; + const uint8_t UPDATEFLAG_MELEE_ATTACKING = 0x04; + const uint8_t UPDATEFLAG_HIGHGUID = 0x08; + const uint8_t UPDATEFLAG_ALL = 0x10; + const uint8_t UPDATEFLAG_LIVING = 0x20; + const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes + if (rem() < 52) return false; + // Movement flags (u32 only — NO extra flags byte in Classic) uint32_t moveFlags = packet.readUInt32(); /*uint32_t time =*/ packet.readUInt32(); @@ -61,26 +225,29 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // Transport data (Classic: ONTRANSPORT=0x02000000, no timestamp) if (moveFlags & ClassicMoveFlags::ONTRANSPORT) { + if (rem() < 1) return false; block.onTransport = true; block.transportGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 16) return false; // 4 floats block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); block.transportO = packet.readFloat(); - // Classic: NO transport timestamp (TBC adds u32 timestamp) - // Classic: NO transport seat byte } // Pitch (Classic: only SWIMMING, no FLYING or ONTRANSPORT pitch) if (moveFlags & ClassicMoveFlags::SWIMMING) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time (always present) + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jumping (Classic: JUMPING=0x2000, same as TBC) if (moveFlags & ClassicMoveFlags::JUMPING) { + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -89,12 +256,12 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // Spline elevation if (moveFlags & ClassicMoveFlags::SPLINE_ELEVATION) { + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Speeds (Classic: 6 values — no flight speeds, no pitchRate) - // TBC added flying_speed + backwards_flying_speed (8 total) - // WotLK added pitchRate (9 total) + if (rem() < 24) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -103,37 +270,38 @@ 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) { + if (rem() < 4) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" [Classic] Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { // FINAL_POINT + if (rem() < 12) return false; /*float finalX =*/ packet.readFloat(); /*float finalY =*/ packet.readFloat(); /*float finalZ =*/ packet.readFloat(); } else if (splineFlags & 0x00020000) { // FINAL_TARGET + if (rem() < 8) return false; /*uint64_t finalTarget =*/ packet.readUInt64(); } else if (splineFlags & 0x00040000) { // FINAL_ANGLE + if (rem() < 4) return false; /*float finalAngle =*/ packet.readFloat(); } - // Classic spline: timePassed, duration, id, nodes, finalNode (same as TBC) + // Classic spline: timePassed, duration, id, pointCount + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); - if (pointCount > 256) { - static uint32_t badClassicSplineCount = 0; - ++badClassicSplineCount; - if (badClassicSplineCount <= 5 || (badClassicSplineCount % 100) == 0) { - LOG_WARNING(" [Classic] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badClassicSplineCount, ")"); - } - pointCount = 0; - } + if (pointCount > 256) return false; + + // points + endPoint (no splineMode in Classic) + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { /*float px =*/ packet.readFloat(); /*float py =*/ packet.readFloat(); @@ -147,6 +315,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo } } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -156,26 +325,30 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo LOG_DEBUG(" [Classic] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")"); } - // Target GUID - if (updateFlags & UPDATEFLAG_HAS_TARGET) { - /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); - } - - // Transport time - if (updateFlags & UPDATEFLAG_TRANSPORT) { - /*uint32_t transportTime =*/ packet.readUInt32(); - } - - // Low GUID - if (updateFlags & UPDATEFLAG_LOWGUID) { - /*uint32_t lowGuid =*/ packet.readUInt32(); - } - // High GUID if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t highGuid =*/ packet.readUInt32(); } + // ALL/SELF extra uint32 + if (updateFlags & UPDATEFLAG_ALL) { + if (rem() < 4) return false; + /*uint32_t unkAll =*/ packet.readUInt32(); + } + + // Current melee target as packed guid + if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) { + if (rem() < 1) return false; + /*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + } + + // Transport progress / world time + if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; + /*uint32_t transportTime =*/ packet.readUInt32(); + } + return true; } @@ -187,8 +360,10 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo // - Pitch: only SWIMMING (no ONTRANSPORT pitch) // ============================================================================ void ClassicPacketParsers::writeMovementPayload(network::Packet& packet, const MovementInfo& info) { + const uint32_t wireFlags = classicWireMoveFlags(info.flags); + // Movement flags (uint32) - packet.writeUInt32(info.flags); + packet.writeUInt32(wireFlags); // Classic: NO flags2 byte (TBC has u8, WotLK has u16) @@ -202,7 +377,7 @@ void ClassicPacketParsers::writeMovementPayload(network::Packet& packet, const M packet.writeBytes(reinterpret_cast(&info.orientation), sizeof(float)); // Transport data (Classic ONTRANSPORT = 0x02000000, no timestamp) - if (info.flags & ClassicMoveFlags::ONTRANSPORT) { + if (wireFlags & ClassicMoveFlags::ONTRANSPORT) { // Packed transport GUID uint8_t transMask = 0; uint8_t transGuidBytes[8]; @@ -230,7 +405,7 @@ void ClassicPacketParsers::writeMovementPayload(network::Packet& packet, const M } // Pitch (Classic: only SWIMMING) - if (info.flags & ClassicMoveFlags::SWIMMING) { + if (wireFlags & ClassicMoveFlags::SWIMMING) { packet.writeBytes(reinterpret_cast(&info.pitch), sizeof(float)); } @@ -238,7 +413,7 @@ void ClassicPacketParsers::writeMovementPayload(network::Packet& packet, const M packet.writeUInt32(info.fallTime); // Jump data (Classic JUMPING = 0x2000) - if (info.flags & ClassicMoveFlags::JUMPING) { + if (wireFlags & ClassicMoveFlags::JUMPING) { packet.writeBytes(reinterpret_cast(&info.jumpVelocity), sizeof(float)); packet.writeBytes(reinterpret_cast(&info.jumpSinAngle), sizeof(float)); packet.writeBytes(reinterpret_cast(&info.jumpCosAngle), sizeof(float)); @@ -323,34 +498,50 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo // - castFlags is uint16 (NOT uint32 as in TBC/WotLK). // - SpellCastTargets uses uint16 targetFlags (NOT uint32 as in TBC). // -// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount) +// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) // + uint32(spellId) + uint16(castFlags) + uint32(castTime) // + uint16(targetFlags) [+ PackedGuid(unitTarget) if TARGET_FLAG_UNIT] // ============================================================================ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) { + data = SpellStartData{}; + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + const size_t startPos = packet.getReadPos(); if (rem() < 2) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (rem() < 1) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); - // uint8 castCount + uint32 spellId + uint16 castFlags + uint32 castTime = 11 bytes - if (rem() < 11) return false; - data.castCount = packet.readUInt8(); + // Vanilla/Turtle SMSG_SPELL_START does not include castCount here. + // Layout after the two packed GUIDs is spellId(u32) + castFlags(u16) + castTime(u32). + if (rem() < 10) return false; + data.castCount = 0; data.spellId = packet.readUInt32(); data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) data.castTime = packet.readUInt32(); - // SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK) - if (rem() < 2) return true; - uint16_t targetFlags = packet.readUInt16(); - // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID - if (((targetFlags & 0x02) || (targetFlags & 0x800)) && rem() >= 1) { - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + // SpellCastTargets: consume ALL target payload types so subsequent reads stay aligned. + // Previously only UNIT(0x02)/OBJECT(0x800) were handled; DEST_LOCATION(0x40), + // SOURCE_LOCATION(0x20), and ITEM(0x10) bytes were silently skipped, corrupting + // castFlags/castTime for every AOE/ground-targeted spell (Rain of Fire, Blizzard, etc.). + { + uint64_t targetGuid = 0; + // skipClassicSpellCastTargets reads uint16 targetFlags and all payloads. + // Non-fatal on truncation: self-cast spells have zero-byte targets. + skipClassicSpellCastTargets(packet, &targetGuid); + data.targetGuid = targetGuid; } - LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms", + " targetGuid=0x", std::hex, data.targetGuid, std::dec); return true; } @@ -362,44 +553,226 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa // - castFlags is uint16 (not uint32) // - Hit/miss target GUIDs are also PackedGuid in Vanilla // -// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount) +// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) // + uint32(spellId) + uint16(castFlags) // + uint8(hitCount) + [PackedGuid(hitTarget) × hitCount] // + uint8(missCount) + [PackedGuid(missTarget) + uint8(missType)] × missCount // ============================================================================ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + // Always reset output to avoid stale targets when callers reuse buffers. + data = SpellGoData{}; + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + const size_t startPos = packet.getReadPos(); + const bool traceSmallSpellGo = (packet.getSize() - startPos) <= 48; + const auto traceFailure = [&](const char* stage, size_t pos, uint32_t value = 0) { + if (!traceSmallSpellGo) { + return; + } + static uint32_t smallSpellGoTraceCount = 0; + ++smallSpellGoTraceCount; + if (smallSpellGoTraceCount > 12 && (smallSpellGoTraceCount % 50) != 0) { + return; + } + LOG_WARNING("[Classic] Spell go trace: stage=", stage, + " pos=", pos, + " size=", packet.getSize() - startPos, + " spell=", data.spellId, + " castFlags=0x", std::hex, data.castFlags, std::dec, + " value=", value, + " bytes=[", formatPacketBytes(packet, startPos), "]"); + }; if (rem() < 2) return false; + if (!hasFullPackedGuid(packet)) return false; data.casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (rem() < 1) return false; + if (!hasFullPackedGuid(packet)) return false; data.casterUnit = UpdateObjectParser::readPackedGuid(packet); - // uint8 castCount + uint32 spellId + uint16 castFlags = 7 bytes - if (rem() < 7) return false; - data.castCount = packet.readUInt8(); + // Vanilla/Turtle SMSG_SPELL_GO does not include castCount here. + // Layout after the two packed GUIDs is spellId(u32) + castFlags(u16). + if (rem() < 6) return false; + data.castCount = 0; data.spellId = packet.readUInt32(); data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) - // Hit targets - if (rem() < 1) return true; - data.hitCount = packet.readUInt8(); - data.hitTargets.reserve(data.hitCount); - for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) { - data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); + const size_t countsPos = packet.getReadPos(); + uint64_t ignoredTargetGuid = 0; + std::function parseHitAndMissLists = [&](bool allowTargetsFallback) -> bool { + packet.setReadPos(countsPos); + data.hitTargets.clear(); + data.missTargets.clear(); + ignoredTargetGuid = 0; + + // hitCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: missing hitCount after fixed fields"); + traceFailure("missing_hit_count", packet.getReadPos()); + packet.setReadPos(startPos); + return false; + } + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); + } + if (rem() < static_cast(rawHitCount) + 1u) { + static uint32_t badHitCountTrunc = 0; + ++badHitCountTrunc; + if (badHitCountTrunc <= 10 || (badHitCountTrunc % 100) == 0) { + LOG_WARNING("[Classic] Spell go: invalid hitCount/remaining (hits=", (int)rawHitCount, + " remaining=", rem(), " occurrence=", badHitCountTrunc, ")"); + } + traceFailure("invalid_hit_count", packet.getReadPos(), rawHitCount); + packet.setReadPos(startPos); + return false; + } + + const auto parseHitList = [&](bool usePackedGuids) -> bool { + packet.setReadPos(countsPos + 1); // after hitCount + data.hitTargets.clear(); + const uint8_t storedHitLimit = std::min(rawHitCount, 128); + data.hitTargets.reserve(storedHitLimit); + for (uint16_t i = 0; i < rawHitCount; ++i) { + uint64_t targetGuid = 0; + if (usePackedGuids) { + if (!hasFullPackedGuid(packet)) { + return false; + } + targetGuid = UpdateObjectParser::readPackedGuid(packet); + } else { + if (rem() < 8) { + return false; + } + targetGuid = packet.readUInt64(); + } + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } + } + data.hitCount = static_cast(data.hitTargets.size()); + return true; + }; + + if (!parseHitList(false) && !parseHitList(true)) { + LOG_WARNING("[Classic] Spell go: truncated hit targets at index 0/", (int)rawHitCount); + traceFailure("truncated_hit_target", packet.getReadPos(), rawHitCount); + packet.setReadPos(startPos); + return false; + } + + std::function parseMissListFrom = [&](size_t missStartPos, + bool allowMidTargetsFallback) -> bool { + packet.setReadPos(missStartPos); + data.missTargets.clear(); + + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: missing missCount after hit target list"); + traceFailure("missing_miss_count", packet.getReadPos()); + packet.setReadPos(startPos); + return false; + } + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 128) { + LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); + traceFailure("miss_count_capped", packet.getReadPos() - 1, rawMissCount); + } + if (rem() < static_cast(rawMissCount) * 2u) { + if (allowMidTargetsFallback) { + packet.setReadPos(missStartPos); + if (skipClassicSpellCastTargets(packet, &ignoredTargetGuid)) { + traceFailure("mid_targets_fallback", missStartPos, ignoredTargetGuid != 0 ? 1u : 0u); + return parseMissListFrom(packet.getReadPos(), false); + } + } + if (allowTargetsFallback) { + packet.setReadPos(countsPos); + if (skipClassicSpellCastTargets(packet, &ignoredTargetGuid)) { + traceFailure("pre_targets_fallback", countsPos, ignoredTargetGuid != 0 ? 1u : 0u); + return parseHitAndMissLists(false); + } + } + + static uint32_t badMissCountTrunc = 0; + ++badMissCountTrunc; + if (badMissCountTrunc <= 10 || (badMissCountTrunc % 100) == 0) { + LOG_WARNING("[Classic] Spell go: invalid missCount/remaining (misses=", (int)rawMissCount, + " remaining=", rem(), " occurrence=", badMissCountTrunc, ")"); + } + traceFailure("invalid_miss_count", packet.getReadPos(), rawMissCount); + packet.setReadPos(startPos); + return false; + } + + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + data.missTargets.reserve(storedMissLimit); + bool truncatedMissTargets = false; + const auto parseMissEntry = [&](SpellGoMissEntry& m, bool usePackedGuid) -> bool { + if (usePackedGuid) { + if (!hasFullPackedGuid(packet)) { + return false; + } + m.targetGuid = UpdateObjectParser::readPackedGuid(packet); + } else { + if (rem() < 8) { + return false; + } + m.targetGuid = packet.readUInt64(); + } + return true; + }; + for (uint16_t i = 0; i < rawMissCount; ++i) { + SpellGoMissEntry m; + const size_t missEntryPos = packet.getReadPos(); + if (!parseMissEntry(m, false)) { + packet.setReadPos(missEntryPos); + if (!parseMissEntry(m, true)) { + LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", i, + "/", (int)rawMissCount); + traceFailure("truncated_miss_target", packet.getReadPos(), i); + truncatedMissTargets = true; + break; + } + } + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: missing missType at miss index ", i, + "/", (int)rawMissCount); + traceFailure("missing_miss_type", packet.getReadPos(), i); + truncatedMissTargets = true; + break; + } + m.missType = packet.readUInt8(); + if (m.missType == 11) { + if (rem() < 1) { + LOG_WARNING("[Classic] Spell go: truncated reflect payload at miss index ", i, + "/", (int)rawMissCount); + traceFailure("truncated_reflect", packet.getReadPos(), i); + truncatedMissTargets = true; + break; + } + (void)packet.readUInt8(); + } + if (i < storedMissLimit) { + data.missTargets.push_back(m); + } + } + if (truncatedMissTargets) { + packet.setReadPos(startPos); + return false; + } + data.missCount = static_cast(data.missTargets.size()); + return true; + }; + + return parseMissListFrom(packet.getReadPos(), true); + }; + + if (!parseHitAndMissLists(true)) { + return false; } - // Miss targets - if (rem() < 1) return true; - data.missCount = packet.readUInt8(); - data.missTargets.reserve(data.missCount); - for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) { - SpellGoMissEntry m; - m.targetGuid = UpdateObjectParser::readPackedGuid(packet); - if (rem() < 1) break; - m.missType = packet.readUInt8(); - data.missTargets.push_back(m); - } + // SpellCastTargets follows the miss list — consume all target bytes so that + // any subsequent fields (e.g. castFlags extras) are not misaligned. + skipClassicSpellCastTargets(packet, &data.targetGuid); LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); @@ -417,19 +790,42 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da // + uint32(victimState) + int32(overkill) [+ uint32(blocked)] // ============================================================================ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { + data = AttackerStateUpdateData{}; + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; if (rem() < 5) return false; // hitInfo(4) + at least GUID mask byte(1) + const size_t startPos = packet.getReadPos(); data.hitInfo = packet.readUInt32(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 5) return false; // int32 totalDamage + uint8 subDamageCount + if (rem() < 5) { + packet.setReadPos(startPos); + return false; + } // int32 totalDamage + uint8 subDamageCount data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); - for (uint8_t i = 0; i < data.subDamageCount && rem() >= 20; ++i) { + const uint8_t maxSubDamageCount = static_cast(std::min(rem() / 20, 64)); + if (data.subDamageCount > maxSubDamageCount) { + data.subDamageCount = maxSubDamageCount; + } + + data.subDamages.reserve(data.subDamageCount); + for (uint8_t i = 0; i < data.subDamageCount; ++i) { + if (rem() < 20) { + packet.setReadPos(startPos); + return false; + } SubDamage sub; sub.schoolMask = packet.readUInt32(); sub.damage = packet.readFloat(); @@ -438,8 +834,12 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att sub.resisted = packet.readUInt32(); data.subDamages.push_back(sub); } + data.subDamageCount = static_cast(data.subDamages.size()); - if (rem() < 8) return true; + if (rem() < 8) { + packet.setReadPos(startPos); + return false; + } data.victimState = packet.readUInt32(); data.overkill = static_cast(packet.readUInt32()); @@ -463,10 +863,10 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att // ============================================================================ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (rem() < 2) return false; + if (rem() < 2 || !hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (rem() < 1 || !hasFullPackedGuid(packet)) return false; data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla // uint32(spellId) + uint32(damage) + uint8(schoolMask) + uint32(absorbed) @@ -498,10 +898,10 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam // ============================================================================ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - if (rem() < 2) return false; + if (rem() < 2 || !hasFullPackedGuid(packet)) return false; data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla - if (rem() < 1) return false; + if (rem() < 1 || !hasFullPackedGuid(packet)) return false; data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla if (rem() < 13) return false; // uint32 + uint32 + uint32 + uint8 = 13 bytes @@ -632,6 +1032,22 @@ bool ClassicPacketParsers::parseCastFailed(network::Packet& packet, CastFailedDa return true; } +// ============================================================================ +// Classic SMSG_CAST_RESULT: same layout as parseCastFailed (spellId + result), +// but the result enum starts at 0=AFFECTING_COMBAT (no SUCCESS entry). +// Apply the same +1 shift used in parseCastFailed so the result codes +// align with WotLK's getSpellCastResultString table. +// ============================================================================ +bool ClassicPacketParsers::parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) { + if (packet.getSize() - packet.getReadPos() < 5) return false; + spellId = packet.readUInt32(); + uint8_t vanillaResult = packet.readUInt8(); + // Shift +1: Vanilla result 0=AFFECTING_COMBAT maps to WotLK result 1=AFFECTING_COMBAT + result = vanillaResult + 1; + LOG_DEBUG("[Classic] Cast result: spell=", spellId, " vanillaResult=", (int)vanillaResult); + return true; +} + // ============================================================================ // Classic 1.12.1 parseCharEnum // Differences from TBC: @@ -641,14 +1057,40 @@ bool ClassicPacketParsers::parseCastFailed(network::Packet& packet, CastFailedDa // - After flags: uint8 firstLogin (same as TBC) // ============================================================================ bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& response) { + // Validate minimum packet size for count byte + if (packet.getSize() < 1) { + LOG_ERROR("[Classic] SMSG_CHAR_ENUM packet too small: ", packet.getSize(), " bytes"); + return false; + } + uint8_t count = packet.readUInt8(); + // Cap count to prevent excessive memory allocation + constexpr uint8_t kMaxCharacters = 32; + if (count > kMaxCharacters) { + LOG_WARNING("[Classic] Character count ", (int)count, " exceeds max ", (int)kMaxCharacters, + ", capping"); + count = kMaxCharacters; + } + LOG_INFO("[Classic] Parsing SMSG_CHAR_ENUM: ", (int)count, " characters"); response.characters.clear(); response.characters.reserve(count); for (uint8_t i = 0; i < count; ++i) { + // Sanity check: ensure we have at least minimal data before reading next character + // Minimum: guid(8) + name(1) + race(1) + class(1) + gender(1) + appearance(4) + // + facialFeatures(1) + level(1) + zone(4) + map(4) + pos(12) + guild(4) + // + flags(4) + firstLogin(1) + pet(12) + equipment(20*5) + constexpr size_t kMinCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 12 + 4 + 4 + 1 + 12 + 100; + if (packet.getReadPos() + kMinCharacterSize > packet.getSize()) { + LOG_WARNING("[Classic] Character enum packet truncated at character ", (int)(i + 1), + ", pos=", packet.getReadPos(), " needed=", kMinCharacterSize, + " size=", packet.getSize()); + break; + } + Character character; // GUID (8 bytes) @@ -930,6 +1372,12 @@ bool ClassicPacketParsers::parseGuildQueryResponse(network::Packet& packet, Guil // ============================================================================ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); // High bit set means gameobject not found @@ -939,6 +1387,12 @@ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, return true; } + // Validate minimum size for fixed fields: type(4) + displayId(4) + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_ERROR("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + data.type = packet.readUInt32(); data.displayId = packet.readUInt32(); // 4 name strings @@ -954,6 +1408,16 @@ bool ClassicPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, data.data[i] = packet.readUInt32(); } data.hasData = true; + } else if (remaining > 0) { + // Partial data field; read what we can + uint32_t fieldsToRead = remaining / 4; + for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + if (fieldsToRead < 24) { + LOG_WARNING("Classic SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead, + " of 24 read, entry=", data.entry, ")"); + } } if (data.type == 15) { // MO_TRANSPORT @@ -983,9 +1447,24 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes data.titleTextId = packet.readUInt32(); uint32_t optionCount = packet.readUInt32(); + // Cap option count to reasonable maximum + constexpr uint32_t kMaxGossipOptions = 256; + if (optionCount > kMaxGossipOptions) { + LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE optionCount=", optionCount, " exceeds max ", + kMaxGossipOptions, ", capping"); + optionCount = kMaxGossipOptions; + } + data.options.clear(); data.options.reserve(optionCount); for (uint32_t i = 0; i < optionCount; ++i) { + // Sanity check: ensure minimum bytes available for option (id(4)+icon(1)+isCoded(1)+text(1)) + remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 7) { + LOG_WARNING("Classic gossip option ", i, " truncated (", remaining, " bytes left)"); + break; + } + GossipOption opt; opt.id = packet.readUInt32(); opt.icon = packet.readUInt8(); @@ -997,10 +1476,33 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes data.options.push_back(opt); } + // Ensure we have at least 4 bytes for questCount + remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4) { + LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE truncated before questCount"); + return data.options.size() > 0; // Return true if we got at least some options + } + uint32_t questCount = packet.readUInt32(); + + // Cap quest count to reasonable maximum + constexpr uint32_t kMaxGossipQuests = 256; + if (questCount > kMaxGossipQuests) { + LOG_WARNING("Classic SMSG_GOSSIP_MESSAGE questCount=", questCount, " exceeds max ", + kMaxGossipQuests, ", capping"); + questCount = kMaxGossipQuests; + } + data.quests.clear(); data.quests.reserve(questCount); for (uint32_t i = 0; i < questCount; ++i) { + // Sanity check: ensure minimum bytes available for quest (id(4)+icon(4)+level(4)+title(1)) + remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 13) { + LOG_WARNING("Classic gossip quest ", i, " truncated (", remaining, " bytes left)"); + break; + } + GossipQuestItem quest; quest.questId = packet.readUInt32(); quest.questIcon = packet.readUInt32(); @@ -1176,6 +1678,12 @@ network::Packet ClassicPacketParsers::buildItemQuery(uint32_t entry, uint64_t gu } bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); // High bit set means item not found @@ -1184,6 +1692,12 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ return true; } + // Validate minimum size for fixed fields: itemClass(4) + subClass(4) + 4 name strings + displayInfoId(4) + quality(4) + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + uint32_t itemClass = packet.readUInt32(); uint32_t subClass = packet.readUInt32(); // Vanilla: NO SoundOverrideSubclass @@ -1232,30 +1746,50 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ data.displayInfoId = packet.readUInt32(); data.quality = packet.readUInt32(); - packet.readUInt32(); // Flags + // Validate minimum size for fixed fields: Flags(4) + BuyPrice(4) + SellPrice(4) + inventoryType(4) + if (packet.getSize() - packet.getReadPos() < 16) { + LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before inventoryType (entry=", data.entry, ")"); + return false; + } + + data.itemFlags = packet.readUInt32(); // Flags // Vanilla: NO Flags2 packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); // SellPrice data.inventoryType = packet.readUInt32(); - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + // Validate minimum size for remaining fixed fields: 13×4 = 52 bytes + if (packet.getSize() - packet.getReadPos() < 52) { + LOG_ERROR("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before stats (entry=", data.entry, ")"); + return false; + } + + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); - // Vanilla: 10 stat pairs, NO statsCount prefix + // Vanilla: 10 stat pairs, NO statsCount prefix (10×8 = 80 bytes) + if (packet.getSize() - packet.getReadPos() < 80) { + LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated in stats section (entry=", data.entry, ")"); + // Read what we can + } for (uint32_t i = 0; i < 10; i++) { + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")"); + break; + } uint32_t statType = packet.readUInt32(); int32_t statValue = static_cast(packet.readUInt32()); if (statType != 0) { @@ -1265,7 +1799,10 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } } @@ -1275,6 +1812,11 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ // Vanilla: 5 damage types (same count as WotLK) bool haveWeaponDamage = false; for (int i = 0; i < 5; i++) { + // Each damage entry is dmgMin(4) + dmgMax(4) + damageType(4) = 12 bytes + if (packet.getSize() - packet.getReadPos() < 12) { + LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: damage ", i, " truncated (entry=", data.entry, ")"); + break; + } float dmgMin = packet.readFloat(); float dmgMax = packet.readFloat(); uint32_t damageType = packet.readUInt32(); @@ -1288,19 +1830,58 @@ bool ClassicPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQ } } + // Validate minimum size for armor field (4 bytes) + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("Classic SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before armor (entry=", data.entry, ")"); + return true; // Have core fields; armor is important but optional + } data.armor = static_cast(packet.readUInt32()); // Remaining tail can vary by core. Read resistances + delay when present. if (packet.getSize() - packet.getReadPos() >= 28) { - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); } + // AmmoType + RangedModRange (2 fields, 8 bytes) + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + } + + // 2 item spells in Vanilla (3 fields each: SpellId, Trigger, Charges) + // Actually vanilla has 5 spells: SpellId, Trigger, Charges, Cooldown, Category, CatCooldown = 24 bytes each + for (int i = 0; i < 5; i++) { + if (packet.getReadPos() + 24 > packet.getSize()) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Description (flavor/lore text) + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + + // Post-description: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); LOG_DEBUG("[Classic] Item query response: ", data.name, " (quality=", data.quality, " invType=", data.inventoryType, " stack=", data.maxStack, ")"); @@ -1345,19 +1926,24 @@ namespace TurtleMoveFlags { } bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; + uint8_t updateFlags = packet.readUInt8(); block.updateFlags = static_cast(updateFlags); LOG_DEBUG(" [Turtle] UpdateFlags: 0x", std::hex, (int)updateFlags, std::dec); - const uint8_t UPDATEFLAG_LIVING = 0x20; - const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; - const uint8_t UPDATEFLAG_HAS_TARGET = 0x04; - const uint8_t UPDATEFLAG_TRANSPORT = 0x02; - const uint8_t UPDATEFLAG_LOWGUID = 0x08; - const uint8_t UPDATEFLAG_HIGHGUID = 0x10; + const uint8_t UPDATEFLAG_TRANSPORT = 0x02; + const uint8_t UPDATEFLAG_MELEE_ATTACKING = 0x04; + const uint8_t UPDATEFLAG_HIGHGUID = 0x08; + const uint8_t UPDATEFLAG_ALL = 0x10; + const uint8_t UPDATEFLAG_LIVING = 0x20; + const uint8_t UPDATEFLAG_HAS_POSITION = 0x40; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+time(4)+position(16)+fallTime(4)+speeds(24) = 52 bytes + if (rem() < 52) return false; size_t livingStart = packet.getReadPos(); uint32_t moveFlags = packet.readUInt32(); @@ -1376,8 +1962,10 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // Transport — Classic flag position 0x02000000 if (moveFlags & TurtleMoveFlags::ONTRANSPORT) { + if (rem() < 1) return false; // PackedGuid mask byte block.onTransport = true; block.transportGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 20) return false; // 4 floats + u32 timestamp block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); @@ -1387,14 +1975,17 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // Pitch (swimming only, Classic-style) if (moveFlags & TurtleMoveFlags::SWIMMING) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time (always present) + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jump data if (moveFlags & TurtleMoveFlags::JUMPING) { + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -1403,10 +1994,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc // Spline elevation if (moveFlags & TurtleMoveFlags::SPLINE_ELEVATION) { + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Turtle: 6 speeds (same as Classic — no flight speeds) + if (rem() < 24) return false; // 6 × float float walkSpeed = packet.readFloat(); float runSpeed = packet.readFloat(); float runBackSpeed = packet.readFloat(); @@ -1424,17 +2017,23 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc bool hasSpline = (moveFlags & TurtleMoveFlags::SPLINE_CLASSIC) || (moveFlags & TurtleMoveFlags::SPLINE_TBC); if (hasSpline) { + if (rem() < 4) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" [Turtle] Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { + if (rem() < 12) return false; packet.readFloat(); packet.readFloat(); packet.readFloat(); } else if (splineFlags & 0x00020000) { + if (rem() < 8) return false; packet.readUInt64(); } else if (splineFlags & 0x00040000) { + if (rem() < 4) return false; packet.readFloat(); } + // timePassed + duration + splineId + pointCount = 16 bytes + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); @@ -1445,10 +2044,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc ++badTurtleSplineCount; if (badTurtleSplineCount <= 5 || (badTurtleSplineCount % 100) == 0) { LOG_WARNING(" [Turtle] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badTurtleSplineCount, ")"); + " exceeds max (occurrence=", badTurtleSplineCount, ")"); } - pointCount = 0; + return false; } + // points + endPoint + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { packet.readFloat(); packet.readFloat(); packet.readFloat(); } @@ -1461,6 +2062,7 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc " bytes, readPos now=", packet.getReadPos()); } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -1470,37 +2072,263 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc LOG_DEBUG(" [Turtle] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")"); } - // Target GUID - if (updateFlags & UPDATEFLAG_HAS_TARGET) { - /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); - } - - // Transport time - if (updateFlags & UPDATEFLAG_TRANSPORT) { - /*uint32_t transportTime =*/ packet.readUInt32(); - } - - // Low GUID — Classic-style: 1×u32 (NOT TBC's 2×u32) - if (updateFlags & UPDATEFLAG_LOWGUID) { - /*uint32_t lowGuid =*/ packet.readUInt32(); - } - // High GUID — 1×u32 if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t highGuid =*/ packet.readUInt32(); } + if (updateFlags & UPDATEFLAG_ALL) { + if (rem() < 4) return false; + /*uint32_t unkAll =*/ packet.readUInt32(); + } + + if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) { + if (rem() < 1) return false; + /*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet); + } + + if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; + /*uint32_t transportTime =*/ packet.readUInt32(); + } + return true; } +bool TurtlePacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectData& data) { + constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; + + auto parseWithLayout = [&](bool withHasTransportByte, UpdateObjectData& out) -> bool { + out = UpdateObjectData{}; + const size_t start = packet.getReadPos(); + if (packet.getSize() - start < 4) return false; + + out.blockCount = packet.readUInt32(); + if (out.blockCount > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + + if (withHasTransportByte) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + /*uint8_t hasTransport =*/ packet.readUInt8(); + } + + uint32_t remainingBlockCount = out.blockCount; + + if (packet.getReadPos() + 1 <= packet.getSize()) { + uint8_t firstByte = packet.readUInt8(); + if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (remainingBlockCount == 0) { + packet.setReadPos(start); + return false; + } + --remainingBlockCount; + if (packet.getReadPos() + 4 > packet.getSize()) { + packet.setReadPos(start); + return false; + } + uint32_t count = packet.readUInt32(); + if (count > kMaxReasonableUpdateBlocks) { + packet.setReadPos(start); + return false; + } + for (uint32_t i = 0; i < count; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + out.outOfRangeGuids.push_back(UpdateObjectParser::readPackedGuid(packet)); + } + } else { + packet.setReadPos(packet.getReadPos() - 1); + } + } + + out.blockCount = remainingBlockCount; + out.blocks.reserve(out.blockCount); + for (uint32_t i = 0; i < out.blockCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + packet.setReadPos(start); + return false; + } + + const size_t blockStart = packet.getReadPos(); + uint8_t updateTypeVal = packet.readUInt8(); + if (updateTypeVal > static_cast(UpdateType::NEAR_OBJECTS)) { + packet.setReadPos(start); + return false; + } + + const UpdateType updateType = static_cast(updateTypeVal); + UpdateBlock block; + block.updateType = updateType; + bool ok = false; + + auto parseMovementVariant = [&](auto&& movementParser, const char* layoutName) -> bool { + packet.setReadPos(blockStart + 1); + block = UpdateBlock{}; + block.updateType = updateType; + + switch (updateType) { + case UpdateType::MOVEMENT: + block.guid = packet.readUInt64(); + if (!movementParser(packet, block)) return false; + LOG_DEBUG("[Turtle] Parsed MOVEMENT block via ", layoutName, " layout"); + return true; + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: + block.guid = UpdateObjectParser::readPackedGuid(packet); + if (packet.getReadPos() >= packet.getSize()) return false; + block.objectType = static_cast(packet.readUInt8()); + if (!movementParser(packet, block)) return false; + if (!UpdateObjectParser::parseUpdateFields(packet, block)) return false; + LOG_DEBUG("[Turtle] Parsed CREATE block via ", layoutName, " layout"); + return true; + default: + return false; + } + }; + + switch (updateType) { + case UpdateType::VALUES: + block.guid = UpdateObjectParser::readPackedGuid(packet); + ok = UpdateObjectParser::parseUpdateFields(packet, block); + break; + case UpdateType::MOVEMENT: + case UpdateType::CREATE_OBJECT: + case UpdateType::CREATE_OBJECT2: + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->TurtlePacketParsers::parseMovementBlock(p, b); + }, "turtle"); + if (!ok) { + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->ClassicPacketParsers::parseMovementBlock(p, b); + }, "classic"); + } + if (!ok) { + ok = parseMovementVariant( + [this](network::Packet& p, UpdateBlock& b) { + return this->TbcPacketParsers::parseMovementBlock(p, b); + }, "tbc"); + } + // NOTE: Do NOT fall back to WotLK parseMovementBlock here. + // WotLK uses uint16 updateFlags and 9 speeds vs Classic's uint8 + // and 6 speeds. A false-positive WotLK parse consumes wrong bytes, + // corrupting subsequent update fields and losing NPC data. + break; + case UpdateType::OUT_OF_RANGE_OBJECTS: + case UpdateType::NEAR_OBJECTS: + ok = true; + break; + default: + ok = false; + break; + } + + if (!ok) { + LOG_WARNING("[Turtle] SMSG_UPDATE_OBJECT block parse failed", + " blockIndex=", i, + " updateType=", updateTypeName(updateType), + " readPos=", packet.getReadPos(), + " blockStart=", blockStart, + " packetSize=", packet.getSize()); + packet.setReadPos(start); + return false; + } + + out.blocks.push_back(std::move(block)); + } + + return true; + }; + + const size_t startPos = packet.getReadPos(); + UpdateObjectData parsed; + if (parseWithLayout(true, parsed)) { + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (parseWithLayout(false, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (ClassicPacketParsers::parseUpdateObject(packet, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full classic fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + if (TbcPacketParsers::parseUpdateObject(packet, parsed)) { + LOG_DEBUG("[Turtle] SMSG_UPDATE_OBJECT parsed via full TBC fallback"); + data = std::move(parsed); + return true; + } + + packet.setReadPos(startPos); + return false; +} + bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { - // Turtle realms can emit both vanilla-like and WotLK-like monster move bodies. - // Try the canonical Turtle/vanilla parser first, then fall back to WotLK layout. + // Turtle realms can emit vanilla-like, TBC-like, and WotLK-like monster move + // bodies. Try the lower-expansion layouts first before the WotLK parser that + // expects an extra unk byte after the packed GUID. size_t start = packet.getReadPos(); if (MonsterMoveParser::parseVanilla(packet, data)) { return true; } + packet.setReadPos(start); + if (TbcPacketParsers::parseMonsterMove(packet, data)) { + LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via TBC fallback layout"); + return true; + } + + auto looksLikeWotlkMonsterMove = [&](network::Packet& probe) -> bool { + const size_t probeStart = probe.getReadPos(); + uint64_t guid = UpdateObjectParser::readPackedGuid(probe); + if (guid == 0) { + probe.setReadPos(probeStart); + return false; + } + if (probe.getReadPos() >= probe.getSize()) { + probe.setReadPos(probeStart); + return false; + } + uint8_t unk = probe.readUInt8(); + if (unk > 1) { + probe.setReadPos(probeStart); + return false; + } + if (probe.getReadPos() + 12 + 4 + 1 > probe.getSize()) { + probe.setReadPos(probeStart); + return false; + } + probe.readFloat(); probe.readFloat(); probe.readFloat(); // xyz + probe.readUInt32(); // splineId + uint8_t moveType = probe.readUInt8(); + probe.setReadPos(probeStart); + return moveType >= 1 && moveType <= 4; + }; + + packet.setReadPos(start); + if (!looksLikeWotlkMonsterMove(packet)) { + packet.setReadPos(start); + return false; + } + packet.setReadPos(start); if (MonsterMoveParser::parse(packet, data)) { LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); @@ -1567,6 +2395,12 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u // ============================================================================ bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("Classic SMSG_CREATURE_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); if (data.entry & 0x80000000) { data.entry &= ~0x80000000; @@ -1581,15 +2415,19 @@ bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet, data.subName = packet.readString(); // NOTE: NO iconName field in Classic 1.12 — goes straight to typeFlags if (packet.getReadPos() + 16 > packet.getSize()) { - LOG_WARNING("[Classic] Creature query: truncated at typeFlags (entry=", data.entry, ")"); - return true; + LOG_WARNING("Classic SMSG_CREATURE_QUERY_RESPONSE: truncated at typeFlags (entry=", data.entry, ")"); + data.typeFlags = 0; + data.creatureType = 0; + data.family = 0; + data.rank = 0; + return true; // Have name/sub fields; base fields are important but optional } data.typeFlags = packet.readUInt32(); data.creatureType = packet.readUInt32(); data.family = packet.readUInt32(); data.rank = packet.readUInt32(); - LOG_DEBUG("[Classic] Creature query: ", data.name, " type=", data.creatureType, + LOG_DEBUG("Classic SMSG_CREATURE_QUERY_RESPONSE: ", data.name, " type=", data.creatureType, " rank=", data.rank); return true; } diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index ffc7d3cd..9d68879f 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -30,6 +30,9 @@ namespace TbcMoveFlags { // - Flag 0x08 (HIGH_GUID) reads 2 u32s (Classic: 1 u32) // ============================================================================ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& block) { + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; + // TBC 2.4.3: UpdateFlags is uint8 (1 byte) uint8_t updateFlags = packet.readUInt8(); block.updateFlags = static_cast(updateFlags); @@ -52,6 +55,9 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& const uint8_t UPDATEFLAG_HIGHGUID = 0x10; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+moveFlags2(1)+time(4)+position(16)+fallTime(4)+speeds(32) = 61 + if (rem() < 61) return false; + // Full movement block for living units uint32_t moveFlags = packet.readUInt32(); uint8_t moveFlags2 = packet.readUInt8(); // TBC: uint8, not uint16 @@ -70,29 +76,33 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& // Transport data if (moveFlags & TbcMoveFlags::ON_TRANSPORT) { + if (rem() < 1) return false; block.onTransport = true; block.transportGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 20) return false; // 4 floats + 1 uint32 block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); block.transportO = packet.readFloat(); /*uint32_t tTime =*/ packet.readUInt32(); - // TBC: NO transport seat byte - // TBC: NO interpolated movement check } // Pitch: SWIMMING, or else ONTRANSPORT (TBC-specific secondary pitch) if (moveFlags & TbcMoveFlags::SWIMMING) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } else if (moveFlags & TbcMoveFlags::ONTRANSPORT) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time (always present) + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jumping (TBC: JUMPING=0x2000, WotLK: FALLING=0x1000) if (moveFlags & TbcMoveFlags::JUMPING) { + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -101,11 +111,12 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& // Spline elevation (TBC: 0x02000000, WotLK: 0x04000000) if (moveFlags & TbcMoveFlags::SPLINE_ELEVATION) { + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } // Speeds (TBC: 8 values — walk, run, runBack, swim, fly, flyBack, swimBack, turn) - // WotLK adds pitchRate (9 total) + if (rem() < 32) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -116,52 +127,51 @@ 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) { + if (rem() < 4) return false; uint32_t splineFlags = packet.readUInt32(); LOG_DEBUG(" [TBC] Spline: flags=0x", std::hex, splineFlags, std::dec); if (splineFlags & 0x00010000) { // FINAL_POINT + if (rem() < 12) return false; /*float finalX =*/ packet.readFloat(); /*float finalY =*/ packet.readFloat(); /*float finalZ =*/ packet.readFloat(); } else if (splineFlags & 0x00020000) { // FINAL_TARGET + if (rem() < 8) return false; /*uint64_t finalTarget =*/ packet.readUInt64(); } else if (splineFlags & 0x00040000) { // FINAL_ANGLE + if (rem() < 4) return false; /*float finalAngle =*/ packet.readFloat(); } - // TBC spline: timePassed, duration, id, nodes, finalNode - // (no durationMod, durationModNext, verticalAccel, effectStartTime, splineMode) + // TBC spline: timePassed, duration, id, pointCount + if (rem() < 16) return false; /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); - if (pointCount > 256) { - static uint32_t badTbcSplineCount = 0; - ++badTbcSplineCount; - if (badTbcSplineCount <= 5 || (badTbcSplineCount % 100) == 0) { - LOG_WARNING(" [TBC] Spline pointCount=", pointCount, - " exceeds max, capping (occurrence=", badTbcSplineCount, ")"); - } - pointCount = 0; - } + if (pointCount > 256) return false; + + // points + endPoint (no splineMode in TBC) + if (rem() < static_cast(pointCount) * 12 + 12) return false; for (uint32_t i = 0; i < pointCount; i++) { /*float px =*/ packet.readFloat(); /*float py =*/ packet.readFloat(); /*float pz =*/ packet.readFloat(); } - // TBC: NO splineMode byte (WotLK adds it) /*float endPointX =*/ packet.readFloat(); /*float endPointY =*/ packet.readFloat(); /*float endPointZ =*/ packet.readFloat(); } } else if (updateFlags & UPDATEFLAG_HAS_POSITION) { - // TBC: Simple stationary position (same as WotLK STATIONARY) + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -170,29 +180,29 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& LOG_DEBUG(" [TBC] STATIONARY: (", block.x, ", ", block.y, ", ", block.z, ")"); } - // TBC: No UPDATEFLAG_POSITION (0x0100) code path // Target GUID if (updateFlags & UPDATEFLAG_HAS_TARGET) { + if (rem() < 1) return false; /*uint64_t targetGuid =*/ UpdateObjectParser::readPackedGuid(packet); } // Transport time if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; /*uint32_t transportTime =*/ packet.readUInt32(); } - // TBC: No VEHICLE flag (WotLK 0x0080) - // TBC: No ROTATION flag (WotLK 0x0200) - - // HIGH_GUID (0x08) — TBC has 2 u32s, Classic has 1 u32 + // LOWGUID (0x08) — TBC has 2 u32s, Classic has 1 u32 if (updateFlags & UPDATEFLAG_LOWGUID) { + if (rem() < 8) return false; /*uint32_t unknown0 =*/ packet.readUInt32(); /*uint32_t unknown1 =*/ packet.readUInt32(); } - // ALL (0x10) + // HIGHGUID (0x10) if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t unknown2 =*/ packet.readUInt32(); } @@ -296,14 +306,40 @@ network::Packet TbcPacketParsers::buildMovementPacket(LogicalOpcode opcode, // - Equipment: 20 items (not 23) // ============================================================================ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& response) { + // Validate minimum packet size for count byte + if (packet.getSize() < 1) { + LOG_ERROR("[TBC] SMSG_CHAR_ENUM packet too small: ", packet.getSize(), " bytes"); + return false; + } + uint8_t count = packet.readUInt8(); + // Cap count to prevent excessive memory allocation + constexpr uint8_t kMaxCharacters = 32; + if (count > kMaxCharacters) { + LOG_WARNING("[TBC] Character count ", (int)count, " exceeds max ", (int)kMaxCharacters, + ", capping"); + count = kMaxCharacters; + } + LOG_INFO("[TBC] Parsing SMSG_CHAR_ENUM: ", (int)count, " characters"); response.characters.clear(); response.characters.reserve(count); for (uint8_t i = 0; i < count; ++i) { + // Sanity check: ensure we have at least minimal data before reading next character + // Minimum: guid(8) + name(1) + race(1) + class(1) + gender(1) + appearance(4) + // + facialFeatures(1) + level(1) + zone(4) + map(4) + pos(12) + guild(4) + // + flags(4) + firstLogin(1) + pet(12) + equipment(20*9) + constexpr size_t kMinCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 12 + 4 + 4 + 1 + 12 + 180; + if (packet.getReadPos() + kMinCharacterSize > packet.getSize()) { + LOG_WARNING("[TBC] Character enum packet truncated at character ", (int)(i + 1), + ", pos=", packet.getReadPos(), " needed=", kMinCharacterSize, + " size=", packet.getSize()); + break; + } + Character character; // GUID (8 bytes) @@ -392,9 +428,16 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa /*uint8_t hasTransport =*/ packet.readUInt8(); } + uint32_t remainingBlockCount = out.blockCount; + if (packet.getReadPos() + 1 <= packet.getSize()) { uint8_t firstByte = packet.readUInt8(); if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (remainingBlockCount == 0) { + packet.setReadPos(start); + return false; + } + --remainingBlockCount; if (packet.getReadPos() + 4 > packet.getSize()) { packet.setReadPos(start); return false; @@ -417,6 +460,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa } } + out.blockCount = remainingBlockCount; out.blocks.reserve(out.blockCount); for (uint32_t i = 0; i < out.blockCount; ++i) { if (packet.getReadPos() >= packet.getSize()) { @@ -440,7 +484,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa break; } case UpdateType::MOVEMENT: { - block.guid = UpdateObjectParser::readPackedGuid(packet); + block.guid = packet.readUInt64(); ok = this->parseMovementBlock(packet, block); break; } @@ -507,9 +551,25 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage data.titleTextId = packet.readUInt32(); uint32_t optionCount = packet.readUInt32(); + // Cap option count to reasonable maximum + constexpr uint32_t kMaxGossipOptions = 256; + if (optionCount > kMaxGossipOptions) { + LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE optionCount=", optionCount, " exceeds max ", + kMaxGossipOptions, ", capping"); + optionCount = kMaxGossipOptions; + } + data.options.clear(); data.options.reserve(optionCount); for (uint32_t i = 0; i < optionCount; ++i) { + // Sanity check: ensure minimum bytes available for option + // (id(4)+icon(1)+isCoded(1)+boxMoney(4)+text(1)+boxText(1)) + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 12) { + LOG_WARNING("[TBC] gossip option ", i, " truncated (", remaining, " bytes left)"); + break; + } + GossipOption opt; opt.id = packet.readUInt32(); opt.icon = packet.readUInt8(); @@ -520,10 +580,34 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage data.options.push_back(opt); } + // Ensure we have at least 4 bytes for questCount + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4) { + LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE truncated before questCount"); + return data.options.size() > 0; // Return true if we got at least some options + } + uint32_t questCount = packet.readUInt32(); + + // Cap quest count to reasonable maximum + constexpr uint32_t kMaxGossipQuests = 256; + if (questCount > kMaxGossipQuests) { + LOG_WARNING("[TBC] SMSG_GOSSIP_MESSAGE questCount=", questCount, " exceeds max ", + kMaxGossipQuests, ", capping"); + questCount = kMaxGossipQuests; + } + data.quests.clear(); data.quests.reserve(questCount); for (uint32_t i = 0; i < questCount; ++i) { + // Sanity check: ensure minimum bytes available for quest + // (id(4)+icon(4)+level(4)+title(1)) + remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 13) { + LOG_WARNING("[TBC] gossip quest ", i, " truncated (", remaining, " bytes left)"); + break; + } + GossipQuestItem quest; quest.questId = packet.readUInt32(); quest.questIcon = packet.readUInt32(); @@ -738,9 +822,15 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa 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 + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = i; + data.rewardChoiceItems.push_back(ri); + } } } @@ -748,9 +838,14 @@ bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsDa 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 + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } } } @@ -874,12 +969,24 @@ bool TbcPacketParsers::parseNameQueryResponse(network::Packet& packet, NameQuery // - Has statsCount prefix (Classic reads 10 pairs with no prefix) // ============================================================================ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); if (data.entry & 0x80000000) { data.entry &= ~0x80000000; return true; } + // Validate minimum size for fixed fields: itemClass(4) + subClass(4) + soundOverride(4) + 4 name strings + displayInfoId(4) + quality(4) + if (packet.getSize() - packet.getReadPos() < 12) { + LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + uint32_t itemClass = packet.readUInt32(); uint32_t subClass = packet.readUInt32(); data.itemClass = itemClass; @@ -896,32 +1003,57 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery data.displayInfoId = packet.readUInt32(); data.quality = packet.readUInt32(); - packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2) + // Validate minimum size for fixed fields: Flags(4) + BuyPrice(4) + SellPrice(4) + inventoryType(4) + if (packet.getSize() - packet.getReadPos() < 16) { + LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before inventoryType (entry=", data.entry, ")"); + return false; + } + + data.itemFlags = packet.readUInt32(); // Flags (TBC: 1 flags field only — no Flags2) // TBC: NO Flags2, NO BuyCount packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); data.inventoryType = packet.readUInt32(); - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + // Validate minimum size for remaining fixed fields: 13×4 = 52 bytes + if (packet.getSize() - packet.getReadPos() < 52) { + LOG_ERROR("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")"); + return false; + } + + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount - data.maxStack = static_cast(packet.readUInt32()); // Stackable + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) + data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); // TBC: statsCount prefix + exactly statsCount pairs (WotLK always sends 10) + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated at statsCount (entry=", data.entry, ")"); + return true; // Have core fields; stats are optional + } uint32_t statsCount = packet.readUInt32(); - if (statsCount > 10) statsCount = 10; // sanity cap + if (statsCount > 10) { + LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: statsCount=", statsCount, " exceeds max 10 (entry=", + data.entry, "), capping"); + statsCount = 10; + } for (uint32_t i = 0; i < statsCount; i++) { + // Each stat is 2 uint32s = 8 bytes + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")"); + break; + } uint32_t statType = packet.readUInt32(); int32_t statValue = static_cast(packet.readUInt32()); switch (statType) { @@ -930,14 +1062,22 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } // TBC: NO ScalingStatDistribution, NO ScalingStatValue (WotLK-only) - // 5 damage entries + // 5 damage entries (5×12 = 60 bytes) bool haveWeaponDamage = false; for (int i = 0; i < 5; i++) { + // Each damage entry is dmgMin(4) + dmgMax(4) + damageType(4) = 12 bytes + if (packet.getSize() - packet.getReadPos() < 12) { + LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: damage ", i, " truncated (entry=", data.entry, ")"); + break; + } float dmgMin = packet.readFloat(); float dmgMax = packet.readFloat(); uint32_t damageType = packet.readUInt32(); @@ -950,18 +1090,56 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery } } + // Validate minimum size for armor (4 bytes) + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("TBC SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before armor (entry=", data.entry, ")"); + return true; // Have core fields; armor is important but optional + } data.armor = static_cast(packet.readUInt32()); if (packet.getSize() - packet.getReadPos() >= 28) { - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); } + // AmmoType + RangedModRange + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + } + + // 5 item spells + for (int i = 0; i < 5; i++) { + if (packet.getReadPos() + 24 > packet.getSize()) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Flavor/lore text + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + + // Post-description: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + data.valid = !data.name.empty(); LOG_DEBUG("[TBC] Item query: ", data.name, " quality=", data.quality, " invType=", data.inventoryType, " armor=", data.armor); @@ -1053,10 +1231,70 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector bool { + if (!(targetFlags & flag)) return true; + // Packed GUID: 1-byte mask + up to 8 data bytes + if (packet.getReadPos() >= packet.getSize()) return false; + uint8_t mask = packet.getData()[packet.getReadPos()]; + size_t needed = 1; + for (int b = 0; b < 8; ++b) if (mask & (1u << b)) ++needed; + if (packet.getSize() - packet.getReadPos() < needed) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (capture && primaryTargetGuid && *primaryTargetGuid == 0) *primaryTargetGuid = g; + return true; + }; + auto skipFloats3 = [&](uint32_t flag) -> bool { + if (!(targetFlags & flag)) return true; + if (packet.getSize() - packet.getReadPos() < 12) return false; + (void)packet.readFloat(); (void)packet.readFloat(); (void)packet.readFloat(); + return true; + }; + + // Process in wire order matching cmangos-tbc SpellCastTargets::write() + if (!readPackedGuidCond(0x0002, true)) return false; // UNIT + if (!readPackedGuidCond(0x0004, false)) return false; // UNIT_MINIPET + if (!readPackedGuidCond(0x0010, false)) return false; // ITEM + if (!skipFloats3(0x0020)) return false; // SOURCE_LOCATION + if (!skipFloats3(0x0040)) return false; // DEST_LOCATION + + if (targetFlags & 0x1000) { // TRADE_ITEM: uint8 + if (packet.getReadPos() >= packet.getSize()) return false; + (void)packet.readUInt8(); + } + if (targetFlags & 0x2000) { // STRING: null-terminated + const auto& raw = packet.getData(); + size_t pos = packet.getReadPos(); + while (pos < raw.size() && raw[pos] != 0) ++pos; + if (pos >= raw.size()) return false; + packet.setReadPos(pos + 1); + } + if (!readPackedGuidCond(0x8200, false)) return false; // CORPSE / PVP_CORPSE + if (!readPackedGuidCond(0x0800, true)) return false; // OBJECT + + return true; +} + // TbcPacketParsers::parseSpellStart — TBC 2.4.3 SMSG_SPELL_START // // TBC uses full uint64 GUIDs for casterGuid and casterUnit. @@ -1067,6 +1305,7 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector= packet.getSize()) { - LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " (no hit data)"); - return true; + LOG_WARNING("[TBC] Spell go: missing hitCount after fixed fields"); + packet.setReadPos(startPos); + return false; } - data.hitCount = packet.readUInt8(); - data.hitTargets.reserve(data.hitCount); - for (uint8_t i = 0; i < data.hitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { - data.hitTargets.push_back(packet.readUInt64()); // full GUID in TBC + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); + } + const uint8_t storedHitLimit = std::min(rawHitCount, 128); + data.hitTargets.reserve(storedHitLimit); + bool truncatedTargets = false; + for (uint16_t i = 0; i < rawHitCount; ++i) { + if (packet.getReadPos() + 8 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", i, + "/", (int)rawHitCount); + truncatedTargets = true; + break; + } + const uint64_t targetGuid = packet.readUInt64(); // full GUID in TBC + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } + } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } + data.hitCount = static_cast(data.hitTargets.size()); + + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("[TBC] Spell go: missing missCount after hit target list"); + packet.setReadPos(startPos); + return false; } - if (packet.getReadPos() < packet.getSize()) { - data.missCount = packet.readUInt8(); - data.missTargets.reserve(data.missCount); - for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) { - SpellGoMissEntry m; - m.targetGuid = packet.readUInt64(); // full GUID in TBC - m.missType = packet.readUInt8(); + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 128) { + LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)rawMissCount, ")"); + } + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount; ++i) { + if (packet.getReadPos() + 9 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; + break; + } + SpellGoMissEntry m; + m.targetGuid = packet.readUInt64(); // full GUID in TBC + m.missType = packet.readUInt8(); + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (packet.getReadPos() + 1 > packet.getSize()) { + LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i, + "/", (int)rawMissCount); + truncatedTargets = true; + break; + } + (void)packet.readUInt8(); // reflectResult + } + if (i < storedMissLimit) { data.missTargets.push_back(m); } } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } + data.missCount = static_cast(data.missTargets.size()); + + // SpellCastTargets follows the miss list — consume all target bytes so that + // any subsequent fields are not misaligned for ground-targeted AoE spells. + skipTbcSpellCastTargets(packet, &data.targetGuid); LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); @@ -1171,15 +1475,33 @@ bool TbcPacketParsers::parseCastFailed(network::Packet& packet, CastFailedData& // would mis-parse TBC's GUIDs and corrupt all subsequent damage fields. // ============================================================================ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { - if (packet.getSize() - packet.getReadPos() < 21) return false; + data = AttackerStateUpdateData{}; - data.hitInfo = packet.readUInt32(); - data.attackerGuid = packet.readUInt64(); // full GUID in TBC - data.targetGuid = packet.readUInt64(); // full GUID in TBC - data.totalDamage = static_cast(packet.readUInt32()); + const size_t startPos = packet.getReadPos(); + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + + // Fixed fields before sub-damage list: + // hitInfo(4) + attackerGuid(8) + targetGuid(8) + totalDamage(4) + subDamageCount(1) = 25 bytes + if (rem() < 25) return false; + + data.hitInfo = packet.readUInt32(); + data.attackerGuid = packet.readUInt64(); // full GUID in TBC + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); + // Clamp to what can fit in the remaining payload (20 bytes per sub-damage entry). + const uint8_t maxSubDamageCount = static_cast(std::min(rem() / 20, 64)); + if (data.subDamageCount > maxSubDamageCount) { + data.subDamageCount = maxSubDamageCount; + } + + data.subDamages.reserve(data.subDamageCount); for (uint8_t i = 0; i < data.subDamageCount; ++i) { + if (rem() < 20) { + packet.setReadPos(startPos); + return false; + } SubDamage sub; sub.schoolMask = packet.readUInt32(); sub.damage = packet.readFloat(); @@ -1189,10 +1511,17 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke data.subDamages.push_back(sub); } + data.subDamageCount = static_cast(data.subDamages.size()); + + // victimState + overkill are part of the expected payload. + if (rem() < 8) { + packet.setReadPos(startPos); + return false; + } data.victimState = packet.readUInt32(); data.overkill = static_cast(packet.readUInt32()); - if (packet.getReadPos() < packet.getSize()) { + if (rem() >= 4) { data.blocked = packet.readUInt32(); } @@ -1208,20 +1537,28 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke // TBC uses full uint64 GUIDs; WotLK uses packed GUIDs. // ============================================================================ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { - if (packet.getSize() - packet.getReadPos() < 29) return false; + // Fixed TBC payload size: + // targetGuid(8) + attackerGuid(8) + spellId(4) + damage(4) + schoolMask(1) + // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) + // = 43 bytes + // Some servers append additional trailing fields; consume the canonical minimum + // and leave any extension bytes unread. + if (packet.getSize() - packet.getReadPos() < 43) return false; - data.targetGuid = packet.readUInt64(); // full GUID in TBC + data = SpellDamageLogData{}; + + data.targetGuid = packet.readUInt64(); // full GUID in TBC data.attackerGuid = packet.readUInt64(); // full GUID in TBC - data.spellId = packet.readUInt32(); - data.damage = packet.readUInt32(); - data.schoolMask = packet.readUInt8(); - data.absorbed = packet.readUInt32(); - data.resisted = packet.readUInt32(); + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); uint8_t periodicLog = packet.readUInt8(); (void)periodicLog; - packet.readUInt8(); // unused - packet.readUInt32(); // blocked + packet.readUInt8(); // unused + packet.readUInt32(); // blocked uint32_t flags = packet.readUInt32(); data.isCrit = (flags & 0x02) != 0; @@ -1239,13 +1576,17 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL // TBC uses full uint64 GUIDs; WotLK uses packed GUIDs. // ============================================================================ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { - if (packet.getSize() - packet.getReadPos() < 25) return false; + // Fixed payload is 28 bytes; many cores append crit flag (1 byte). + // targetGuid(8) + casterGuid(8) + spellId(4) + heal(4) + overheal(4) + if (packet.getSize() - packet.getReadPos() < 28) return false; - data.targetGuid = packet.readUInt64(); // full GUID in TBC - data.casterGuid = packet.readUInt64(); // full GUID in TBC - data.spellId = packet.readUInt32(); - data.heal = packet.readUInt32(); - data.overheal = packet.readUInt32(); + data = SpellHealLogData{}; + + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.casterGuid = packet.readUInt64(); // full GUID in TBC + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); // TBC has no absorbed field in SMSG_SPELLHEALLOG; skip crit flag if (packet.getReadPos() < packet.getSize()) { uint8_t critFlag = packet.readUInt8(); @@ -1257,5 +1598,333 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa return true; } +// ============================================================================ +// TBC 2.4.3 SMSG_MESSAGECHAT +// TBC format: type(u8) + language(u32) + [type-specific data] + msgLen(u32) + msg + tag(u8) +// WotLK adds senderGuid(u64) + unknown(u32) before type-specific data. +// ============================================================================ + +bool TbcPacketParsers::parseMessageChat(network::Packet& packet, MessageChatData& data) { + if (packet.getSize() < 10) { + LOG_ERROR("[TBC] SMSG_MESSAGECHAT packet too small: ", packet.getSize(), " bytes"); + return false; + } + + uint8_t typeVal = packet.readUInt8(); + data.type = static_cast(typeVal); + + uint32_t langVal = packet.readUInt32(); + data.language = static_cast(langVal); + + // TBC: NO senderGuid or unknown field here (WotLK has senderGuid(u64) + unk(u32)) + + switch (data.type) { + case ChatType::MONSTER_SAY: + case ChatType::MONSTER_YELL: + case ChatType::MONSTER_EMOTE: + case ChatType::MONSTER_WHISPER: + case ChatType::MONSTER_PARTY: + case ChatType::RAID_BOSS_EMOTE: { + // senderGuid(u64) + nameLen(u32) + name + targetGuid(u64) + data.senderGuid = packet.readUInt64(); + uint32_t nameLen = packet.readUInt32(); + if (nameLen > 0 && nameLen < 256) { + data.senderName.resize(nameLen); + for (uint32_t i = 0; i < nameLen; ++i) { + data.senderName[i] = static_cast(packet.readUInt8()); + } + if (!data.senderName.empty() && data.senderName.back() == '\0') { + data.senderName.pop_back(); + } + } + data.receiverGuid = packet.readUInt64(); + break; + } + + case ChatType::SAY: + case ChatType::PARTY: + case ChatType::YELL: + case ChatType::WHISPER: + case ChatType::WHISPER_INFORM: + case ChatType::GUILD: + case ChatType::OFFICER: + case ChatType::RAID: + case ChatType::RAID_LEADER: + case ChatType::RAID_WARNING: + case ChatType::EMOTE: + case ChatType::TEXT_EMOTE: { + // senderGuid(u64) + senderGuid(u64) — written twice by server + data.senderGuid = packet.readUInt64(); + /*duplicateGuid*/ packet.readUInt64(); + break; + } + + case ChatType::CHANNEL: { + // channelName(string) + rank(u32) + senderGuid(u64) + data.channelName = packet.readString(); + /*uint32_t rank =*/ packet.readUInt32(); + data.senderGuid = packet.readUInt64(); + break; + } + + default: { + // All other types: senderGuid(u64) + senderGuid(u64) — written twice + data.senderGuid = packet.readUInt64(); + /*duplicateGuid*/ packet.readUInt64(); + break; + } + } + + // Read message length + message + uint32_t messageLen = packet.readUInt32(); + if (messageLen > 0 && messageLen < 8192) { + data.message.resize(messageLen); + for (uint32_t i = 0; i < messageLen; ++i) { + data.message[i] = static_cast(packet.readUInt8()); + } + if (!data.message.empty() && data.message.back() == '\0') { + data.message.pop_back(); + } + } + + // Read chat tag + if (packet.getReadPos() < packet.getSize()) { + data.chatTag = packet.readUInt8(); + } + + LOG_DEBUG("[TBC] SMSG_MESSAGECHAT: type=", getChatTypeString(data.type), + " sender=", data.senderName.empty() ? std::to_string(data.senderGuid) : data.senderName); + + return true; +} + +// ============================================================================ +// TBC 2.4.3 quest giver status +// TBC sends uint32 (like Classic), WotLK changed to uint8. +// TBC 2.4.3 enum: 0=NONE,1=UNAVAILABLE,2=CHAT,3=INCOMPLETE,4=REWARD_REP, +// 5=AVAILABLE_REP,6=AVAILABLE,7=REWARD2,8=REWARD +// ============================================================================ + +uint8_t TbcPacketParsers::readQuestGiverStatus(network::Packet& packet) { + uint32_t tbcStatus = packet.readUInt32(); + switch (tbcStatus) { + case 0: return 0; // NONE + case 1: return 1; // UNAVAILABLE + case 2: return 0; // CHAT → NONE (no marker) + case 3: return 5; // INCOMPLETE → WotLK INCOMPLETE + case 4: return 6; // REWARD_REP → WotLK REWARD_REP + case 5: return 7; // AVAILABLE_REP → WotLK AVAILABLE_LOW_LEVEL + case 6: return 8; // AVAILABLE → WotLK AVAILABLE + case 7: return 10; // REWARD2 → WotLK REWARD + case 8: return 10; // REWARD → WotLK REWARD + default: return 0; + } +} + +// ============================================================================ +// TBC 2.4.3 channel join/leave +// Classic/TBC: just name+password (no channelId/hasVoice/joinedByZone prefix) +// ============================================================================ + +network::Packet TbcPacketParsers::buildJoinChannel(const std::string& channelName, const std::string& password) { + network::Packet packet(wireOpcode(Opcode::CMSG_JOIN_CHANNEL)); + packet.writeString(channelName); + packet.writeString(password); + LOG_DEBUG("[TBC] Built CMSG_JOIN_CHANNEL: channel=", channelName); + return packet; +} + +network::Packet TbcPacketParsers::buildLeaveChannel(const std::string& channelName) { + network::Packet packet(wireOpcode(Opcode::CMSG_LEAVE_CHANNEL)); + packet.writeString(channelName); + LOG_DEBUG("[TBC] Built CMSG_LEAVE_CHANNEL: channel=", channelName); + return packet; +} + +// ============================================================================ +// TBC 2.4.3 SMSG_GAMEOBJECT_QUERY_RESPONSE +// TBC has 2 extra strings after name[4] (iconName + castBarCaption). +// WotLK has 3 (adds unk1). Classic has 0. +// ============================================================================ + +bool TbcPacketParsers::parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("TBC SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.entry = packet.readUInt32(); + + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + data.name = ""; + return true; + } + + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_ERROR("TBC SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + + data.type = packet.readUInt32(); + data.displayId = packet.readUInt32(); + // 4 name strings + data.name = packet.readString(); + packet.readString(); + packet.readString(); + packet.readString(); + + // TBC: 2 extra strings (iconName + castBarCaption) — WotLK has 3, Classic has 0 + packet.readString(); // iconName + packet.readString(); // castBarCaption + + // Read 24 type-specific data fields + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining >= 24 * 4) { + for (int i = 0; i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + data.hasData = true; + } else if (remaining > 0) { + uint32_t fieldsToRead = remaining / 4; + for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + if (fieldsToRead < 24) { + LOG_WARNING("TBC SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead, + " of 24, entry=", data.entry, ")"); + } + } + + if (data.type == 15) { // MO_TRANSPORT + LOG_DEBUG("TBC GO query: MO_TRANSPORT entry=", data.entry, + " name=\"", data.name, "\" displayId=", data.displayId, + " taxiPathId=", data.data[0], " moveSpeed=", data.data[1]); + } else { + LOG_DEBUG("TBC GO query: ", data.name, " type=", data.type, " entry=", data.entry); + } + return true; +} + +// ============================================================================ +// TBC 2.4.3 guild roster parser +// Same rank structure as WotLK (variable rankCount + goldLimit + bank tabs), +// but NO gender byte per member (WotLK added it). +// ============================================================================ + +bool TbcPacketParsers::parseGuildRoster(network::Packet& packet, GuildRosterData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("TBC SMSG_GUILD_ROSTER too small: ", packet.getSize()); + return false; + } + uint32_t numMembers = packet.readUInt32(); + + const uint32_t MAX_GUILD_MEMBERS = 1000; + if (numMembers > MAX_GUILD_MEMBERS) { + LOG_WARNING("TBC GuildRoster: numMembers capped (requested=", numMembers, ")"); + numMembers = MAX_GUILD_MEMBERS; + } + + data.motd = packet.readString(); + data.guildInfo = packet.readString(); + + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("TBC GuildRoster: truncated before rankCount"); + data.ranks.clear(); + data.members.clear(); + return true; + } + + uint32_t rankCount = packet.readUInt32(); + const uint32_t MAX_GUILD_RANKS = 20; + if (rankCount > MAX_GUILD_RANKS) { + LOG_WARNING("TBC GuildRoster: rankCount capped (requested=", rankCount, ")"); + rankCount = MAX_GUILD_RANKS; + } + + data.ranks.resize(rankCount); + for (uint32_t i = 0; i < rankCount; ++i) { + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("TBC GuildRoster: truncated rank at index ", i); + break; + } + data.ranks[i].rights = packet.readUInt32(); + if (packet.getReadPos() + 4 > packet.getSize()) { + data.ranks[i].goldLimit = 0; + } else { + data.ranks[i].goldLimit = packet.readUInt32(); + } + // 6 bank tab flags + 6 bank tab items per day (guild banks added in TBC 2.3) + for (int t = 0; t < 6; ++t) { + if (packet.getReadPos() + 8 > packet.getSize()) break; + packet.readUInt32(); // tabFlags + packet.readUInt32(); // tabItemsPerDay + } + } + + data.members.resize(numMembers); + for (uint32_t i = 0; i < numMembers; ++i) { + if (packet.getReadPos() + 9 > packet.getSize()) { + LOG_WARNING("TBC GuildRoster: truncated member at index ", i); + break; + } + auto& m = data.members[i]; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + + if (packet.getReadPos() >= packet.getSize()) { + m.name.clear(); + } else { + m.name = packet.readString(); + } + + if (packet.getReadPos() + 1 > packet.getSize()) { + m.rankIndex = 0; + m.level = 1; + m.classId = 0; + m.gender = 0; + m.zoneId = 0; + } else { + m.rankIndex = packet.readUInt32(); + if (packet.getReadPos() + 2 > packet.getSize()) { + m.level = 1; + m.classId = 0; + } else { + m.level = packet.readUInt8(); + m.classId = packet.readUInt8(); + } + // TBC: NO gender byte (WotLK added it) + m.gender = 0; + if (packet.getReadPos() + 4 > packet.getSize()) { + m.zoneId = 0; + } else { + m.zoneId = packet.readUInt32(); + } + } + + if (!m.online) { + if (packet.getReadPos() + 4 > packet.getSize()) { + m.lastOnline = 0.0f; + } else { + m.lastOnline = packet.readFloat(); + } + } + + if (packet.getReadPos() >= packet.getSize()) { + m.publicNote.clear(); + m.officerNote.clear(); + } else { + m.publicNote = packet.readString(); + if (packet.getReadPos() >= packet.getSize()) { + m.officerNote.clear(); + } else { + m.officerNote = packet.readString(); + } + } + } + LOG_INFO("Parsed TBC SMSG_GUILD_ROSTER: ", numMembers, " members, motd=", data.motd); + return true; +} + } // namespace game } // namespace wowee diff --git a/src/game/transport_manager.cpp b/src/game/transport_manager.cpp index 955f8eaa..649c9923 100644 --- a/src/game/transport_manager.cpp +++ b/src/game/transport_manager.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -31,13 +30,13 @@ void TransportManager::update(float deltaTime) { void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, uint32_t pathId, const glm::vec3& spawnWorldPos, uint32_t entry) { auto pathIt = paths_.find(pathId); if (pathIt == paths_.end()) { - std::cerr << "TransportManager: Path " << pathId << " not found for transport " << guid << std::endl; + LOG_ERROR("TransportManager: Path ", pathId, " not found for transport ", guid); return; } const auto& path = pathIt->second; if (path.points.empty()) { - std::cerr << "TransportManager: Path " << pathId << " has no waypoints" << std::endl; + LOG_ERROR("TransportManager: Path ", pathId, " has no waypoints"); return; } @@ -128,7 +127,7 @@ void TransportManager::registerTransport(uint64_t guid, uint32_t wmoInstanceId, void TransportManager::unregisterTransport(uint64_t guid) { transports_.erase(guid); - std::cout << "TransportManager: Unregistered transport " << guid << std::endl; + LOG_INFO("TransportManager: Unregistered transport ", guid); } ActiveTransport* TransportManager::getTransport(uint64_t guid) { @@ -168,7 +167,7 @@ glm::mat4 TransportManager::getTransportInvTransform(uint64_t transportGuid) { void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vector& waypoints, bool looping, float speed) { if (waypoints.empty()) { - std::cerr << "TransportManager: Cannot load empty path " << pathId << std::endl; + LOG_ERROR("TransportManager: Cannot load empty path ", pathId); return; } @@ -227,7 +226,7 @@ void TransportManager::loadPathFromNodes(uint32_t pathId, const std::vectorsetInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } return; } @@ -287,8 +288,10 @@ void TransportManager::updateTransportMovement(ActiveTransport& transport, float } else { // Strict server-authoritative mode: do not guess movement between server snapshots. updateTransformMatrices(transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + if (transport.isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport.wmoInstanceId, transport.transform); } return; } @@ -777,8 +780,10 @@ void TransportManager::updateServerTransport(uint64_t guid, const glm::vec3& pos } updateTransformMatrices(*transport); - if (wmoRenderer_) { - wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + if (transport->isM2) { + if (m2Renderer_) m2Renderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); + } else { + if (wmoRenderer_) wmoRenderer_->setInstanceTransform(transport->wmoInstanceId, transport->transform); } return; } diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 505b86e6..85ac0458 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -19,6 +19,7 @@ struct UFNameEntry { static const UFNameEntry kUFNames[] = { {"OBJECT_FIELD_ENTRY", UF::OBJECT_FIELD_ENTRY}, + {"OBJECT_FIELD_SCALE_X", UF::OBJECT_FIELD_SCALE_X}, {"UNIT_FIELD_TARGET_LO", UF::UNIT_FIELD_TARGET_LO}, {"UNIT_FIELD_TARGET_HI", UF::UNIT_FIELD_TARGET_HI}, {"UNIT_FIELD_BYTES_0", UF::UNIT_FIELD_BYTES_0}, @@ -33,10 +34,18 @@ static const UFNameEntry kUFNames[] = { {"UNIT_FIELD_DISPLAYID", UF::UNIT_FIELD_DISPLAYID}, {"UNIT_FIELD_MOUNTDISPLAYID", UF::UNIT_FIELD_MOUNTDISPLAYID}, {"UNIT_FIELD_AURAS", UF::UNIT_FIELD_AURAS}, + {"UNIT_FIELD_AURAFLAGS", UF::UNIT_FIELD_AURAFLAGS}, {"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS}, {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES}, + {"UNIT_FIELD_STAT0", UF::UNIT_FIELD_STAT0}, + {"UNIT_FIELD_STAT1", UF::UNIT_FIELD_STAT1}, + {"UNIT_FIELD_STAT2", UF::UNIT_FIELD_STAT2}, + {"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3}, + {"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4}, {"UNIT_END", UF::UNIT_END}, + {"UNIT_FIELD_ATTACK_POWER", UF::UNIT_FIELD_ATTACK_POWER}, + {"UNIT_FIELD_RANGED_ATTACK_POWER", UF::UNIT_FIELD_RANGED_ATTACK_POWER}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_BYTES", UF::PLAYER_BYTES}, {"PLAYER_BYTES_2", UF::PLAYER_BYTES_2}, @@ -46,12 +55,28 @@ static const UFNameEntry kUFNames[] = { {"PLAYER_QUEST_LOG_START", UF::PLAYER_QUEST_LOG_START}, {"PLAYER_FIELD_INV_SLOT_HEAD", UF::PLAYER_FIELD_INV_SLOT_HEAD}, {"PLAYER_FIELD_PACK_SLOT_1", UF::PLAYER_FIELD_PACK_SLOT_1}, + {"PLAYER_FIELD_KEYRING_SLOT_1", UF::PLAYER_FIELD_KEYRING_SLOT_1}, {"PLAYER_FIELD_BANK_SLOT_1", UF::PLAYER_FIELD_BANK_SLOT_1}, {"PLAYER_FIELD_BANKBAG_SLOT_1", UF::PLAYER_FIELD_BANKBAG_SLOT_1}, {"PLAYER_SKILL_INFO_START", UF::PLAYER_SKILL_INFO_START}, {"PLAYER_EXPLORED_ZONES_START", UF::PLAYER_EXPLORED_ZONES_START}, {"GAMEOBJECT_DISPLAYID", UF::GAMEOBJECT_DISPLAYID}, {"ITEM_FIELD_STACK_COUNT", UF::ITEM_FIELD_STACK_COUNT}, + {"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY}, + {"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY}, + {"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE}, + {"PLAYER_CHOSEN_TITLE", UF::PLAYER_CHOSEN_TITLE}, + {"PLAYER_FIELD_MOD_DAMAGE_DONE_POS", UF::PLAYER_FIELD_MOD_DAMAGE_DONE_POS}, + {"PLAYER_FIELD_MOD_HEALING_DONE_POS", UF::PLAYER_FIELD_MOD_HEALING_DONE_POS}, + {"PLAYER_BLOCK_PERCENTAGE", UF::PLAYER_BLOCK_PERCENTAGE}, + {"PLAYER_DODGE_PERCENTAGE", UF::PLAYER_DODGE_PERCENTAGE}, + {"PLAYER_PARRY_PERCENTAGE", UF::PLAYER_PARRY_PERCENTAGE}, + {"PLAYER_CRIT_PERCENTAGE", UF::PLAYER_CRIT_PERCENTAGE}, + {"PLAYER_RANGED_CRIT_PERCENTAGE", UF::PLAYER_RANGED_CRIT_PERCENTAGE}, + {"PLAYER_SPELL_CRIT_PERCENTAGE1", UF::PLAYER_SPELL_CRIT_PERCENTAGE1}, + {"PLAYER_FIELD_COMBAT_RATING_1", UF::PLAYER_FIELD_COMBAT_RATING_1}, + {"PLAYER_FIELD_HONOR_CURRENCY", UF::PLAYER_FIELD_HONOR_CURRENCY}, + {"PLAYER_FIELD_ARENA_CURRENCY", UF::PLAYER_FIELD_ARENA_CURRENCY}, {"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS}, {"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1}, }; diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index 5fadc408..a30a6dd6 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -1,7 +1,8 @@ #include "game/warden_emulator.hpp" -#include +#include "core/logger.hpp" #include #include +#include #ifdef HAVE_UNICORN // Unicorn Engine headers @@ -31,6 +32,8 @@ WardenEmulator::WardenEmulator() , heapBase_(HEAP_BASE) , heapSize_(HEAP_SIZE) , apiStubBase_(API_STUB_BASE) + , nextApiStubAddr_(API_STUB_BASE) + , apiCodeHookRegistered_(false) , nextHeapAddr_(HEAP_BASE) { } @@ -43,17 +46,30 @@ WardenEmulator::~WardenEmulator() { bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint32_t baseAddress) { if (uc_) { - std::cerr << "[WardenEmulator] Already initialized" << '\n'; + LOG_ERROR("WardenEmulator: Already initialized"); return false; } + // Reset allocator state so re-initialization starts with a clean heap. + allocations_.clear(); + freeBlocks_.clear(); + apiAddresses_.clear(); + apiHandlers_.clear(); + hooks_.clear(); + nextHeapAddr_ = heapBase_; + nextApiStubAddr_ = apiStubBase_; + apiCodeHookRegistered_ = false; - std::cout << "[WardenEmulator] Initializing x86 emulator (Unicorn Engine)" << '\n'; - std::cout << "[WardenEmulator] Module: " << moduleSize << " bytes at 0x" << std::hex << baseAddress << std::dec << '\n'; + { + char addrBuf[32]; + std::snprintf(addrBuf, sizeof(addrBuf), "0x%X", baseAddress); + LOG_INFO("WardenEmulator: Initializing x86 emulator (Unicorn Engine)"); + LOG_INFO("WardenEmulator: Module: ", moduleSize, " bytes at ", addrBuf); + } // Create x86 32-bit emulator uc_err err = uc_open(UC_ARCH_X86, UC_MODE_32, &uc_); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] uc_open failed: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: uc_open failed: ", uc_strerror(err)); return false; } @@ -63,9 +79,12 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // 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; + { + char buf[256]; + std::snprintf(buf, sizeof(buf), "WardenEmulator: Module [0x%X, 0x%X) overlaps heap [0x%X, 0x%X) - adjust HEAP_BASE", + moduleBase_, modEnd, heapBase_, heapBase_ + heapSize_); + LOG_ERROR(buf); + } uc_close(uc_); uc_ = nullptr; return false; @@ -74,7 +93,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map module memory (code + data) err = uc_mem_map(uc_, moduleBase_, moduleSize_, UC_PROT_ALL); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map module memory: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map module memory: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -83,7 +102,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Write module code to emulated memory err = uc_mem_write(uc_, moduleBase_, moduleCode, moduleSize); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to write module code: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to write module code: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -92,7 +111,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map stack err = uc_mem_map(uc_, stackBase_, stackSize_, UC_PROT_READ | UC_PROT_WRITE); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map stack: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map stack: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -106,7 +125,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map heap err = uc_mem_map(uc_, heapBase_, heapSize_, UC_PROT_READ | UC_PROT_WRITE); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map heap: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map heap: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -115,7 +134,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 // Map API stub area err = uc_mem_map(uc_, apiStubBase_, 0x10000, UC_PROT_ALL); if (err != UC_ERR_OK) { - std::cerr << "[WardenEmulator] Failed to map API stub area: " << uc_strerror(err) << '\n'; + LOG_ERROR("WardenEmulator: Failed to map API stub area: ", uc_strerror(err)); uc_close(uc_); uc_ = nullptr; return false; @@ -127,7 +146,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 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'; + LOG_WARNING("WardenEmulator: could not map null guard page: ", uc_strerror(err)); } // Add hooks for debugging and invalid memory access @@ -135,35 +154,70 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0); hooks_.push_back(hh); - std::cout << "[WardenEmulator] ✓ Emulator initialized successfully" << '\n'; - std::cout << "[WardenEmulator] Stack: 0x" << std::hex << stackBase_ << " - 0x" << (stackBase_ + stackSize_) << '\n'; - std::cout << "[WardenEmulator] Heap: 0x" << heapBase_ << " - 0x" << (heapBase_ + heapSize_) << std::dec << '\n'; + // Add code hook over the API stub area so Windows API calls are intercepted + uc_hook apiHook; + uc_hook_add(uc_, &apiHook, UC_HOOK_CODE, (void*)hookCode, this, + API_STUB_BASE, API_STUB_BASE + 0x10000 - 1); + hooks_.push_back(apiHook); + apiCodeHookRegistered_ = true; + + { + char sBuf[128]; + std::snprintf(sBuf, sizeof(sBuf), "WardenEmulator: Emulator initialized Stack: 0x%X-0x%X Heap: 0x%X-0x%X", + stackBase_, stackBase_ + stackSize_, heapBase_, heapBase_ + heapSize_); + LOG_INFO(sBuf); + } return true; } uint32_t WardenEmulator::hookAPI(const std::string& dllName, const std::string& functionName, - [[maybe_unused]] std::function&)> handler) { - // Allocate address for this API stub - static uint32_t nextStubAddr = API_STUB_BASE; - uint32_t stubAddr = nextStubAddr; - nextStubAddr += 16; // Space for stub code + std::function&)> handler) { + // Allocate address for this API stub (16 bytes each) + uint32_t stubAddr = nextApiStubAddr_; + nextApiStubAddr_ += 16; - // Store mapping + // Store address mapping for IAT patching apiAddresses_[dllName][functionName] = stubAddr; - std::cout << "[WardenEmulator] Hooked " << dllName << "!" << functionName - << " at 0x" << std::hex << stubAddr << std::dec << '\n'; + // Determine stdcall arg count from known Windows APIs so the hook can + // clean up the stack correctly (RETN N convention). + static const std::pair knownArgCounts[] = { + {"VirtualAlloc", 4}, + {"VirtualFree", 3}, + {"GetTickCount", 0}, + {"Sleep", 1}, + {"GetCurrentThreadId", 0}, + {"GetCurrentProcessId", 0}, + {"ReadProcessMemory", 5}, + }; + int argCount = 0; + for (const auto& [name, cnt] : knownArgCounts) { + if (functionName == name) { argCount = cnt; break; } + } - // TODO: Write stub code that triggers a hook callback - // For now, just return the address for IAT patching + // Store the handler so hookCode() can dispatch to it + apiHandlers_[stubAddr] = { argCount, std::move(handler) }; + + // Write a RET (0xC3) at the stub address as a safe fallback in case + // the code hook fires after EIP has already advanced past our intercept. + if (uc_) { + static const uint8_t retInstr = 0xC3; + uc_mem_write(uc_, stubAddr, &retInstr, 1); + } + + { + char hBuf[64]; + std::snprintf(hBuf, sizeof(hBuf), "0x%X (argCount=%d)", stubAddr, argCount); + LOG_DEBUG("WardenEmulator: Hooked ", dllName, "!", functionName, " at ", hBuf); + } return stubAddr; } void WardenEmulator::setupCommonAPIHooks() { - std::cout << "[WardenEmulator] Setting up common Windows API hooks..." << '\n'; + LOG_INFO("WardenEmulator: Setting up common Windows API hooks..."); // kernel32.dll hookAPI("kernel32.dll", "VirtualAlloc", apiVirtualAlloc); @@ -174,7 +228,7 @@ void WardenEmulator::setupCommonAPIHooks() { hookAPI("kernel32.dll", "GetCurrentProcessId", apiGetCurrentProcessId); hookAPI("kernel32.dll", "ReadProcessMemory", apiReadProcessMemory); - std::cout << "[WardenEmulator] ✓ Common API hooks registered" << '\n'; + LOG_INFO("WardenEmulator: Common API hooks registered"); } uint32_t WardenEmulator::writeData(const void* data, size_t size) { @@ -198,12 +252,15 @@ std::vector WardenEmulator::readData(uint32_t address, size_t size) { uint32_t WardenEmulator::callFunction(uint32_t address, const std::vector& args) { if (!uc_) { - std::cerr << "[WardenEmulator] Not initialized" << '\n'; + LOG_ERROR("WardenEmulator: Not initialized"); return 0; } - std::cout << "[WardenEmulator] Calling function at 0x" << std::hex << address << std::dec - << " with " << args.size() << " args" << '\n'; + { + char aBuf[32]; + std::snprintf(aBuf, sizeof(aBuf), "0x%X", address); + LOG_DEBUG("WardenEmulator: Calling function at ", aBuf, " with ", args.size(), " args"); + } // Get current ESP uint32_t esp; @@ -227,7 +284,7 @@ uint32_t WardenEmulator::callFunction(uint32_t address, const std::vector(size); - if (nextHeapAddr_ + size > heapBase_ + heapSize_) { - std::cerr << "[WardenEmulator] Heap exhausted" << '\n'; + // First-fit from free list so released blocks can be reused. + for (auto it = freeBlocks_.begin(); it != freeBlocks_.end(); ++it) { + if (it->second < size) continue; + const uint32_t addr = it->first; + const size_t blockSz = it->second; + freeBlocks_.erase(it); + if (blockSz > size) + freeBlocks_[addr + allocSize] = blockSz - size; + allocations_[addr] = size; + { + char mBuf[32]; + std::snprintf(mBuf, sizeof(mBuf), "0x%X", addr); + LOG_DEBUG("WardenEmulator: Reused ", size, " bytes at ", mBuf); + } + return addr; + } + + const uint64_t heapEnd = static_cast(heapBase_) + heapSize_; + if (static_cast(nextHeapAddr_) + size > heapEnd) { + LOG_ERROR("WardenEmulator: Heap exhausted"); return 0; } uint32_t addr = nextHeapAddr_; - nextHeapAddr_ += size; - + nextHeapAddr_ += allocSize; allocations_[addr] = size; - std::cout << "[WardenEmulator] Allocated " << size << " bytes at 0x" << std::hex << addr << std::dec << '\n'; + { + char mBuf[32]; + std::snprintf(mBuf, sizeof(mBuf), "0x%X", addr); + LOG_DEBUG("WardenEmulator: Allocated ", size, " bytes at ", mBuf); + } return addr; } @@ -283,12 +368,54 @@ uint32_t WardenEmulator::allocateMemory(size_t size, [[maybe_unused]] uint32_t p bool WardenEmulator::freeMemory(uint32_t address) { auto it = allocations_.find(address); if (it == allocations_.end()) { - std::cerr << "[WardenEmulator] Invalid free at 0x" << std::hex << address << std::dec << '\n'; + { + char fBuf[32]; + std::snprintf(fBuf, sizeof(fBuf), "0x%X", address); + LOG_ERROR("WardenEmulator: Invalid free at ", fBuf); + } return false; } - std::cout << "[WardenEmulator] Freed " << it->second << " bytes at 0x" << std::hex << address << std::dec << '\n'; + { + char fBuf[32]; + std::snprintf(fBuf, sizeof(fBuf), "0x%X", address); + LOG_DEBUG("WardenEmulator: Freed ", it->second, " bytes at ", fBuf); + } + + const size_t freedSize = it->second; allocations_.erase(it); + + // Insert in free list and coalesce adjacent blocks to limit fragmentation. + auto [curr, inserted] = freeBlocks_.emplace(address, freedSize); + if (!inserted) curr->second += freedSize; + + if (curr != freeBlocks_.begin()) { + auto prev = std::prev(curr); + if (static_cast(prev->first) + prev->second == curr->first) { + prev->second += curr->second; + freeBlocks_.erase(curr); + curr = prev; + } + } + + auto next = std::next(curr); + if (next != freeBlocks_.end() && + static_cast(curr->first) + curr->second == next->first) { + curr->second += next->second; + freeBlocks_.erase(next); + } + + // Roll back the bump pointer if the highest free block reaches it. + while (!freeBlocks_.empty()) { + auto last = std::prev(freeBlocks_.end()); + if (static_cast(last->first) + last->second == nextHeapAddr_) { + nextHeapAddr_ = last->first; + freeBlocks_.erase(last); + } else { + break; + } + } + return true; } @@ -319,8 +446,12 @@ uint32_t WardenEmulator::apiVirtualAlloc(WardenEmulator& emu, const std::vector< uint32_t flAllocationType = args[2]; uint32_t flProtect = args[3]; - std::cout << "[WinAPI] VirtualAlloc(0x" << std::hex << lpAddress << ", " << std::dec - << dwSize << ", 0x" << std::hex << flAllocationType << ", 0x" << flProtect << ")" << std::dec << '\n'; + { + char vBuf[128]; + std::snprintf(vBuf, sizeof(vBuf), "WinAPI: VirtualAlloc(0x%X, %u, 0x%X, 0x%X)", + lpAddress, dwSize, flAllocationType, flProtect); + LOG_DEBUG(vBuf); + } // Ignore lpAddress hint for now return emu.allocateMemory(dwSize, flProtect); @@ -332,7 +463,11 @@ uint32_t WardenEmulator::apiVirtualFree(WardenEmulator& emu, const std::vector(now.time_since_epoch()).count(); uint32_t ticks = static_cast(ms & 0xFFFFFFFF); - std::cout << "[WinAPI] GetTickCount() = " << ticks << '\n'; + LOG_DEBUG("WinAPI: GetTickCount() = ", ticks); return ticks; } @@ -350,18 +485,18 @@ uint32_t WardenEmulator::apiSleep([[maybe_unused]] WardenEmulator& emu, const st if (args.size() < 1) return 0; uint32_t dwMilliseconds = args[0]; - std::cout << "[WinAPI] Sleep(" << dwMilliseconds << ")" << '\n'; + LOG_DEBUG("WinAPI: Sleep(", dwMilliseconds, ")"); // Don't actually sleep in emulator return 0; } uint32_t WardenEmulator::apiGetCurrentThreadId([[maybe_unused]] WardenEmulator& emu, [[maybe_unused]] const std::vector& args) { - std::cout << "[WinAPI] GetCurrentThreadId() = 1234" << '\n'; + LOG_DEBUG("WinAPI: GetCurrentThreadId() = 1234"); return 1234; // Fake thread ID } uint32_t WardenEmulator::apiGetCurrentProcessId([[maybe_unused]] WardenEmulator& emu, [[maybe_unused]] const std::vector& args) { - std::cout << "[WinAPI] GetCurrentProcessId() = 5678" << '\n'; + LOG_DEBUG("WinAPI: GetCurrentProcessId() = 5678"); return 5678; // Fake process ID } @@ -375,8 +510,11 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve uint32_t nSize = args[3]; uint32_t lpNumberOfBytesRead = args[4]; - std::cout << "[WinAPI] ReadProcessMemory(0x" << std::hex << lpBaseAddress - << ", " << std::dec << nSize << " bytes)" << '\n'; + { + char rBuf[64]; + std::snprintf(rBuf, sizeof(rBuf), "WinAPI: ReadProcessMemory(0x%X, %u bytes)", lpBaseAddress, nSize); + LOG_DEBUG(rBuf); + } // Read from emulated memory and write to buffer std::vector data(nSize); @@ -399,8 +537,40 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve // Unicorn Callbacks // ============================================================================ -void WardenEmulator::hookCode([[maybe_unused]] uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, [[maybe_unused]] void* userData) { - std::cout << "[Trace] 0x" << std::hex << address << std::dec << '\n'; +void WardenEmulator::hookCode(uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, void* userData) { + auto* self = static_cast(userData); + if (!self) return; + + auto it = self->apiHandlers_.find(static_cast(address)); + if (it == self->apiHandlers_.end()) return; // not an API stub — trace disabled to avoid spam + + const ApiHookEntry& entry = it->second; + + // Read stack: [ESP+0] = return address, [ESP+4..] = stdcall args + uint32_t esp = 0; + uc_reg_read(uc, UC_X86_REG_ESP, &esp); + + uint32_t retAddr = 0; + uc_mem_read(uc, esp, &retAddr, 4); + + std::vector args(static_cast(entry.argCount)); + for (int i = 0; i < entry.argCount; ++i) { + uint32_t val = 0; + uc_mem_read(uc, esp + 4 + static_cast(i) * 4, &val, 4); + args[static_cast(i)] = val; + } + + // Dispatch to the C++ handler + uint32_t retVal = 0; + if (entry.handler) { + retVal = entry.handler(*self, args); + } + + // Simulate stdcall epilogue: pop return address + args + uint32_t newEsp = esp + 4 + static_cast(entry.argCount) * 4; + uc_reg_write(uc, UC_X86_REG_EAX, &retVal); + uc_reg_write(uc, UC_X86_REG_ESP, &newEsp); + uc_reg_write(uc, UC_X86_REG_EIP, &retAddr); } void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, uint64_t address, int size, [[maybe_unused]] int64_t value, [[maybe_unused]] void* userData) { @@ -415,9 +585,12 @@ void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, ui case UC_MEM_FETCH_PROT: typeStr = "FETCH_PROT"; break; } - std::cerr << "[WardenEmulator] Invalid memory access: " << typeStr - << " at 0x" << std::hex << address << std::dec - << " (size=" << size << ")" << '\n'; + { + char mBuf[128]; + std::snprintf(mBuf, sizeof(mBuf), "WardenEmulator: Invalid memory access: %s at 0x%llX (size=%d)", + typeStr, static_cast(address), size); + LOG_ERROR(mBuf); + } } #else // !HAVE_UNICORN @@ -426,7 +599,8 @@ WardenEmulator::WardenEmulator() : uc_(nullptr), moduleBase_(0), moduleSize_(0) , stackBase_(0), stackSize_(0) , heapBase_(0), heapSize_(0) - , apiStubBase_(0), nextHeapAddr_(0) {} + , apiStubBase_(0), nextApiStubAddr_(0), apiCodeHookRegistered_(false) + , nextHeapAddr_(0) {} WardenEmulator::~WardenEmulator() {} bool WardenEmulator::initialize(const void*, size_t, uint32_t) { return false; } uint32_t WardenEmulator::hookAPI(const std::string&, const std::string&, diff --git a/src/game/warden_memory.cpp b/src/game/warden_memory.cpp index c5a0afd2..5b13456a 100644 --- a/src/game/warden_memory.cpp +++ b/src/game/warden_memory.cpp @@ -1,5 +1,6 @@ #include "game/warden_memory.hpp" #include "core/logger.hpp" +#include #include #include #include @@ -7,6 +8,8 @@ #include #include #include +#include +#include namespace wowee { namespace game { @@ -106,19 +109,181 @@ bool WardenMemory::parsePE(const std::vector& fileData) { " size=0x", copySize, std::dec); } + LOG_WARNING("WardenMemory: PE loaded — imageBase=0x", std::hex, imageBase_, + " imageSize=0x", imageSize_, std::dec, + " (", numSections, " sections, ", fileData.size(), " bytes on disk)"); + return true; } void WardenMemory::initKuserSharedData() { std::memset(kuserData_, 0, KUSER_SIZE); - // NtMajorVersion at offset 0x026C = 6 (Vista/7/8/10) - uint32_t ntMajor = 6; - std::memcpy(kuserData_ + 0x026C, &ntMajor, 4); + // ------------------------------------------------------------------- + // KUSER_SHARED_DATA layout — Windows 7 SP1 x86 (from ntddk.h PDB) + // Warden reads this in 238-byte chunks for OS fingerprinting. + // All offsets verified against the canonical _KUSER_SHARED_DATA struct. + // ------------------------------------------------------------------- - // NtMinorVersion at offset 0x0270 = 1 (Windows 7) - uint32_t ntMinor = 1; - std::memcpy(kuserData_ + 0x0270, &ntMinor, 4); + auto w32 = [&](uint32_t off, uint32_t v) { std::memcpy(kuserData_ + off, &v, 4); }; + auto w16 = [&](uint32_t off, uint16_t v) { std::memcpy(kuserData_ + off, &v, 2); }; + auto w8 = [&](uint32_t off, uint8_t v) { kuserData_[off] = v; }; + + // +0x000 TickCountLowDeprecated (ULONG) + w32(0x0000, 0x003F4A00); // ~70 min uptime + + // +0x004 TickCountMultiplier (ULONG) + w32(0x0004, 0x0FA00000); + + // +0x008 InterruptTime (KSYSTEM_TIME: Low4 + High1_4 + High2_4) + w32(0x0008, 0x6B49D200); + w32(0x000C, 0x00000029); + w32(0x0010, 0x00000029); + + // +0x014 SystemTime (KSYSTEM_TIME) — ~2024 epoch FILETIME + w32(0x0014, 0xA0B71B00); + w32(0x0018, 0x01DA5E80); + w32(0x001C, 0x01DA5E80); + + // +0x020 TimeZoneBias (KSYSTEM_TIME) — 0 = UTC + // (leave zeros) + + // +0x02C ImageNumberLow / ImageNumberHigh (USHORT each) + w16(0x002C, 0x014C); // IMAGE_FILE_MACHINE_I386 + w16(0x002E, 0x014C); + + // +0x030 NtSystemRoot (WCHAR[260] = 520 bytes, ends at +0x238) + const wchar_t* sysRoot = L"C:\\WINDOWS"; + for (size_t i = 0; i < 10; i++) { + w16(0x0030 + static_cast(i) * 2, static_cast(sysRoot[i])); + } + + // +0x238 MaxStackTraceDepth (ULONG) + w32(0x0238, 0); + + // +0x23C CryptoExponent (ULONG) — 65537 + w32(0x023C, 0x00010001); + + // +0x240 TimeZoneId (ULONG) — TIME_ZONE_ID_UNKNOWN + w32(0x0240, 0); + + // +0x244 LargePageMinimum (ULONG) — 2 MB + w32(0x0244, 0x00200000); + + // +0x248 Reserved2[7] (28 bytes) — zeros + // (leave zeros) + + // +0x264 NtProductType (NT_PRODUCT_TYPE = ULONG) — VER_NT_WORKSTATION + w32(0x0264, 1); + + // +0x268 ProductTypeIsValid (BOOLEAN = UCHAR) + w8(0x0268, 1); + + // +0x269 Reserved9[3] — padding + // (leave zeros) + + // +0x26C NtMajorVersion (ULONG) — 6 (Windows Vista/7/8/10) + w32(0x026C, 6); + + // +0x270 NtMinorVersion (ULONG) — 1 (Windows 7) + w32(0x0270, 1); + + // +0x274 ProcessorFeatures (BOOLEAN[64] = 64 bytes, ends at +0x2B4) + // Each entry is a single UCHAR (0 or 1). + // Index Name Value + // [0] PF_FLOATING_POINT_PRECISION_ERRATA 0 + // [1] PF_FLOATING_POINT_EMULATED 0 + // [2] PF_COMPARE_EXCHANGE_DOUBLE 1 + // [3] PF_MMX_INSTRUCTIONS_AVAILABLE 1 + // [4] PF_PPC_MOVEMEM_64BIT_OK 0 + // [5] PF_ALPHA_BYTE_INSTRUCTIONS 0 + // [6] PF_XMMI_INSTRUCTIONS_AVAILABLE (SSE) 1 + // [7] PF_3DNOW_INSTRUCTIONS_AVAILABLE 0 + // [8] PF_RDTSC_INSTRUCTION_AVAILABLE 1 + // [9] PF_PAE_ENABLED 1 + // [10] PF_XMMI64_INSTRUCTIONS_AVAILABLE(SSE2)1 + // [11] PF_SSE_DAZ_MODE_AVAILABLE 0 + // [12] PF_NX_ENABLED 1 + // [13] PF_SSE3_INSTRUCTIONS_AVAILABLE 1 + // [14] PF_COMPARE_EXCHANGE128 0 (x86 typically 0) + // [15] PF_COMPARE64_EXCHANGE128 0 + // [16] PF_CHANNELS_ENABLED 0 + // [17] PF_XSAVE_ENABLED 0 + w8(0x0274 + 2, 1); // PF_COMPARE_EXCHANGE_DOUBLE + w8(0x0274 + 3, 1); // PF_MMX + w8(0x0274 + 6, 1); // PF_SSE + w8(0x0274 + 8, 1); // PF_RDTSC + w8(0x0274 + 9, 1); // PF_PAE_ENABLED + w8(0x0274 + 10, 1); // PF_SSE2 + w8(0x0274 + 12, 1); // PF_NX_ENABLED + w8(0x0274 + 13, 1); // PF_SSE3 + + // +0x2B4 Reserved1 (ULONG) + // +0x2B8 Reserved3 (ULONG) + // +0x2BC TimeSlip (ULONG) + // +0x2C0 AlternativeArchitecture (ULONG) = 0 (StandardDesign) + // +0x2C4 AltArchitecturePad[1] (ULONG) + // +0x2C8 SystemExpirationDate (LARGE_INTEGER = 8 bytes) + // (leave zeros) + + // +0x2D0 SuiteMask (ULONG) — VER_SUITE_SINGLEUSERTS | VER_SUITE_TERMINAL + w32(0x02D0, 0x0110); // 0x0100=SINGLEUSERTS, 0x0010=TERMINAL + + // +0x2D4 KdDebuggerEnabled (BOOLEAN = UCHAR) + w8(0x02D4, 0); + + // +0x2D5 NXSupportPolicy (UCHAR) — 2 = OptIn + w8(0x02D5, 2); + + // +0x2D6 Reserved6[2] + // (leave zeros) + + // +0x2D8 ActiveConsoleId (ULONG) — session 0 or 1 + w32(0x02D8, 1); + + // +0x2DC DismountCount (ULONG) + w32(0x02DC, 0); + + // +0x2E0 ComPlusPackage (ULONG) + w32(0x02E0, 0); + + // +0x2E4 LastSystemRITEventTickCount (ULONG) — recent input tick + w32(0x02E4, 0x003F4900); + + // +0x2E8 NumberOfPhysicalPages (ULONG) — 4GB / 4KB ≈ 1M pages + w32(0x02E8, 0x000FF000); + + // +0x2EC SafeBootMode (BOOLEAN) — 0 = normal boot + w8(0x02EC, 0); + + // +0x2F0 SharedDataFlags / TraceLogging (ULONG) + w32(0x02F0, 0); + + // +0x2F8 TestRetInstruction (ULONGLONG = 8 bytes) — RET opcode + w8(0x02F8, 0xC3); // x86 RET instruction + + // +0x300 SystemCall (ULONG) + w32(0x0300, 0); + + // +0x304 SystemCallReturn (ULONG) + w32(0x0304, 0); + + // +0x308 SystemCallPad[3] (24 bytes) + // (leave zeros) + + // +0x320 TickCount (KSYSTEM_TIME) — matches TickCountLowDeprecated + w32(0x0320, 0x003F4A00); + + // +0x32C TickCountPad[1] + // (leave zeros) + + // +0x330 Cookie (ULONG) — stack cookie, random-looking value + w32(0x0330, 0x4A2F8C15); + + // +0x334 ConsoleSessionForegroundProcessId (ULONG) — some PID + w32(0x0334, 0x00001234); + + // Everything after +0x338 is typically zero on Win7 x86 } void WardenMemory::writeLE32(uint32_t va, uint32_t value) { @@ -132,56 +297,52 @@ void WardenMemory::writeLE32(uint32_t va, uint32_t value) { } void WardenMemory::patchRuntimeGlobals() { - // Only patch Classic 1.12.1 (build 5875) WoW.exe - // Identified by: ImageBase=0x400000, ImageSize=0x906000 (unique to 1.12.1) - // Other expansions have different image sizes and different global addresses. - if (imageBase_ != 0x00400000 || imageSize_ != 0x00906000) { - LOG_INFO("WardenMemory: Not Classic 1.12.1 WoW.exe (imageSize=0x", - std::hex, imageSize_, std::dec, "), skipping runtime global patches"); + if (imageBase_ != 0x00400000) { + LOG_WARNING("WardenMemory: unexpected imageBase=0x", std::hex, imageBase_, std::dec, + " — skipping runtime global patches"); return; } // Classic 1.12.1 (build 5875) runtime globals - // These are in the .data BSS region - zero on disk, populated at runtime. - // We patch them with fake but valid values so Warden checks pass. + // VMaNGOS has TWO types of Warden scans that read these addresses: // - // Offsets from CMaNGOS anticheat module (wardenwin.cpp): - // WardenModule = 0xCE897C - // OfsWardenSysInfo = 0x228 - // OfsWardenWinSysInfo = 0x08 - // g_theGxDevicePtr = 0xC0ED38 - // OfsDevice2 = 0x38A8 - // OfsDevice3 = 0x0 - // OfsDevice4 = 0xA8 - // WorldEnables = 0xC7B2A4 - // LastHardwareAction= 0xCF0BC8 + // 1. DB-driven scans (warden_scans table): memcmp against expected bytes. + // These check CODE sections for integrity — never check runtime data addresses. + // + // 2. Scripted scans (WardenWin::LoadScriptedScans): READ and INTERPRET values. + // - "Warden locate" reads 0xCE897C as a pointer, follows chain to SYSTEM_INFO + // - "Anti-AFK hack" reads 0xCF0BC8 as a timestamp, compares vs TIMING ticks + // - "CWorld::enables" reads 0xC7B2A4, checks flag bits + // - "EndScene" reads 0xC0ED38, follows pointer chain to find EndScene address + // + // We MUST patch these for ALL clients (including Turtle WoW) because the scripted + // scans interpret the values as runtime state, not static code bytes. Returning + // raw PE data causes the Anti-AFK scan to see lastHardwareAction > currentTime + // (PE bytes happen to be a large value), triggering a kick after ~3.5 minutes. - // === Warden SYSTEM_INFO chain (3-level pointer chain) === - // Stage 0: [0xCE897C] → fake warden struct base + // === Runtime global patches (applied unconditionally for all image variants) === + + // Warden SYSTEM_INFO chain constexpr uint32_t WARDEN_MODULE_PTR = 0xCE897C; constexpr uint32_t FAKE_WARDEN_BASE = 0xCE8000; writeLE32(WARDEN_MODULE_PTR, FAKE_WARDEN_BASE); - - // Stage 1: [FAKE_WARDEN_BASE + 0x228] → pointer to sysinfo container - constexpr uint32_t OFS_WARDEN_SYSINFO = 0x228; constexpr uint32_t FAKE_SYSINFO_CONTAINER = 0xCE8300; - writeLE32(FAKE_WARDEN_BASE + OFS_WARDEN_SYSINFO, FAKE_SYSINFO_CONTAINER); + writeLE32(FAKE_WARDEN_BASE + 0x228, FAKE_SYSINFO_CONTAINER); - // Stage 2: [FAKE_SYSINFO_CONTAINER + 0x08] → 36-byte SYSTEM_INFO struct - constexpr uint32_t OFS_WARDEN_WIN_SYSINFO = 0x08; - uint32_t sysInfoAddr = FAKE_SYSINFO_CONTAINER + OFS_WARDEN_WIN_SYSINFO; // 0xCE8308 - // WIN_SYSTEM_INFO is 36 bytes (0x24): - // uint16 wProcessorArchitecture (must be 0 = x86) - // uint16 wReserved - // uint32 dwPageSize - // uint32 lpMinimumApplicationAddress - // uint32 lpMaximumApplicationAddress (MUST be non-zero!) - // uint32 dwActiveProcessorMask - // uint32 dwNumberOfProcessors - // uint32 dwProcessorType (must be 386, 486, or 586) - // uint32 dwAllocationGranularity - // uint16 wProcessorLevel - // uint16 wProcessorRevision + // Write SYSINFO pointer at many offsets from FAKE_WARDEN_BASE so the + // chain works regardless of which module-specific offset the server uses. + // MUST be done BEFORE writing the actual SYSTEM_INFO struct, because this + // loop's range (0xCE8200-0xCE8400) overlaps with the struct at 0xCE8308. + for (uint32_t off = 0x200; off <= 0x400; off += 4) { + uint32_t addr = FAKE_WARDEN_BASE + off; + if (addr >= imageBase_ && (addr - imageBase_) + 4 <= imageSize_) { + writeLE32(addr, FAKE_SYSINFO_CONTAINER); + } + } + + // Now write the actual WIN_SYSTEM_INFO struct AFTER the pointer fill loop, + // so it overwrites any values the loop placed in the 0xCE8308+ range. + uint32_t sysInfoAddr = FAKE_SYSINFO_CONTAINER + 0x08; #pragma pack(push, 1) struct { uint16_t wProcessorArchitecture; @@ -195,19 +356,7 @@ void WardenMemory::patchRuntimeGlobals() { uint32_t dwAllocationGranularity; uint16_t wProcessorLevel; uint16_t wProcessorRevision; - } sysInfo = { - 0, // x86 - 0, - 4096, // 4K page size - 0x00010000, // min app address - 0x7FFEFFFF, // max app address (CRITICAL: must be non-zero) - 0x0F, // 4 processors - 4, // 4 CPUs - 586, // Pentium - 65536, // 64K granularity - 6, // P6 family - 0x3A09 // revision - }; + } sysInfo = {0, 0, 4096, 0x00010000, 0x7FFEFFFF, 0x0F, 4, 586, 65536, 6, 0x3A09}; #pragma pack(pop) static_assert(sizeof(sysInfo) == 36, "SYSTEM_INFO must be 36 bytes"); uint32_t rva = sysInfoAddr - imageBase_; @@ -215,52 +364,203 @@ void WardenMemory::patchRuntimeGlobals() { std::memcpy(image_.data() + rva, &sysInfo, 36); } - LOG_INFO("WardenMemory: Patched SYSTEM_INFO chain: [0x", std::hex, - WARDEN_MODULE_PTR, "]→0x", FAKE_WARDEN_BASE, - " [0x", FAKE_WARDEN_BASE + OFS_WARDEN_SYSINFO, "]→0x", FAKE_SYSINFO_CONTAINER, - " SYSTEM_INFO@0x", sysInfoAddr, std::dec); + // Fallback: if the pointer chain breaks and stage 3 reads from address + // 0x00000000 + 0x08 = 8, write valid SYSINFO at RVA 8 (PE DOS header area). + if (8 + 36 <= imageSize_) { + std::memcpy(image_.data() + 8, &sysInfo, 36); + } - // === EndScene chain (4-level pointer chain) === - // Stage 1: [0xC0ED38] → fake D3D device + LOG_WARNING("WardenMemory: Patched SYSINFO chain @0x", std::hex, WARDEN_MODULE_PTR, std::dec); + + // EndScene chain + // VMaNGOS reads g_theGxDevicePtr → device, then device+0x1FC for API kind + // (0=OpenGL, 1=Direct3D). If Direct3D, follows device+0x38A8 → ptr → ptr+0xA8 → EndScene. + // We set API=1 (Direct3D) and provide the full pointer chain. constexpr uint32_t GX_DEVICE_PTR = 0xC0ED38; constexpr uint32_t FAKE_DEVICE = 0xCE8400; writeLE32(GX_DEVICE_PTR, FAKE_DEVICE); + writeLE32(FAKE_DEVICE + 0x1FC, 1); // API kind = Direct3D + // Set up the full EndScene pointer chain at the canonical offsets. + constexpr uint32_t FAKE_VTABLE1 = 0xCE8500; + constexpr uint32_t FAKE_VTABLE2 = 0xCE8600; + constexpr uint32_t FAKE_ENDSCENE = 0x00401000; // start of .text + writeLE32(FAKE_DEVICE + 0x38A8, FAKE_VTABLE1); + writeLE32(FAKE_VTABLE1, FAKE_VTABLE2); + writeLE32(FAKE_VTABLE2 + 0xA8, FAKE_ENDSCENE); - // Stage 2: [FAKE_DEVICE + 0x38A8] → fake intermediate - constexpr uint32_t OFS_DEVICE2 = 0x38A8; - constexpr uint32_t FAKE_INTERMEDIATE = 0xCE8500; - writeLE32(FAKE_DEVICE + OFS_DEVICE2, FAKE_INTERMEDIATE); + // The EndScene device+sOfsDevice2 offset may differ from 0x38A8 in Turtle WoW. + // Also set API=1 (Direct3D) at multiple offsets so the API kind check passes. + // Fill the entire fake device area with the vtable pointer for robustness. + for (uint32_t off = 0x3800; off <= 0x3A00; off += 4) { + uint32_t addr = FAKE_DEVICE + off; + if (addr >= imageBase_ && (addr - imageBase_) + 4 <= imageSize_) { + writeLE32(addr, FAKE_VTABLE1); + } + } + LOG_WARNING("WardenMemory: Patched EndScene chain @0x", std::hex, GX_DEVICE_PTR, std::dec); - // Stage 3: [FAKE_INTERMEDIATE + 0x0] → fake vtable - constexpr uint32_t OFS_DEVICE3 = 0x0; - constexpr uint32_t FAKE_VTABLE = 0xCE8600; - writeLE32(FAKE_INTERMEDIATE + OFS_DEVICE3, FAKE_VTABLE); - - // Stage 4: [FAKE_VTABLE + 0xA8] → address of "EndScene" function - // Point to a real .text address with normal code (not 0xE9/0xCC = not hooked) - constexpr uint32_t OFS_DEVICE4 = 0xA8; - constexpr uint32_t FAKE_ENDSCENE = 0x00401000; // Start of .text section - writeLE32(FAKE_VTABLE + OFS_DEVICE4, FAKE_ENDSCENE); - - LOG_INFO("WardenMemory: Patched EndScene chain: [0x", std::hex, - GX_DEVICE_PTR, "]→0x", FAKE_DEVICE, - " ... →EndScene@0x", FAKE_ENDSCENE, std::dec); - - // === WorldEnables (single value) === - // Required flags: TerrainDoodads|Terrain|MapObjects|MapObjectLighting|MapObjectTextures|Water - // Plus typical defaults (no Prohibited bits set) + // WorldEnables constexpr uint32_t WORLD_ENABLES = 0xC7B2A4; uint32_t enables = 0x1 | 0x2 | 0x10 | 0x20 | 0x40 | 0x100 | 0x200 | 0x400 | 0x800 | 0x8000 | 0x10000 | 0x100000 | 0x1000000 | 0x2000000 | 0x4000000 | 0x8000000 | 0x10000000; writeLE32(WORLD_ENABLES, enables); - LOG_INFO("WardenMemory: Patched WorldEnables=0x", std::hex, enables, std::dec); + LOG_WARNING("WardenMemory: Patched WorldEnables @0x", std::hex, WORLD_ENABLES, std::dec); - // === LastHardwareAction (tick count) === - // Must be <= currentTime from timing check. Set to a plausible value. + // LastHardwareAction — must be a recent GetTickCount()-style timestamp + // so the anti-AFK scan sees (currentTime - lastAction) < threshold. constexpr uint32_t LAST_HARDWARE_ACTION = 0xCF0BC8; - writeLE32(LAST_HARDWARE_ACTION, 60000); // 1 minute - LOG_INFO("WardenMemory: Patched LastHardwareAction=60000ms"); + uint32_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + writeLE32(LAST_HARDWARE_ACTION, nowMs - 2000); + LOG_WARNING("WardenMemory: Patched LastHardwareAction @0x", std::hex, LAST_HARDWARE_ACTION, std::dec); + + // Embed the 37-byte Warden module memcpy pattern in BSS so that + // FIND_CODE_BY_HASH (PAGE_B) brute-force search can find it. + // This is the pattern VMaNGOS's "Warden Memory Read check" looks for. + constexpr uint32_t MEMCPY_PATTERN_VA = 0xCE8700; + static const uint8_t kWardenMemcpyPattern[37] = { + 0x56, 0x57, 0xFC, 0x8B, 0x54, 0x24, 0x14, 0x8B, + 0x74, 0x24, 0x10, 0x8B, 0x44, 0x24, 0x0C, 0x8B, + 0xCA, 0x8B, 0xF8, 0xC1, 0xE9, 0x02, 0x74, 0x02, + 0xF3, 0xA5, 0xB1, 0x03, 0x23, 0xCA, 0x74, 0x02, + 0xF3, 0xA4, 0x5F, 0x5E, 0xC3 + }; + uint32_t patRva = MEMCPY_PATTERN_VA - imageBase_; + if (patRva + sizeof(kWardenMemcpyPattern) <= imageSize_) { + std::memcpy(image_.data() + patRva, kWardenMemcpyPattern, sizeof(kWardenMemcpyPattern)); + LOG_WARNING("WardenMemory: Embedded Warden memcpy pattern at 0x", std::hex, MEMCPY_PATTERN_VA, std::dec); + } +} + +void WardenMemory::patchTurtleWowBinary() { + // Apply TurtlePatcher byte patches to make our PE image match a real Turtle WoW client. + // These patches are applied at file offsets which equal RVAs for this PE. + // Source: TurtlePatcher/Main.cpp PatchBinary() + PatchVersion() + + auto patchBytes = [&](uint32_t fileOffset, const std::vector& bytes) { + if (fileOffset + bytes.size() > imageSize_) { + LOG_WARNING("WardenMemory: Turtle patch at 0x", std::hex, fileOffset, + " exceeds image size, skipping"); + return; + } + std::memcpy(image_.data() + fileOffset, bytes.data(), bytes.size()); + }; + + auto patchString = [&](uint32_t fileOffset, const char* str) { + size_t len = std::strlen(str) + 1; // include null terminator + if (fileOffset + len > imageSize_) return; + std::memcpy(image_.data() + fileOffset, str, len); + }; + + // --- PatchBinary() patches --- + + // Patches 1-4: Unknown purpose code patches in .text + patchBytes(0x2F113A, {0xEB, 0x19}); + patchBytes(0x2F1158, {0x03}); + patchBytes(0x2F11A7, {0x03}); + patchBytes(0x2F11F0, {0xEB, 0xB2}); + + // PvP rank check removal (6x NOP) + patchBytes(0x2093B0, {0x90, 0x90, 0x90, 0x90, 0x90, 0x90}); + + // Dwarf mage hackfix removal + patchBytes(0x0706E5, {0xFE}); + patchBytes(0x0706EB, {0xFE}); + patchBytes(0x07075D, {0xFE}); + patchBytes(0x070763, {0xFE}); + + // Emote sound race ID checks (High Elf support) + patchBytes(0x059289, {0x40}); + patchBytes(0x057C81, {0x40}); + + // Nameplate distance (41 yards) + patchBytes(0x40C448, {0x00, 0x00, 0x24, 0x42}); + + // Large address aware flag in PE header + patchBytes(0x000126, {0x2F, 0x01}); + + // Sound channel patches + patchBytes(0x05728C, {0x38, 0x5D, 0x83, 0x00}); // software channels + patchBytes(0x057250, {0x38, 0x5D, 0x83, 0x00}); // hardware channels + patchBytes(0x0572C8, {0x6C, 0x5C, 0x83, 0x00}); // memory cache + + // Sound in background (non-FoV build) + patchBytes(0x3A4869, {0x14}); + + // Hardcore chat patches + patchBytes(0x09B0B8, {0x5F}); + patchBytes(0x09B193, {0xE9, 0xA8, 0xAE, 0x86}); + patchBytes(0x09F7A5, {0x70, 0x53, 0x56, 0x33, 0xF6, 0xE9, 0x71, 0x68, 0x86, 0x00}); + patchBytes(0x09F864, {0x94}); + patchBytes(0x09F878, {0x0E}); + patchBytes(0x09F887, {0x90}); + patchBytes(0x11BAE1, {0x0C, 0x60, 0xD0}); + + // Hardcore chat code cave at 0x48E000 (85 bytes) + patchBytes(0x48E000, { + 0x48, 0x41, 0x52, 0x44, 0x43, 0x4F, 0x52, 0x45, 0x00, 0x00, 0x00, 0x00, 0x43, 0x48, 0x41, 0x54, + 0x5F, 0x4D, 0x53, 0x47, 0x5F, 0x48, 0x41, 0x52, 0x44, 0x43, 0x4F, 0x52, 0x45, 0x00, 0x00, 0x00, + 0x57, 0x8B, 0xDA, 0x8B, 0xF9, 0xC7, 0x45, 0x94, 0x00, 0x60, 0xD0, 0x00, 0xC7, 0x45, 0x90, 0x5E, + 0x00, 0x00, 0x00, 0xE9, 0x77, 0x97, 0x79, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x68, 0x08, 0x46, 0x84, 0x00, 0x83, 0x7D, 0xF0, 0x5E, 0x75, 0x05, 0xB9, 0x1F, 0x02, 0x00, 0x00, + 0xE9, 0x43, 0x51, 0x79, 0xFF + }); + + // Blue child moon patch + patchBytes(0x3E5B83, { + 0xC7, 0x05, 0xA4, 0x98, 0xCE, 0x00, 0xD4, 0xE2, 0xE7, 0xFF, 0xC2, 0x04, 0x00 + }); + + // Blue child moon timer + patchBytes(0x2D2095, {0x00, 0x00, 0x80, 0x3F}); + + // SetUnit codecave jump + patchBytes(0x105E19, {0xE9, 0x02, 0x03, 0x80, 0x00}); + + // SetUnit main code cave at 0x48E060 (291 bytes) + patchBytes(0x48E060, { + 0x55, 0x89, 0xE5, 0x83, 0xEC, 0x10, 0x85, 0xD2, 0x53, 0x56, 0x57, 0x89, 0xCF, 0x0F, 0x84, 0xA2, + 0x00, 0x00, 0x00, 0x89, 0xD0, 0x85, 0xC0, 0x0F, 0x8C, 0x98, 0x00, 0x00, 0x00, 0x3B, 0x05, 0x94, + 0xDE, 0xC0, 0x00, 0x0F, 0x8F, 0x8C, 0x00, 0x00, 0x00, 0x8B, 0x0D, 0x90, 0xDE, 0xC0, 0x00, 0x8B, + 0x04, 0x81, 0x85, 0xC0, 0x89, 0x45, 0xF0, 0x74, 0x7C, 0x8B, 0x40, 0x04, 0x85, 0xC0, 0x7C, 0x75, + 0x3B, 0x05, 0x6C, 0xDE, 0xC0, 0x00, 0x7F, 0x6D, 0x8B, 0x15, 0x68, 0xDE, 0xC0, 0x00, 0x8B, 0x1C, + 0x82, 0x85, 0xDB, 0x74, 0x60, 0x8B, 0x43, 0x08, 0x6A, 0x00, 0x50, 0x89, 0xF9, 0xE8, 0xFE, 0x6E, + 0xA6, 0xFF, 0x89, 0xC1, 0xE8, 0x87, 0x12, 0xA0, 0xFF, 0x89, 0xC6, 0x85, 0xF6, 0x74, 0x46, 0x8B, + 0x55, 0xF0, 0x53, 0x89, 0xF1, 0xE8, 0xD6, 0x36, 0x77, 0xFF, 0x8B, 0x17, 0x56, 0x89, 0xF9, 0xFF, + 0x92, 0x90, 0x00, 0x00, 0x00, 0x89, 0xF8, 0x99, 0x52, 0x50, 0x68, 0xA0, 0x62, 0x50, 0x00, 0x89, + 0xF1, 0xE8, 0xBA, 0xBA, 0xA0, 0xFF, 0x6A, 0x01, 0x6A, 0x01, 0x68, 0x00, 0x00, 0x80, 0x3F, 0x6A, + 0x00, 0x6A, 0xFF, 0x6A, 0x00, 0x6A, 0xFF, 0x89, 0xF1, 0xE8, 0x92, 0xC0, 0xA0, 0xFF, 0x89, 0xF1, + 0xE8, 0x8B, 0xA2, 0xA0, 0xFF, 0x5F, 0x5E, 0x5B, 0x89, 0xEC, 0x5D, 0xC3, 0x90, 0x90, 0x90, 0x90, + 0xBA, 0x02, 0x00, 0x00, 0x00, 0x89, 0xF1, 0xE8, 0xD4, 0xD2, 0x9E, 0xFF, 0x83, 0xF8, 0x03, 0x75, + 0x43, 0xBA, 0x02, 0x00, 0x00, 0x00, 0x89, 0xF1, 0xE8, 0xE3, 0xD4, 0x9E, 0xFF, 0xE8, 0x6E, 0x41, + 0x70, 0xFF, 0x56, 0x8B, 0xB7, 0xD4, 0x00, 0x00, 0x00, 0x31, 0xD2, 0x39, 0xD6, 0x89, 0x97, 0xE0, + 0x03, 0x00, 0x00, 0x89, 0x97, 0xE4, 0x03, 0x00, 0x00, 0x89, 0x97, 0xF0, 0x03, 0x00, 0x00, 0x5E, + 0x0F, 0x84, 0xD3, 0xFC, 0x7F, 0xFF, 0x89, 0xC2, 0x89, 0xF9, 0xE8, 0xF1, 0xFE, 0xFF, 0xFF, 0xE9, + 0xC5, 0xFC, 0x7F, 0xFF, 0xBA, 0x02, 0x00, 0x00, 0x00, 0xE9, 0xA0, 0xFC, 0x7F, 0xFF, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 + }); + + // --- PatchVersion() patches --- + + // Net version: build 7199 (0x1C1F LE) + patchBytes(0x1B2122, {0x1F, 0x1C}); + + // Visual version string + patchString(0x437C04, "1.17.2"); + + // Visual build string + patchString(0x437BFC, "7199"); + + // Build date string + patchString(0x434798, "May 20 2024"); + + // Website filters + patchString(0x45CCD8, "*.turtle-wow.org"); + patchString(0x45CC9C, "*.discord.gg"); + + LOG_WARNING("WardenMemory: Applied TurtlePatcher binary patches (build 7199)"); } bool WardenMemory::readMemory(uint32_t va, uint8_t length, uint8_t* outBuf) const { @@ -272,18 +572,40 @@ bool WardenMemory::readMemory(uint32_t va, uint8_t length, uint8_t* outBuf) cons return true; } - // PE image range - if (!loaded_ || va < imageBase_) return false; - uint32_t offset = va - imageBase_; + if (!loaded_) return false; + + // Warden MEM_CHECK offsets are seen in multiple forms: + // 1) Absolute VA (e.g. 0x00401337) + // 2) RVA (e.g. 0x000139A9) + // 3) Tiny module-relative offsets (e.g. 0x00000229, 0x00000008) + // Accept all three to avoid fallback-to-zeros on Classic/Turtle. + uint32_t offset = 0; + if (va >= imageBase_) { + // Absolute VA. + offset = va - imageBase_; + } else if (va < imageSize_) { + // RVA into WoW.exe image. + offset = va; + } else { + // Tiny relative offsets frequently target fake Warden runtime globals. + constexpr uint32_t kFakeWardenBase = 0xCE8000; + const uint32_t remappedVa = kFakeWardenBase + va; + if (remappedVa < imageBase_) return false; + offset = remappedVa - imageBase_; + } + if (static_cast(offset) + length > imageSize_) return false; std::memcpy(outBuf, image_.data() + offset, length); return true; } -uint32_t WardenMemory::expectedImageSizeForBuild(uint16_t build) { +uint32_t WardenMemory::expectedImageSizeForBuild(uint16_t build, bool isTurtle) { switch (build) { - case 5875: return 0x00906000; // Classic 1.12.1 + case 5875: + // Turtle WoW uses a custom WoW.exe with different code bytes. + // Their warden_scans DB expects bytes from this custom exe. + return isTurtle ? 0x00906000 : 0x009FD000; default: return 0; // Unknown — accept any } } @@ -301,6 +623,7 @@ std::string WardenMemory::findWowExe(uint16_t build) const { } } candidateDirs.push_back("Data/misc"); + candidateDirs.push_back("Data/expansions/turtle/overlay/misc"); const char* candidateExes[] = { "WoW.exe", "TurtleWoW.exe", "Wow.exe" }; @@ -318,7 +641,7 @@ std::string WardenMemory::findWowExe(uint16_t build) const { } // If we know the expected imageSize for this build, try to find a matching PE - uint32_t expectedSize = expectedImageSizeForBuild(build); + uint32_t expectedSize = expectedImageSizeForBuild(build, isTurtle_); if (expectedSize != 0 && allPaths.size() > 1) { for (const auto& path : allPaths) { std::ifstream f(path, std::ios::binary); @@ -342,17 +665,29 @@ std::string WardenMemory::findWowExe(uint16_t build) const { } } - // Fallback: return first available - return allPaths.empty() ? "" : allPaths[0]; + // Fallback: prefer the largest PE file (modified clients like Turtle WoW are + // larger than vanilla, and Warden checks target the actual running client). + std::string bestPath; + uintmax_t bestSize = 0; + for (const auto& path : allPaths) { + std::error_code ec; + auto sz = std::filesystem::file_size(path, ec); + if (!ec && sz > bestSize) { + bestSize = sz; + bestPath = path; + } + } + return bestPath.empty() && !allPaths.empty() ? allPaths[0] : bestPath; } -bool WardenMemory::load(uint16_t build) { +bool WardenMemory::load(uint16_t build, bool isTurtle) { + isTurtle_ = isTurtle; std::string path = findWowExe(build); if (path.empty()) { LOG_WARNING("WardenMemory: WoW.exe not found in any candidate directory"); return false; } - LOG_INFO("WardenMemory: Found ", path); + LOG_WARNING("WardenMemory: Loading PE image: ", path, " (build=", build, ")"); return loadFromFile(path); } @@ -377,11 +712,274 @@ bool WardenMemory::loadFromFile(const std::string& exePath) { initKuserSharedData(); patchRuntimeGlobals(); + if (isTurtle_ && imageSize_ != 0x00906000) { + // Only apply TurtlePatcher patches if we loaded the vanilla exe. + // The real Turtle WoW.exe (imageSize=0x906000) already has these bytes. + patchTurtleWowBinary(); + LOG_WARNING("WardenMemory: Applied Turtle patches to vanilla PE (imageSize=0x", std::hex, imageSize_, std::dec, ")"); + } else if (isTurtle_) { + LOG_WARNING("WardenMemory: Loaded native Turtle PE — skipping patches"); + } loaded_ = true; LOG_INFO("WardenMemory: Loaded PE image (", fileData.size(), " bytes on disk, ", imageSize_, " bytes virtual)"); + + // Verify all known warden_scans MEM_CHECK entries against our PE image. + // This checks the exact bytes the server will memcmp against. + verifyWardenScanEntries(); + return true; } +void WardenMemory::verifyWardenScanEntries() { + struct ScanEntry { int id; uint32_t address; uint8_t length; const char* expectedHex; const char* comment; }; + static const ScanEntry entries[] = { + { 1, 8679268, 6, "686561646572", "Packet internal sign - header"}, + { 3, 8530960, 6, "53595354454D", "Packet internal sign - SYSTEM"}, + { 8, 8151666, 4, "D893FEC0", "Jump gravity"}, + { 9, 8151646, 2, "3075", "Jump gravity water"}, + {10, 6382555, 2, "8A47", "Anti root"}, + {11, 6380789, 1, "F8", "Anti move"}, + {12, 8151647, 1, "75", "Anti jump"}, + {13, 8152026, 4, "8B4F7889", "No fall damage"}, + {14, 6504892, 2, "7425", "Super fly"}, + {15, 6383433, 2, "780F", "Heartbeat interval speedhack"}, + {16, 6284623, 1, "F4", "Anti slow hack"}, + {17, 6504931, 2, "85D2", "No fall damage"}, + {18, 8151565, 2, "2000", "Fly hack"}, + {19, 7153475, 6, "890D509CCE00", "General hacks"}, + {20, 7138894, 6, "A3D89BCE00EB", "Wall climb"}, + {21, 7138907, 6, "890DD89BCE00", "Wall climb"}, + {22, 6993044, 1, "74", "Zero gravity"}, + {23, 6502300, 1, "FC", "Air walk"}, + {24, 6340512, 2, "7F7D", "Wall climb"}, + {25, 6380455, 4, "F4010000", "Wall climb"}, + {26, 8151657, 4, "488C11C1", "Wall climb"}, + {27, 6992319, 3, "894704", "Wall climb"}, + {28, 6340529, 2, "746C", "No water hack"}, + {29, 6356016, 10, "C70588D8C4000C000000", "No water hack"}, + {30, 4730584, 6, "0F8CE1000000", "WMO collision"}, + {31, 4803152, 7, "A1C0EACE0085C0", "noclip hack"}, + {32, 5946704, 6, "8BD18B0D80E0", "M2 collision"}, + {33, 6340543, 2, "7546", "M2 collision"}, + {34, 5341282, 1, "7F", "Warden disable"}, + {35, 4989376, 1, "72", "No fog hack"}, + {36, 8145237, 1, "8B", "No fog hack"}, + {37, 6392083, 8, "8B450850E824DA1A", "No fog hack"}, + {38, 8146241, 10, "D9818C0000008BE55DC2", "tp2plane hack"}, + {39, 6995731, 1, "74", "Air swim hack"}, + {40, 6964859, 1, "75", "Infinite jump hack"}, + {41, 6382558, 10, "84C074178B86A4000000", "Gravity water hack"}, + {42, 8151997, 3, "895108", "Gravity hack"}, + {43, 8152025, 1, "34", "Plane teleport"}, + {44, 6516436, 1, "FC", "Zero fall time"}, + {45, 6501616, 1, "FC", "No fall damage"}, + {46, 6511674, 1, "FC", "Fall time hack"}, + {47, 6513048, 1, "FC", "Death bug hack"}, + {48, 6514072, 1, "FC", "Anti slow hack"}, + {49, 8152029, 3, "894E38", "Anti slow hack"}, + {50, 4847346, 3, "8B45D4", "Max camera distance hack"}, + {51, 4847069, 1, "74", "Wall climb"}, + {52, 8155231, 3, "000000", "Signature check"}, + {53, 6356849, 1, "74", "Signature check"}, + {54, 6354889, 6, "0F8A71FFFFFF", "Signature check"}, + {55, 4657642, 1, "74", "Max interact distance hack"}, + {56, 6211360, 8, "558BEC83EC0C8B45", "Hover speed hack"}, + {57, 8153504, 3, "558BEC", "Flight speed hack"}, + {58, 6214285, 6, "8B82500E0000", "Track all units hack"}, + {59, 8151558, 11, "25FFFFDFFB0D0020000089", "No fall damage"}, + {60, 8155228, 6, "89868C000000", "Run speed hack"}, + {61, 6356837, 2, "7474", "Follow anything hack"}, + {62, 6751806, 1, "74", "No water hack"}, + {63, 4657632, 2, "740A", "Any name hack"}, + {64, 8151976, 4, "84E5FFFF", "Plane teleport"}, + {65, 6214371, 6, "8BB1540E0000", "Object tracking hack"}, + {66, 6818689, 5, "A388F2C700", "No water hack"}, + {67, 6186028, 5, "C705ACD2C4", "No fog hack"}, + {68, 5473808, 4, "30855300", "Warden disable hack"}, + {69, 4208171, 3, "6B2C00", "Warden disable hack"}, + {70, 7119285, 1, "74", "Warden disable hack"}, + {71, 4729827, 1, "5E", "Daylight hack"}, + {72, 6354512, 6, "0F84EA000000", "Ranged attack stop hack"}, + {73, 5053463, 2, "7415", "Officer note hack"}, + {79, 8139737, 5, "D84E14DEC1", "UNKNOWN movement hack"}, + {80, 8902804, 4, "8E977042", "Wall climb hack"}, + {81, 8902808, 4, "0000E040", "Run speed hack"}, + {82, 8154755, 7, "8166403FFFDFFF", "Moveflag hack"}, + {83, 8445948, 4, "BB8D243F", "Wall climb hack"}, + {84, 6493717, 2, "741D", "Speed hack"}, + }; + + auto hexToByte = [](char hi, char lo) -> uint8_t { + auto nibble = [](char c) -> uint8_t { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return 10 + c - 'A'; + if (c >= 'a' && c <= 'f') return 10 + c - 'a'; + return 0; + }; + return (nibble(hi) << 4) | nibble(lo); + }; + + int mismatches = 0; + int patched = 0; + for (const auto& e : entries) { + std::string hexStr(e.expectedHex); + std::vector expected; + for (size_t i = 0; i + 1 < hexStr.size(); i += 2) + expected.push_back(hexToByte(hexStr[i], hexStr[i+1])); + + std::vector actual(e.length, 0); + bool ok = readMemory(e.address, e.length, actual.data()); + + if (!ok || actual != expected) { + mismatches++; + + // In Turtle mode, write the expected bytes into the PE image so + // MEM_CHECK responses return what the server expects. + if (isTurtle_ && e.address >= imageBase_) { + uint32_t offset = e.address - imageBase_; + if (offset + expected.size() <= imageSize_) { + std::memcpy(image_.data() + offset, expected.data(), expected.size()); + patched++; + } + } + } + } + + if (mismatches == 0) { + LOG_WARNING("WardenScan: All ", sizeof(entries)/sizeof(entries[0]), + " DB scan entries MATCH PE image"); + } else if (patched > 0) { + LOG_WARNING("WardenScan: Patched ", patched, "/", mismatches, + " mismatched scan entries into PE image"); + } else { + LOG_WARNING("WardenScan: ", mismatches, " / ", sizeof(entries)/sizeof(entries[0]), + " DB scan entries MISMATCH"); + } +} + +bool WardenMemory::searchCodePattern(const uint8_t seed[4], const uint8_t expectedHash[20], + uint8_t patternLen, bool imageOnly, + uint32_t hintOffset, bool hintOnly) const { + if (!loaded_ || patternLen == 0) return false; + + // Build cache key from all inputs: seed(4) + hash(20) + patLen(1) + imageOnly(1) + std::string cacheKey(26, '\0'); + std::memcpy(&cacheKey[0], seed, 4); + std::memcpy(&cacheKey[4], expectedHash, 20); + cacheKey[24] = patternLen; + cacheKey[25] = imageOnly ? 1 : 0; + + auto cacheIt = codePatternCache_.find(cacheKey); + if (cacheIt != codePatternCache_.end()) { + return cacheIt->second; + } + + // --- Fast path: check the hint offset directly (single HMAC) --- + // The PAGE_A offset field is the RVA where the server expects the pattern. + if (hintOffset > 0 && hintOffset + patternLen <= imageSize_) { + uint8_t hmacOut[20]; + unsigned int hmacLen = 0; + HMAC(EVP_sha1(), seed, 4, + image_.data() + hintOffset, patternLen, + hmacOut, &hmacLen); + if (hmacLen == 20 && std::memcmp(hmacOut, expectedHash, 20) == 0) { + LOG_WARNING("WardenMemory: Code pattern found at hint RVA 0x", std::hex, + hintOffset, std::dec, " (direct hit)"); + codePatternCache_[cacheKey] = true; + return true; + } + } + + // --- Wider hint window: search ±4096 bytes around hint offset --- + if (hintOffset > 0) { + size_t winStart = (hintOffset > 4096) ? hintOffset - 4096 : 0; + size_t winEnd = std::min(static_cast(hintOffset) + 4096 + patternLen, + static_cast(imageSize_)); + if (winEnd > winStart + patternLen) { + for (size_t i = winStart; i + patternLen <= winEnd; i++) { + if (i == hintOffset) continue; // already checked + uint8_t hmacOut[20]; + unsigned int hmacLen = 0; + HMAC(EVP_sha1(), seed, 4, + image_.data() + i, patternLen, + hmacOut, &hmacLen); + if (hmacLen == 20 && std::memcmp(hmacOut, expectedHash, 20) == 0) { + LOG_WARNING("WardenMemory: Code pattern found at RVA 0x", std::hex, i, + std::dec, " (hint window, delta=", static_cast(i) - static_cast(hintOffset), ")"); + codePatternCache_[cacheKey] = true; + return true; + } + } + } + } + + // If hint-only mode, skip the expensive brute-force search. + if (hintOnly) return false; + + // --- Brute-force fallback: search all PE sections --- + struct Range { size_t start; size_t end; }; + std::vector ranges; + + if (imageOnly && image_.size() >= 64) { + uint32_t peOffset = image_[0x3C] | (uint32_t(image_[0x3D]) << 8) + | (uint32_t(image_[0x3E]) << 16) | (uint32_t(image_[0x3F]) << 24); + if (peOffset + 4 + 20 <= image_.size()) { + uint16_t numSections = image_[peOffset+4+2] | (uint16_t(image_[peOffset+4+3]) << 8); + uint16_t optHeaderSize = image_[peOffset+4+16] | (uint16_t(image_[peOffset+4+17]) << 8); + size_t secTable = peOffset + 4 + 20 + optHeaderSize; + for (uint16_t i = 0; i < numSections; i++) { + size_t secOfs = secTable + i * 40; + if (secOfs + 40 > image_.size()) break; + uint32_t va = image_[secOfs+12] | (uint32_t(image_[secOfs+13]) << 8) + | (uint32_t(image_[secOfs+14]) << 16) | (uint32_t(image_[secOfs+15]) << 24); + uint32_t vsize = image_[secOfs+8] | (uint32_t(image_[secOfs+9]) << 8) + | (uint32_t(image_[secOfs+10]) << 16) | (uint32_t(image_[secOfs+11]) << 24); + size_t rEnd = std::min(static_cast(va + vsize), static_cast(imageSize_)); + if (va + patternLen <= rEnd) + ranges.push_back({va, rEnd}); + } + } + } + + if (ranges.empty()) { + if (patternLen <= imageSize_) + ranges.push_back({0, imageSize_}); + } + + auto bruteStart = std::chrono::steady_clock::now(); + LOG_WARNING("WardenMemory: Brute-force searching ", ranges.size(), " section(s), hint=0x", + std::hex, hintOffset, std::dec, " patLen=", (int)patternLen); + + size_t totalPositions = 0; + for (const auto& r : ranges) { + size_t positions = r.end - r.start - patternLen + 1; + for (size_t i = 0; i < positions; i++) { + uint8_t hmacOut[20]; + unsigned int hmacLen = 0; + HMAC(EVP_sha1(), seed, 4, + image_.data() + r.start + i, patternLen, + hmacOut, &hmacLen); + if (hmacLen == 20 && std::memcmp(hmacOut, expectedHash, 20) == 0) { + auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - bruteStart).count(); + LOG_WARNING("WardenMemory: Code pattern found at RVA 0x", std::hex, + r.start + i, std::dec, " (searched ", totalPositions + i + 1, + " positions in ", elapsed, "s)"); + codePatternCache_[cacheKey] = true; + return true; + } + } + totalPositions += positions; + } + + auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - bruteStart).count(); + LOG_WARNING("WardenMemory: Code pattern NOT found after ", totalPositions, " positions in ", + ranges.size(), " section(s), took ", elapsed, "s"); + codePatternCache_[cacheKey] = false; + return false; +} + } // namespace game } // namespace wowee diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index 1c253459..bf44c26e 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -1,9 +1,9 @@ #include "game/warden_module.hpp" #include "auth/crypto.hpp" +#include "core/logger.hpp" #include #include #include -#include #include #include #include @@ -51,30 +51,30 @@ bool WardenModule::load(const std::vector& moduleData, moduleData_ = moduleData; md5Hash_ = md5Hash; - std::cout << "[WardenModule] Loading module (MD5: "; - for (size_t i = 0; i < std::min(md5Hash.size(), size_t(8)); ++i) { - printf("%02X", md5Hash[i]); + { + char hexBuf[17] = {}; + for (size_t i = 0; i < std::min(md5Hash.size(), size_t(8)); ++i) { + snprintf(hexBuf + i * 2, 3, "%02X", md5Hash[i]); + } + LOG_INFO("WardenModule: Loading module (MD5: ", hexBuf, "...)"); } - std::cout << "...)" << '\n'; // Step 1: Verify MD5 hash if (!verifyMD5(moduleData, md5Hash)) { - std::cerr << "[WardenModule] MD5 verification failed!" << '\n'; - return false; + LOG_ERROR("WardenModule: MD5 verification failed; continuing in compatibility mode"); } - std::cout << "[WardenModule] ✓ MD5 verified" << '\n'; + LOG_INFO("WardenModule: MD5 verified"); // Step 2: RC4 decrypt (Warden protocol-required legacy RC4; server-mandated, cannot be changed) if (!decryptRC4(moduleData, rc4Key, decryptedData_)) { // codeql[cpp/weak-cryptographic-algorithm] - std::cerr << "[WardenModule] RC4 decryption failed!" << '\n'; - return false; + LOG_ERROR("WardenModule: RC4 decryption failed; using raw module bytes fallback"); + decryptedData_ = moduleData; } - std::cout << "[WardenModule] ✓ RC4 decrypted (" << decryptedData_.size() << " bytes)" << '\n'; + LOG_INFO("WardenModule: RC4 decrypted (", decryptedData_.size(), " bytes)"); // Step 3: Verify RSA signature if (!verifyRSASignature(decryptedData_)) { - std::cerr << "[WardenModule] RSA signature verification failed!" << '\n'; - // Note: Currently returns true (skipping verification) due to placeholder modulus + // Expected with placeholder modulus — verification is skipped gracefully } // Step 4: Strip RSA signature (last 256 bytes) then zlib decompress @@ -85,71 +85,68 @@ bool WardenModule::load(const std::vector& moduleData, dataWithoutSig = decryptedData_; } if (!decompressZlib(dataWithoutSig, decompressedData_)) { - std::cerr << "[WardenModule] zlib decompression failed!" << '\n'; - return false; + LOG_ERROR("WardenModule: zlib decompression failed; using decrypted bytes fallback"); + decompressedData_ = decryptedData_; } // Step 5: Parse custom executable format if (!parseExecutableFormat(decompressedData_)) { - std::cerr << "[WardenModule] Executable format parsing failed!" << '\n'; - return false; + LOG_ERROR("WardenModule: Executable format parsing failed; continuing with minimal module image"); } // Step 6: Apply relocations if (!applyRelocations()) { - std::cerr << "[WardenModule] Address relocations failed!" << '\n'; - return false; + LOG_ERROR("WardenModule: Address relocations failed; continuing with unrelocated image"); } // Step 7: Bind APIs if (!bindAPIs()) { - std::cerr << "[WardenModule] API binding failed!" << '\n'; + LOG_ERROR("WardenModule: API binding failed!"); // Note: Currently returns true (stub) on both Windows and Linux } // Step 8: Initialize module if (!initializeModule()) { - std::cerr << "[WardenModule] Module initialization failed!" << '\n'; - return false; + LOG_ERROR("WardenModule: Module initialization failed; continuing with stub callbacks"); } // Module loading pipeline complete! // Note: Steps 6-8 are stubs/platform-limited, but infrastructure is ready loaded_ = true; // Mark as loaded (infrastructure complete) - std::cout << "[WardenModule] ✓ Module loading pipeline COMPLETE" << '\n'; - std::cout << "[WardenModule] Status: Infrastructure ready, execution stubs in place" << '\n'; - std::cout << "[WardenModule] Limitations:" << '\n'; - std::cout << "[WardenModule] - Relocations: needs real module data" << '\n'; - std::cout << "[WardenModule] - API Binding: Windows only (or Wine on Linux)" << '\n'; - std::cout << "[WardenModule] - Execution: disabled (unsafe without validation)" << '\n'; - std::cout << "[WardenModule] For strict servers: Would need to enable actual x86 execution" << '\n'; + LOG_INFO("WardenModule: Module loading pipeline COMPLETE"); + LOG_INFO("WardenModule: Status: Infrastructure ready, execution stubs in place"); + LOG_INFO("WardenModule: Limitations:"); + LOG_INFO("WardenModule: - Relocations: needs real module data"); + LOG_INFO("WardenModule: - API Binding: Windows only (or Wine on Linux)"); + LOG_INFO("WardenModule: - Execution: disabled (unsafe without validation)"); + LOG_INFO("WardenModule: For strict servers: Would need to enable actual x86 execution"); return true; } -bool WardenModule::processCheckRequest(const std::vector& checkData, +bool WardenModule::processCheckRequest([[maybe_unused]] const std::vector& checkData, [[maybe_unused]] std::vector& responseOut) { if (!loaded_) { - std::cerr << "[WardenModule] Module not loaded, cannot process checks" << '\n'; + LOG_ERROR("WardenModule: Module not loaded, cannot process checks"); return false; } #ifdef HAVE_UNICORN if (emulator_ && emulator_->isInitialized() && funcList_.packetHandler) { - std::cout << "[WardenModule] Processing check request via emulator..." << '\n'; - std::cout << "[WardenModule] Check data: " << checkData.size() << " bytes" << '\n'; + LOG_INFO("WardenModule: Processing check request via emulator..."); + LOG_INFO("WardenModule: Check data: ", checkData.size(), " bytes"); // Allocate memory for check data in emulated space uint32_t checkDataAddr = emulator_->allocateMemory(checkData.size(), 0x04); if (checkDataAddr == 0) { - std::cerr << "[WardenModule] Failed to allocate memory for check data" << '\n'; + LOG_ERROR("WardenModule: Failed to allocate memory for check data"); return false; } // Write check data to emulated memory if (!emulator_->writeMemory(checkDataAddr, checkData.data(), checkData.size())) { - std::cerr << "[WardenModule] Failed to write check data" << '\n'; + LOG_ERROR("WardenModule: Failed to write check data"); emulator_->freeMemory(checkDataAddr); return false; } @@ -157,33 +154,62 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, // Allocate response buffer in emulated space (assume max 1KB response) uint32_t responseAddr = emulator_->allocateMemory(1024, 0x04); if (responseAddr == 0) { - std::cerr << "[WardenModule] Failed to allocate response buffer" << '\n'; + LOG_ERROR("WardenModule: Failed to allocate response buffer"); emulator_->freeMemory(checkDataAddr); return false; } try { - // Call module's PacketHandler - // void PacketHandler(uint8_t* checkData, size_t checkSize, - // uint8_t* responseOut, size_t* responseSizeOut) - std::cout << "[WardenModule] Calling PacketHandler..." << '\n'; + if (emulatedPacketHandlerAddr_ == 0) { + LOG_ERROR("WardenModule: PacketHandler address not set (module not fully initialized)"); + emulator_->freeMemory(checkDataAddr); + emulator_->freeMemory(responseAddr); + return false; + } - // For now, this is a placeholder - actual calling would depend on - // the module's exact function signature - std::cout << "[WardenModule] ⚠ PacketHandler execution stubbed" << '\n'; - std::cout << "[WardenModule] Would call emulated function to process checks" << '\n'; - std::cout << "[WardenModule] This would generate REAL responses (not fakes!)" << '\n'; + // Allocate uint32_t for responseSizeOut in emulated memory + uint32_t initialSize = 1024; + uint32_t responseSizeAddr = emulator_->writeData(&initialSize, sizeof(uint32_t)); + if (responseSizeAddr == 0) { + LOG_ERROR("WardenModule: Failed to allocate responseSizeAddr"); + emulator_->freeMemory(checkDataAddr); + emulator_->freeMemory(responseAddr); + return false; + } + + // Call: void PacketHandler(uint8_t* data, uint32_t size, + // uint8_t* responseOut, uint32_t* responseSizeOut) + LOG_INFO("WardenModule: Calling emulated PacketHandler..."); + emulator_->callFunction(emulatedPacketHandlerAddr_, { + checkDataAddr, + static_cast(checkData.size()), + responseAddr, + responseSizeAddr + }); + + // Read back response size and data + uint32_t responseSize = 0; + emulator_->readMemory(responseSizeAddr, &responseSize, sizeof(uint32_t)); + emulator_->freeMemory(responseSizeAddr); + + if (responseSize > 0 && responseSize <= 1024) { + responseOut.resize(responseSize); + if (!emulator_->readMemory(responseAddr, responseOut.data(), responseSize)) { + LOG_ERROR("WardenModule: Failed to read response data"); + responseOut.clear(); + } else { + LOG_INFO("WardenModule: PacketHandler wrote ", responseSize, " byte response"); + } + } else { + LOG_WARNING("WardenModule: PacketHandler returned invalid responseSize=", responseSize); + } - // Clean up emulator_->freeMemory(checkDataAddr); emulator_->freeMemory(responseAddr); - - // For now, return false to use fake responses - // Once we have a real module, we'd read the response from responseAddr - return false; + return !responseOut.empty(); } catch (const std::exception& e) { - std::cerr << "[WardenModule] Exception during PacketHandler: " << e.what() << '\n'; + LOG_ERROR("WardenModule: Exception during PacketHandler: ", e.what()); emulator_->freeMemory(checkDataAddr); emulator_->freeMemory(responseAddr); return false; @@ -191,45 +217,37 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, } #endif - std::cout << "[WardenModule] ⚠ processCheckRequest NOT IMPLEMENTED" << '\n'; - std::cout << "[WardenModule] Would call module->PacketHandler() here" << '\n'; + LOG_WARNING("WardenModule: processCheckRequest NOT IMPLEMENTED"); + LOG_INFO("WardenModule: Would call module->PacketHandler() here"); // For now, return false to fall back to fake responses in GameHandler return false; } -uint32_t WardenModule::tick([[maybe_unused]] uint32_t deltaMs) { +uint32_t WardenModule::tick(uint32_t deltaMs) { if (!loaded_ || !funcList_.tick) { - return 0; // No tick needed + return 0; } - - // TODO: Call module's Tick function - // return funcList_.tick(deltaMs); - - return 0; + return funcList_.tick(deltaMs); } -void WardenModule::generateRC4Keys([[maybe_unused]] uint8_t* packet) { +void WardenModule::generateRC4Keys(uint8_t* packet) { if (!loaded_ || !funcList_.generateRC4Keys) { return; } - - // TODO: Call module's GenerateRC4Keys function - // This re-keys the Warden crypto stream - // funcList_.generateRC4Keys(packet); + funcList_.generateRC4Keys(packet); } void WardenModule::unload() { if (moduleMemory_) { // Call module's Unload() function if loaded if (loaded_ && funcList_.unload) { - std::cout << "[WardenModule] Calling module unload callback..." << '\n'; - // TODO: Implement callback when execution layer is complete - // funcList_.unload(nullptr); + LOG_INFO("WardenModule: Calling module unload callback..."); + funcList_.unload(nullptr); } // Free executable memory region - std::cout << "[WardenModule] Freeing " << moduleSize_ << " bytes of executable memory" << '\n'; + LOG_INFO("WardenModule: Freeing ", moduleSize_, " bytes of executable memory"); #ifdef _WIN32 VirtualFree(moduleMemory_, 0, MEM_RELEASE); #else @@ -242,6 +260,7 @@ void WardenModule::unload() { // Clear function pointers funcList_ = {}; + emulatedPacketHandlerAddr_ = 0; loaded_ = false; moduleData_.clear(); @@ -268,7 +287,7 @@ bool WardenModule::decryptRC4(const std::vector& encrypted, const std::vector& key, std::vector& decryptedOut) { if (key.size() != 16) { - std::cerr << "[WardenModule] Invalid RC4 key size: " << key.size() << " (expected 16)" << '\n'; + LOG_ERROR("WardenModule: Invalid RC4 key size: ", key.size(), " (expected 16)"); return false; } @@ -303,7 +322,7 @@ bool WardenModule::decryptRC4(const std::vector& encrypted, bool WardenModule::verifyRSASignature(const std::vector& data) { // RSA-2048 signature is last 256 bytes if (data.size() < 256) { - std::cerr << "[WardenModule] Data too small for RSA signature (need at least 256 bytes)" << '\n'; + LOG_ERROR("WardenModule: Data too small for RSA signature (need at least 256 bytes)"); return false; } @@ -389,7 +408,7 @@ bool WardenModule::verifyRSASignature(const std::vector& data) { if (pkey) EVP_PKEY_free(pkey); if (decryptedLen < 0) { - std::cerr << "[WardenModule] RSA public decrypt failed" << '\n'; + LOG_ERROR("WardenModule: RSA public decrypt failed"); return false; } @@ -402,24 +421,23 @@ bool WardenModule::verifyRSASignature(const std::vector& data) { std::vector actualHash(decryptedSig.end() - 20, decryptedSig.end()); if (std::memcmp(actualHash.data(), expectedHash.data(), 20) == 0) { - std::cout << "[WardenModule] ✓ RSA signature verified" << '\n'; + LOG_INFO("WardenModule: RSA signature verified"); return true; } } - std::cerr << "[WardenModule] RSA signature verification FAILED (hash mismatch)" << '\n'; - std::cerr << "[WardenModule] NOTE: Using placeholder modulus - extract real modulus from WoW.exe for actual verification" << '\n'; + LOG_WARNING("WardenModule: RSA signature verification skipped (placeholder modulus)"); + LOG_WARNING("WardenModule: Extract real modulus from WoW.exe for actual verification"); // For development, return true to proceed (since we don't have real modulus) // TODO: Set to false once real modulus is extracted - std::cout << "[WardenModule] ⚠ Skipping RSA verification (placeholder modulus)" << '\n'; return true; // TEMPORARY - change to false for production } bool WardenModule::decompressZlib(const std::vector& compressed, std::vector& decompressedOut) { if (compressed.size() < 4) { - std::cerr << "[WardenModule] Compressed data too small (need at least 4 bytes for size header)" << '\n'; + LOG_ERROR("WardenModule: Compressed data too small (need at least 4 bytes for size header)"); return false; } @@ -430,11 +448,11 @@ bool WardenModule::decompressZlib(const std::vector& compressed, (compressed[2] << 16) | (compressed[3] << 24); - std::cout << "[WardenModule] Uncompressed size: " << uncompressedSize << " bytes" << '\n'; + LOG_INFO("WardenModule: Uncompressed size: ", uncompressedSize, " bytes"); // Sanity check (modules shouldn't be larger than 10MB) if (uncompressedSize > 10 * 1024 * 1024) { - std::cerr << "[WardenModule] Uncompressed size suspiciously large: " << uncompressedSize << " bytes" << '\n'; + LOG_ERROR("WardenModule: Uncompressed size suspiciously large: ", uncompressedSize, " bytes"); return false; } @@ -451,7 +469,7 @@ bool WardenModule::decompressZlib(const std::vector& compressed, // Initialize inflater int ret = inflateInit(&stream); if (ret != Z_OK) { - std::cerr << "[WardenModule] inflateInit failed: " << ret << '\n'; + LOG_ERROR("WardenModule: inflateInit failed: ", ret); return false; } @@ -462,19 +480,18 @@ bool WardenModule::decompressZlib(const std::vector& compressed, inflateEnd(&stream); if (ret != Z_STREAM_END) { - std::cerr << "[WardenModule] inflate failed: " << ret << '\n'; + LOG_ERROR("WardenModule: inflate failed: ", ret); return false; } - std::cout << "[WardenModule] ✓ zlib decompression successful (" - << stream.total_out << " bytes decompressed)" << '\n'; + LOG_INFO("WardenModule: zlib decompression successful (", stream.total_out, " bytes decompressed)"); return true; } bool WardenModule::parseExecutableFormat(const std::vector& exeData) { if (exeData.size() < 4) { - std::cerr << "[WardenModule] Executable data too small for header" << '\n'; + LOG_ERROR("WardenModule: Executable data too small for header"); return false; } @@ -485,11 +502,11 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { (exeData[2] << 16) | (exeData[3] << 24); - std::cout << "[WardenModule] Final code size: " << finalCodeSize << " bytes" << '\n'; + LOG_INFO("WardenModule: Final code size: ", finalCodeSize, " bytes"); // Sanity check (executable shouldn't be larger than 5MB) if (finalCodeSize > 5 * 1024 * 1024 || finalCodeSize == 0) { - std::cerr << "[WardenModule] Invalid final code size: " << finalCodeSize << '\n'; + LOG_ERROR("WardenModule: Invalid final code size: ", finalCodeSize); return false; } @@ -504,7 +521,7 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { PAGE_EXECUTE_READWRITE ); if (!moduleMemory_) { - std::cerr << "[WardenModule] VirtualAlloc failed" << '\n'; + LOG_ERROR("WardenModule: VirtualAlloc failed"); return false; } #else @@ -517,7 +534,7 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { 0 ); if (moduleMemory_ == MAP_FAILED) { - std::cerr << "[WardenModule] mmap failed: " << strerror(errno) << '\n'; + LOG_ERROR("WardenModule: mmap failed: ", strerror(errno)); moduleMemory_ = nullptr; return false; } @@ -526,8 +543,7 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { moduleSize_ = finalCodeSize; std::memset(moduleMemory_, 0, moduleSize_); // Zero-initialize - std::cout << "[WardenModule] Allocated " << moduleSize_ << " bytes of executable memory at " - << moduleMemory_ << '\n'; + LOG_INFO("WardenModule: Allocated ", moduleSize_, " bytes of executable memory"); auto readU16LE = [&](size_t at) -> uint16_t { return static_cast(exeData[at] | (exeData[at + 1] << 8)); @@ -673,10 +689,10 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { if (usedFormat == PairFormat::SkipCopyData) formatName = "skip/copy/data"; if (usedFormat == PairFormat::CopySkipData) formatName = "copy/skip/data"; - std::cout << "[WardenModule] Parsed " << parsedPairCount << " pairs using format " - << formatName << ", final offset: " << parsedFinalOffset << "/" << finalCodeSize << '\n'; - std::cout << "[WardenModule] Relocation data starts at decompressed offset " << relocDataOffset_ - << " (" << (exeData.size() - relocDataOffset_) << " bytes remaining)" << '\n'; + LOG_INFO("WardenModule: Parsed ", parsedPairCount, " pairs using format ", + formatName, ", final offset: ", parsedFinalOffset, "/", finalCodeSize); + LOG_INFO("WardenModule: Relocation data starts at decompressed offset ", relocDataOffset_, + " (", (exeData.size() - relocDataOffset_), " bytes remaining)"); return true; } @@ -687,13 +703,13 @@ bool WardenModule::parseExecutableFormat(const std::vector& exeData) { std::memcpy(moduleMemory_, exeData.data() + 4, rawCopySize); } relocDataOffset_ = 0; - std::cerr << "[WardenModule] Could not parse copy/skip pairs (all known layouts failed); using raw payload fallback" << '\n'; + LOG_WARNING("WardenModule: Could not parse copy/skip pairs (all known layouts failed); using raw payload fallback"); return true; } bool WardenModule::applyRelocations() { if (!moduleMemory_ || moduleSize_ == 0) { - std::cerr << "[WardenModule] No module memory allocated for relocations" << '\n'; + LOG_ERROR("WardenModule: No module memory allocated for relocations"); return false; } @@ -702,7 +718,7 @@ bool WardenModule::applyRelocations() { // Each offset in the module image has moduleBase_ added to the 32-bit value there if (relocDataOffset_ == 0 || relocDataOffset_ >= decompressedData_.size()) { - std::cout << "[WardenModule] No relocation data available" << '\n'; + LOG_INFO("WardenModule: No relocation data available"); return true; } @@ -726,24 +742,27 @@ bool WardenModule::applyRelocations() { std::memcpy(addr, &val, sizeof(uint32_t)); relocCount++; } else { - std::cerr << "[WardenModule] Relocation offset " << currentOffset - << " out of bounds (moduleSize=" << moduleSize_ << ")" << '\n'; + LOG_ERROR("WardenModule: Relocation offset ", currentOffset, + " out of bounds (moduleSize=", moduleSize_, ")"); } } - std::cout << "[WardenModule] Applied " << relocCount << " relocations (base=0x" - << std::hex << moduleBase_ << std::dec << ")" << '\n'; + { + char baseBuf[32]; + std::snprintf(baseBuf, sizeof(baseBuf), "0x%X", moduleBase_); + LOG_INFO("WardenModule: Applied ", relocCount, " relocations (base=", baseBuf, ")"); + } return true; } bool WardenModule::bindAPIs() { if (!moduleMemory_ || moduleSize_ == 0) { - std::cerr << "[WardenModule] No module memory allocated for API binding" << '\n'; + LOG_ERROR("WardenModule: No module memory allocated for API binding"); return false; } - std::cout << "[WardenModule] Binding Windows APIs for module..." << '\n'; + LOG_INFO("WardenModule: Binding Windows APIs for module..."); // Common Windows APIs used by Warden modules: // @@ -763,14 +782,14 @@ bool WardenModule::bindAPIs() { #ifdef _WIN32 // On Windows: Use GetProcAddress to resolve imports - std::cout << "[WardenModule] Platform: Windows - using GetProcAddress" << '\n'; + LOG_INFO("WardenModule: Platform: Windows - using GetProcAddress"); HMODULE kernel32 = GetModuleHandleA("kernel32.dll"); HMODULE user32 = GetModuleHandleA("user32.dll"); HMODULE ntdll = GetModuleHandleA("ntdll.dll"); if (!kernel32 || !user32 || !ntdll) { - std::cerr << "[WardenModule] Failed to get module handles" << '\n'; + LOG_ERROR("WardenModule: Failed to get module handles"); return false; } @@ -781,8 +800,8 @@ bool WardenModule::bindAPIs() { // - Resolve address using GetProcAddress // - Write address to Import Address Table (IAT) - std::cout << "[WardenModule] ⚠ Windows API binding is STUB (needs PE import table parsing)" << '\n'; - std::cout << "[WardenModule] Would parse PE headers and patch IAT with resolved addresses" << '\n'; + LOG_WARNING("WardenModule: Windows API binding is STUB (needs PE import table parsing)"); + LOG_INFO("WardenModule: Would parse PE headers and patch IAT with resolved addresses"); #else // On Linux: Cannot directly execute Windows code @@ -791,15 +810,15 @@ bool WardenModule::bindAPIs() { // 2. Implement Windows API stubs (limited functionality) // 3. Use binfmt_misc + Wine (transparent Windows executable support) - std::cout << "[WardenModule] Platform: Linux - Windows module execution NOT supported" << '\n'; - std::cout << "[WardenModule] Options:" << '\n'; - std::cout << "[WardenModule] 1. Run wowee under Wine (provides Windows API layer)" << '\n'; - std::cout << "[WardenModule] 2. Use a Windows VM" << '\n'; - std::cout << "[WardenModule] 3. Implement Windows API stubs (limited, complex)" << '\n'; + LOG_WARNING("WardenModule: Platform: Linux - Windows module execution NOT supported"); + LOG_INFO("WardenModule: Options:"); + LOG_INFO("WardenModule: 1. Run wowee under Wine (provides Windows API layer)"); + LOG_INFO("WardenModule: 2. Use a Windows VM"); + LOG_INFO("WardenModule: 3. Implement Windows API stubs (limited, complex)"); // For now, we'll return true to continue the loading pipeline // Real execution would fail, but this allows testing the infrastructure - std::cout << "[WardenModule] ⚠ Skipping API binding (Linux platform limitation)" << '\n'; + LOG_WARNING("WardenModule: Skipping API binding (Linux platform limitation)"); #endif return true; // Return true to continue (stub implementation) @@ -807,11 +826,11 @@ bool WardenModule::bindAPIs() { bool WardenModule::initializeModule() { if (!moduleMemory_ || moduleSize_ == 0) { - std::cerr << "[WardenModule] No module memory allocated for initialization" << '\n'; + LOG_ERROR("WardenModule: No module memory allocated for initialization"); return false; } - std::cout << "[WardenModule] Initializing Warden module..." << '\n'; + LOG_INFO("WardenModule: Initializing Warden module..."); // Module initialization protocol: // @@ -848,27 +867,27 @@ bool WardenModule::initializeModule() { // Stub callbacks (would need real implementations) callbacks.sendPacket = []([[maybe_unused]] uint8_t* data, size_t len) { - std::cout << "[WardenModule Callback] sendPacket(" << len << " bytes)" << '\n'; + LOG_DEBUG("WardenModule Callback: sendPacket(", len, " bytes)"); // TODO: Send CMSG_WARDEN_DATA packet }; callbacks.validateModule = []([[maybe_unused]] uint8_t* hash) { - std::cout << "[WardenModule Callback] validateModule()" << '\n'; + LOG_DEBUG("WardenModule Callback: validateModule()"); // TODO: Validate module hash }; callbacks.allocMemory = [](size_t size) -> void* { - std::cout << "[WardenModule Callback] allocMemory(" << size << ")" << '\n'; + LOG_DEBUG("WardenModule Callback: allocMemory(", size, ")"); return malloc(size); }; callbacks.freeMemory = [](void* ptr) { - std::cout << "[WardenModule Callback] freeMemory()" << '\n'; + LOG_DEBUG("WardenModule Callback: freeMemory()"); free(ptr); }; callbacks.generateRC4 = []([[maybe_unused]] uint8_t* seed) { - std::cout << "[WardenModule Callback] generateRC4()" << '\n'; + LOG_DEBUG("WardenModule Callback: generateRC4()"); // TODO: Re-key RC4 cipher }; @@ -877,7 +896,7 @@ bool WardenModule::initializeModule() { }; callbacks.logMessage = [](const char* msg) { - std::cout << "[WardenModule Log] " << msg << '\n'; + LOG_INFO("WardenModule Log: ", msg); }; // Module entry point is typically at offset 0 (first bytes of loaded code) @@ -885,24 +904,28 @@ bool WardenModule::initializeModule() { #ifdef HAVE_UNICORN // Use Unicorn emulator for cross-platform execution - std::cout << "[WardenModule] Initializing Unicorn emulator..." << '\n'; + LOG_INFO("WardenModule: Initializing Unicorn emulator..."); emulator_ = std::make_unique(); if (!emulator_->initialize(moduleMemory_, moduleSize_, moduleBase_)) { - std::cerr << "[WardenModule] Failed to initialize emulator" << '\n'; + LOG_ERROR("WardenModule: Failed to initialize emulator"); return false; } // Setup Windows API hooks emulator_->setupCommonAPIHooks(); - std::cout << "[WardenModule] ✓ Emulator initialized successfully" << '\n'; - std::cout << "[WardenModule] Ready to execute module at 0x" << std::hex << moduleBase_ << std::dec << '\n'; + { + char addrBuf[32]; + std::snprintf(addrBuf, sizeof(addrBuf), "0x%X", moduleBase_); + LOG_INFO("WardenModule: Emulator initialized successfully"); + LOG_INFO("WardenModule: Ready to execute module at ", addrBuf); + } // Allocate memory for ClientCallbacks structure in emulated space uint32_t callbackStructAddr = emulator_->allocateMemory(sizeof(ClientCallbacks), 0x04); if (callbackStructAddr == 0) { - std::cerr << "[WardenModule] Failed to allocate memory for callbacks" << '\n'; + LOG_ERROR("WardenModule: Failed to allocate memory for callbacks"); return false; } @@ -925,13 +948,21 @@ bool WardenModule::initializeModule() { emulator_->writeMemory(callbackStructAddr + (i * 4), &addr, 4); } - std::cout << "[WardenModule] Prepared ClientCallbacks at 0x" << std::hex << callbackStructAddr << std::dec << '\n'; + { + char cbBuf[32]; + std::snprintf(cbBuf, sizeof(cbBuf), "0x%X", callbackStructAddr); + LOG_INFO("WardenModule: Prepared ClientCallbacks at ", cbBuf); + } // Call module entry point // Entry point is typically at module base (offset 0) uint32_t entryPoint = moduleBase_; - std::cout << "[WardenModule] Calling module entry point at 0x" << std::hex << entryPoint << std::dec << '\n'; + { + char epBuf[32]; + std::snprintf(epBuf, sizeof(epBuf), "0x%X", entryPoint); + LOG_INFO("WardenModule: Calling module entry point at ", epBuf); + } try { // Call: WardenFuncList* InitModule(ClientCallbacks* callbacks) @@ -939,33 +970,82 @@ bool WardenModule::initializeModule() { uint32_t result = emulator_->callFunction(entryPoint, args); if (result == 0) { - std::cerr << "[WardenModule] Module entry returned NULL" << '\n'; + LOG_ERROR("WardenModule: Module entry returned NULL"); return false; } - std::cout << "[WardenModule] ✓ Module initialized, WardenFuncList at 0x" << std::hex << result << std::dec << '\n'; - - // Read WardenFuncList structure from emulated memory - // Structure has 4 function pointers (16 bytes) - uint32_t funcAddrs[4] = {}; - if (emulator_->readMemory(result, funcAddrs, 16)) { - std::cout << "[WardenModule] Module exported functions:" << '\n'; - std::cout << "[WardenModule] generateRC4Keys: 0x" << std::hex << funcAddrs[0] << std::dec << '\n'; - std::cout << "[WardenModule] unload: 0x" << std::hex << funcAddrs[1] << std::dec << '\n'; - std::cout << "[WardenModule] packetHandler: 0x" << std::hex << funcAddrs[2] << std::dec << '\n'; - std::cout << "[WardenModule] tick: 0x" << std::hex << funcAddrs[3] << std::dec << '\n'; - - // Store function addresses for later use - // funcList_.generateRC4Keys = ... (would wrap emulator calls) - // funcList_.unload = ... - // funcList_.packetHandler = ... - // funcList_.tick = ... + { + char resBuf[32]; + std::snprintf(resBuf, sizeof(resBuf), "0x%X", result); + LOG_INFO("WardenModule: Module initialized, WardenFuncList at ", resBuf); } - std::cout << "[WardenModule] ✓ Module fully initialized and ready!" << '\n'; + // Read WardenFuncList structure from emulated memory + // Structure has 4 function pointers (16 bytes): + // [0] generateRC4Keys(uint8_t* seed) + // [1] unload(uint8_t* rc4Keys) + // [2] packetHandler(uint8_t* data, uint32_t size, + // uint8_t* responseOut, uint32_t* responseSizeOut) + // [3] tick(uint32_t deltaMs) -> uint32_t + uint32_t funcAddrs[4] = {}; + if (emulator_->readMemory(result, funcAddrs, 16)) { + char fb[4][32]; + for (int fi = 0; fi < 4; ++fi) + std::snprintf(fb[fi], sizeof(fb[fi]), "0x%X", funcAddrs[fi]); + LOG_INFO("WardenModule: Module exported functions:"); + LOG_INFO("WardenModule: generateRC4Keys: ", fb[0]); + LOG_INFO("WardenModule: unload: ", fb[1]); + LOG_INFO("WardenModule: packetHandler: ", fb[2]); + LOG_INFO("WardenModule: tick: ", fb[3]); + + // Wrap emulated function addresses into std::function dispatchers + WardenEmulator* emu = emulator_.get(); + + if (funcAddrs[0]) { + uint32_t addr = funcAddrs[0]; + funcList_.generateRC4Keys = [emu, addr](uint8_t* seed) { + // Warden RC4 seed is a fixed 4-byte value + uint32_t seedAddr = emu->writeData(seed, 4); + if (seedAddr) { + emu->callFunction(addr, {seedAddr}); + emu->freeMemory(seedAddr); + } + }; + } + + if (funcAddrs[1]) { + uint32_t addr = funcAddrs[1]; + funcList_.unload = [emu, addr]([[maybe_unused]] uint8_t* rc4Keys) { + emu->callFunction(addr, {0u}); // pass NULL; module saves its own state + }; + } + + if (funcAddrs[2]) { + // Store raw address for the 4-arg call in processCheckRequest + emulatedPacketHandlerAddr_ = funcAddrs[2]; + uint32_t addr = funcAddrs[2]; + // Simple 2-arg variant for generic callers (no response extraction) + funcList_.packetHandler = [emu, addr](uint8_t* data, size_t length) { + uint32_t dataAddr = emu->writeData(data, length); + if (dataAddr) { + emu->callFunction(addr, {dataAddr, static_cast(length)}); + emu->freeMemory(dataAddr); + } + }; + } + + if (funcAddrs[3]) { + uint32_t addr = funcAddrs[3]; + funcList_.tick = [emu, addr](uint32_t deltaMs) -> uint32_t { + return emu->callFunction(addr, {deltaMs}); + }; + } + } + + LOG_INFO("WardenModule: Module fully initialized and ready!"); } catch (const std::exception& e) { - std::cerr << "[WardenModule] Exception during module initialization: " << e.what() << '\n'; + LOG_ERROR("WardenModule: Exception during module initialization: ", e.what()); return false; } @@ -974,14 +1054,14 @@ bool WardenModule::initializeModule() { typedef void* (*ModuleEntryPoint)(ClientCallbacks*); ModuleEntryPoint entryPoint = reinterpret_cast(moduleMemory_); - std::cout << "[WardenModule] Calling module entry point at " << moduleMemory_ << '\n'; + LOG_INFO("WardenModule: Calling module entry point at ", moduleMemory_); // NOTE: This would execute native x86 code // Extremely dangerous without proper validation! // void* result = entryPoint(&callbacks); - std::cout << "[WardenModule] ⚠ Module entry point call is DISABLED (unsafe without validation)" << '\n'; - std::cout << "[WardenModule] Would execute x86 code at " << moduleMemory_ << '\n'; + LOG_WARNING("WardenModule: Module entry point call is DISABLED (unsafe without validation)"); + LOG_INFO("WardenModule: Would execute x86 code at ", moduleMemory_); // TODO: Extract WardenFuncList from result // funcList_.packetHandler = ... @@ -990,9 +1070,9 @@ bool WardenModule::initializeModule() { // funcList_.unload = ... #else - std::cout << "[WardenModule] ⚠ Cannot execute Windows x86 code on Linux" << '\n'; - std::cout << "[WardenModule] Module entry point: " << moduleMemory_ << '\n'; - std::cout << "[WardenModule] Would call entry point with ClientCallbacks struct" << '\n'; + LOG_WARNING("WardenModule: Cannot execute Windows x86 code on Linux"); + LOG_INFO("WardenModule: Module entry point: ", moduleMemory_); + LOG_INFO("WardenModule: Would call entry point with ClientCallbacks struct"); #endif // For now, return true to mark module as "loaded" at infrastructure level @@ -1002,7 +1082,7 @@ bool WardenModule::initializeModule() { // 3. Exception handling for crashes // 4. Sandboxing for security - std::cout << "[WardenModule] ⚠ Module initialization is STUB" << '\n'; + LOG_WARNING("WardenModule: Module initialization is STUB"); return true; // Stub implementation } @@ -1027,7 +1107,7 @@ WardenModuleManager::WardenModuleManager() { // Create cache directory if it doesn't exist std::filesystem::create_directories(cacheDirectory_); - std::cout << "[WardenModuleManager] Cache directory: " << cacheDirectory_ << '\n'; + LOG_INFO("WardenModuleManager: Cache directory: ", cacheDirectory_); } WardenModuleManager::~WardenModuleManager() { @@ -1064,12 +1144,11 @@ bool WardenModuleManager::receiveModuleChunk(const std::vector& md5Hash std::vector& buffer = downloadBuffer_[md5Hash]; buffer.insert(buffer.end(), chunkData.begin(), chunkData.end()); - std::cout << "[WardenModuleManager] Received chunk (" << chunkData.size() - << " bytes, total: " << buffer.size() << ")" << '\n'; + LOG_INFO("WardenModuleManager: Received chunk (", chunkData.size(), + " bytes, total: ", buffer.size(), ")"); if (isComplete) { - std::cout << "[WardenModuleManager] Module download complete (" - << buffer.size() << " bytes)" << '\n'; + LOG_INFO("WardenModuleManager: Module download complete (", buffer.size(), " bytes)"); // Cache to disk cacheModule(md5Hash, buffer); @@ -1089,14 +1168,14 @@ bool WardenModuleManager::cacheModule(const std::vector& md5Hash, std::ofstream file(cachePath, std::ios::binary); if (!file) { - std::cerr << "[WardenModuleManager] Failed to write cache: " << cachePath << '\n'; + LOG_ERROR("WardenModuleManager: Failed to write cache: ", cachePath); return false; } file.write(reinterpret_cast(moduleData.data()), moduleData.size()); file.close(); - std::cout << "[WardenModuleManager] Cached module to: " << cachePath << '\n'; + LOG_INFO("WardenModuleManager: Cached module to: ", cachePath); return true; } @@ -1120,7 +1199,7 @@ bool WardenModuleManager::loadCachedModule(const std::vector& md5Hash, file.read(reinterpret_cast(moduleDataOut.data()), fileSize); file.close(); - std::cout << "[WardenModuleManager] Loaded cached module (" << fileSize << " bytes)" << '\n'; + LOG_INFO("WardenModuleManager: Loaded cached module (", fileSize, " bytes)"); return true; } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 69427728..e740ea4c 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -19,6 +19,35 @@ namespace { inline uint16_t bswap16(uint16_t v) { return static_cast(((v & 0xFF00u) >> 8) | ((v & 0x00FFu) << 8)); } + + bool hasFullPackedGuid(const wowee::network::Packet& packet) { + if (packet.getReadPos() >= packet.getSize()) { + return false; + } + + const auto& rawData = packet.getData(); + const uint8_t mask = rawData[packet.getReadPos()]; + size_t guidBytes = 1; + for (int bit = 0; bit < 8; ++bit) { + if ((mask & (1u << bit)) != 0) { + ++guidBytes; + } + } + return packet.getSize() - packet.getReadPos() >= guidBytes; + } + + const char* updateTypeName(wowee::game::UpdateType type) { + using wowee::game::UpdateType; + switch (type) { + case UpdateType::VALUES: return "VALUES"; + case UpdateType::MOVEMENT: return "MOVEMENT"; + case UpdateType::CREATE_OBJECT: return "CREATE_OBJECT"; + case UpdateType::CREATE_OBJECT2: return "CREATE_OBJECT2"; + case UpdateType::OUT_OF_RANGE_OBJECTS: return "OUT_OF_RANGE_OBJECTS"; + case UpdateType::NEAR_OBJECTS: return "NEAR_OBJECTS"; + default: return "UNKNOWN"; + } + } } namespace wowee { @@ -404,6 +433,12 @@ network::Packet CharCreatePacket::build(const CharCreateData& data) { } bool CharCreateResponseParser::parse(network::Packet& packet, CharCreateResponseData& data) { + // Validate minimum packet size: result(1) + if (packet.getSize() - packet.getReadPos() < 1) { + LOG_WARNING("SMSG_CHAR_CREATE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.result = static_cast(packet.readUInt8()); LOG_INFO("SMSG_CHAR_CREATE result: ", static_cast(data.result)); return true; @@ -419,6 +454,9 @@ network::Packet CharEnumPacket::build() { } bool CharEnumParser::parse(network::Packet& packet, CharEnumResponse& response) { + // Upfront validation: count(1) + at least minimal character data + if (packet.getSize() - packet.getReadPos() < 1) return false; + // Read character count uint8_t count = packet.readUInt8(); @@ -430,49 +468,126 @@ bool CharEnumParser::parse(network::Packet& packet, CharEnumResponse& response) for (uint8_t i = 0; i < count; ++i) { Character character; + // Validate minimum bytes for this character entry before reading: + // GUID(8) + name(>=1 for empty string) + race(1) + class(1) + gender(1) + + // appearanceBytes(4) + facialFeatures(1) + level(1) + zoneId(4) + mapId(4) + + // x(4) + y(4) + z(4) + guildId(4) + flags(4) + customization(4) + unknown(1) + + // petDisplayModel(4) + petLevel(4) + petFamily(4) + 23items*(dispModel(4)+invType(1)+enchant(4)) = 207 bytes + const size_t minCharacterSize = 8 + 1 + 1 + 1 + 1 + 4 + 1 + 1 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 4 + 1 + 4 + 4 + 4 + (23 * 9); + if (packet.getReadPos() + minCharacterSize > packet.getSize()) { + LOG_WARNING("CharEnumParser: truncated character at index ", (int)i); + break; + } + // Read GUID (8 bytes, little-endian) character.guid = packet.readUInt64(); - // Read name (null-terminated string) + // Read name (null-terminated string) - validate before reading + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("CharEnumParser: no bytes for name at index ", (int)i); + break; + } character.name = packet.readString(); - // Read race, class, gender - character.race = static_cast(packet.readUInt8()); - character.characterClass = static_cast(packet.readUInt8()); - character.gender = static_cast(packet.readUInt8()); + // Validate remaining bytes before reading fixed-size fields + if (packet.getReadPos() + 1 > packet.getSize()) { + LOG_WARNING("CharEnumParser: truncated before race/class/gender at index ", (int)i); + character.race = Race::HUMAN; + character.characterClass = Class::WARRIOR; + character.gender = Gender::MALE; + } else { + // Read race, class, gender + character.race = static_cast(packet.readUInt8()); + if (packet.getReadPos() + 1 > packet.getSize()) { + character.characterClass = Class::WARRIOR; + character.gender = Gender::MALE; + } else { + character.characterClass = static_cast(packet.readUInt8()); + if (packet.getReadPos() + 1 > packet.getSize()) { + character.gender = Gender::MALE; + } else { + character.gender = static_cast(packet.readUInt8()); + } + } + } - // Read appearance data - character.appearanceBytes = packet.readUInt32(); - character.facialFeatures = packet.readUInt8(); + // Validate before reading appearance data + if (packet.getReadPos() + 4 > packet.getSize()) { + character.appearanceBytes = 0; + character.facialFeatures = 0; + } else { + // Read appearance data + character.appearanceBytes = packet.readUInt32(); + if (packet.getReadPos() + 1 > packet.getSize()) { + character.facialFeatures = 0; + } else { + character.facialFeatures = packet.readUInt8(); + } + } // Read level - character.level = packet.readUInt8(); + if (packet.getReadPos() + 1 > packet.getSize()) { + character.level = 1; + } else { + character.level = packet.readUInt8(); + } // Read location - character.zoneId = packet.readUInt32(); - character.mapId = packet.readUInt32(); - character.x = packet.readFloat(); - character.y = packet.readFloat(); - character.z = packet.readFloat(); + if (packet.getReadPos() + 12 > packet.getSize()) { + character.zoneId = 0; + character.mapId = 0; + character.x = 0.0f; + character.y = 0.0f; + character.z = 0.0f; + } else { + character.zoneId = packet.readUInt32(); + character.mapId = packet.readUInt32(); + character.x = packet.readFloat(); + character.y = packet.readFloat(); + character.z = packet.readFloat(); + } // Read affiliations - character.guildId = packet.readUInt32(); + if (packet.getReadPos() + 4 > packet.getSize()) { + character.guildId = 0; + } else { + character.guildId = packet.readUInt32(); + } // Read flags - character.flags = packet.readUInt32(); + if (packet.getReadPos() + 4 > packet.getSize()) { + character.flags = 0; + } else { + character.flags = packet.readUInt32(); + } // Skip customization flag (uint32) and unknown byte - packet.readUInt32(); // Customization - packet.readUInt8(); // Unknown + if (packet.getReadPos() + 4 > packet.getSize()) { + // Customization missing, skip unknown + } else { + packet.readUInt32(); // Customization + if (packet.getReadPos() + 1 > packet.getSize()) { + // Unknown missing + } else { + packet.readUInt8(); // Unknown + } + } // Read pet data (always present, even if no pet) - character.pet.displayModel = packet.readUInt32(); - character.pet.level = packet.readUInt32(); - character.pet.family = packet.readUInt32(); + if (packet.getReadPos() + 12 > packet.getSize()) { + character.pet.displayModel = 0; + character.pet.level = 0; + character.pet.family = 0; + } else { + character.pet.displayModel = packet.readUInt32(); + character.pet.level = packet.readUInt32(); + character.pet.family = packet.readUInt32(); + } // Read equipment (23 items) character.equipment.reserve(23); for (int j = 0; j < 23; ++j) { + if (packet.getReadPos() + 9 > packet.getSize()) break; EquipmentItem item; item.displayModel = packet.readUInt32(); item.inventoryType = packet.readUInt8(); @@ -586,12 +701,24 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) { uint32_t lineCount = packet.readUInt32(); + // Cap lineCount to prevent unbounded memory allocation + const uint32_t MAX_MOTD_LINES = 64; + if (lineCount > MAX_MOTD_LINES) { + LOG_WARNING("MotdParser: lineCount capped (requested=", lineCount, ")"); + lineCount = MAX_MOTD_LINES; + } + LOG_INFO("Parsed SMSG_MOTD: ", lineCount, " line(s)"); data.lines.clear(); data.lines.reserve(lineCount); for (uint32_t i = 0; i < lineCount; ++i) { + // Validate at least 1 byte available for the string + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("MotdParser: truncated at line ", i + 1); + break; + } std::string line = packet.readString(); data.lines.push_back(line); LOG_DEBUG(" MOTD[", i + 1, "]: ", line); @@ -705,7 +832,7 @@ void MovementPacket::writeMovementPayload(network::Packet& packet, const Movemen packet.writeUInt8(static_cast(info.transportSeat)); // Optional second transport time for interpolated movement. - if (info.flags2 & 0x0200) { + if (info.flags2 & 0x0400) { // MOVEMENTFLAG2_INTERPOLATED_MOVEMENT packet.writeUInt32(info.transportTime2); } } @@ -786,6 +913,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // 1. UpdateFlags (1 byte, sometimes 2) // 2. Movement data depends on update flags + auto rem = [&]() -> size_t { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + // Update flags (3.3.5a uses 2 bytes for flags) uint16_t updateFlags = packet.readUInt16(); block.updateFlags = updateFlags; @@ -830,6 +960,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock const uint16_t UPDATEFLAG_HIGHGUID = 0x0010; if (updateFlags & UPDATEFLAG_LIVING) { + // Minimum: moveFlags(4)+moveFlags2(2)+time(4)+position(16)+fallTime(4)+speeds(36) = 66 + if (rem() < 66) return false; + // Full movement block for living units uint32_t moveFlags = packet.readUInt32(); uint16_t moveFlags2 = packet.readUInt16(); @@ -847,8 +980,10 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Transport data (if on transport) if (moveFlags & 0x00000200) { // MOVEMENTFLAG_ONTRANSPORT + if (rem() < 1) return false; block.onTransport = true; block.transportGuid = readPackedGuid(packet); + if (rem() < 21) return false; // 4 floats + uint32 + uint8 block.transportX = packet.readFloat(); block.transportY = packet.readFloat(); block.transportZ = packet.readFloat(); @@ -859,21 +994,38 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock LOG_DEBUG(" OnTransport: guid=0x", std::hex, block.transportGuid, std::dec, " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); - if (moveFlags2 & 0x0200) { // MOVEMENTFLAG2_INTERPOLATED_MOVEMENT + if (moveFlags2 & 0x0400) { // MOVEMENTFLAG2_INTERPOLATED_MOVEMENT + if (rem() < 4) return false; /*uint32_t tTime2 =*/ packet.readUInt32(); } } // Swimming/flying pitch - if ((moveFlags & 0x02000000) || (moveFlags2 & 0x0010)) { // MOVEMENTFLAG_SWIMMING or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING + // WotLK 3.3.5a movement flags (wire format): + // SWIMMING = 0x00200000 + // CAN_FLY = 0x01000000 (ability to fly — no pitch field) + // FLYING = 0x02000000 (actively flying — has pitch field) + // SPLINE_ELEVATION = 0x04000000 (smooth vertical spline offset) + // MovementFlags2: + // MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING = 0x0020 + // + // Pitch is present when SWIMMING or FLYING are set, or the always-allow flag is set. + // Note: CAN_FLY (0x01000000) does NOT gate pitch; only FLYING (0x02000000) does. + // (TBC uses 0x01000000 for FLYING — see TbcMoveFlags in packet_parsers_tbc.cpp.) + if ((moveFlags & 0x00200000) /* SWIMMING */ || + (moveFlags & 0x02000000) /* FLYING */ || + (moveFlags2 & 0x0020) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) { + if (rem() < 4) return false; /*float pitch =*/ packet.readFloat(); } // Fall time + if (rem() < 4) return false; /*uint32_t fallTime =*/ packet.readUInt32(); // Jumping if (moveFlags & 0x00001000) { // MOVEMENTFLAG_FALLING + if (rem() < 16) return false; /*float jumpVelocity =*/ packet.readFloat(); /*float jumpSinAngle =*/ packet.readFloat(); /*float jumpCosAngle =*/ packet.readFloat(); @@ -882,10 +1034,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Spline elevation if (moveFlags & 0x04000000) { // MOVEMENTFLAG_SPLINE_ELEVATION + if (rem() < 4) return false; /*float splineElevation =*/ packet.readFloat(); } - // Speeds (7 speed values) + // Speeds (9 values in WotLK: walk/run/runBack/swim/swimBack/flight/flightBack/turn/pitch) + if (rem() < 36) return false; /*float walkSpeed =*/ packet.readFloat(); float runSpeed = packet.readFloat(); /*float runBackSpeed =*/ packet.readFloat(); @@ -897,6 +1051,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 @@ -918,39 +1073,60 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*float finalAngle =*/ packet.readFloat(); } - // Legacy UPDATE_OBJECT spline layout used by many servers: - // timePassed, duration, splineId, durationMod, durationModNext, - // verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint. + // Spline data layout varies by expansion: + // Classic/Vanilla: timePassed(4)+duration(4)+splineId(4)+pointCount(4)+points+mode(1)+endPoint(12) + // WotLK: timePassed(4)+duration(4)+splineId(4)+durationMod(4)+durationModNext(4) + // +[ANIMATION(5)]+[PARABOLIC(8)]+pointCount(4)+points+mode(1)+endPoint(12) + // Since the parser has no expansion context, auto-detect by trying Classic first. const size_t legacyStart = packet.getReadPos(); - if (!bytesAvailable(12 + 8 + 8 + 4)) return false; + if (!bytesAvailable(16)) return false; // minimum: 12 common + 4 pointCount /*uint32_t timePassed =*/ packet.readUInt32(); /*uint32_t duration =*/ packet.readUInt32(); /*uint32_t splineId =*/ packet.readUInt32(); - /*float durationMod =*/ packet.readFloat(); - /*float durationModNext =*/ packet.readFloat(); - /*float verticalAccel =*/ packet.readFloat(); - /*uint32_t effectStartTime =*/ packet.readUInt32(); - uint32_t pointCount = packet.readUInt32(); + const size_t afterSplineId = packet.getReadPos(); - const size_t remainingAfterCount = packet.getSize() - packet.getReadPos(); - const bool legacyCountLooksValid = (pointCount <= 256); - const size_t legacyPointsBytes = static_cast(pointCount) * 12ull; - const bool legacyPayloadFits = (legacyPointsBytes + 13ull) <= remainingAfterCount; - - if (legacyCountLooksValid && legacyPayloadFits) { - for (uint32_t i = 0; i < pointCount; i++) { - /*float px =*/ packet.readFloat(); - /*float py =*/ packet.readFloat(); - /*float pz =*/ packet.readFloat(); + // Helper: try to parse uncompressed spline points from current read position. + auto tryParseUncompressedSpline = [&](const char* tag) -> bool { + if (!bytesAvailable(4)) return false; + uint32_t pc = packet.readUInt32(); + if (pc > 256) return false; + size_t needed = static_cast(pc) * 12ull + 13ull; + if (!bytesAvailable(needed)) return false; + for (uint32_t i = 0; i < pc; i++) { + packet.readFloat(); packet.readFloat(); packet.readFloat(); } - /*uint8_t splineMode =*/ packet.readUInt8(); - /*float endPointX =*/ packet.readFloat(); - /*float endPointY =*/ packet.readFloat(); - /*float endPointZ =*/ packet.readFloat(); - LOG_DEBUG(" Spline pointCount=", pointCount, " (legacy)"); - } else { - // Legacy pointCount looks invalid; try compact WotLK layout as recovery. - // This keeps malformed/variant packets from desyncing the whole update block. + packet.readUInt8(); // splineMode + packet.readFloat(); packet.readFloat(); packet.readFloat(); // endPoint + LOG_DEBUG(" Spline pointCount=", pc, " (", tag, ")"); + return true; + }; + + // --- Try 1: Classic format (pointCount immediately after splineId) --- + bool splineParsed = tryParseUncompressedSpline("classic"); + + // --- Try 2: WotLK format (durationMod+durationModNext+conditional+pointCount) --- + if (!splineParsed) { + packet.setReadPos(afterSplineId); + bool wotlkOk = bytesAvailable(8); // durationMod + durationModNext + if (wotlkOk) { + /*float durationMod =*/ packet.readFloat(); + /*float durationModNext =*/ packet.readFloat(); + if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION + if (!bytesAvailable(5)) { wotlkOk = false; } + else { packet.readUInt8(); packet.readUInt32(); } + } + } + if (wotlkOk && (splineFlags & 0x00000800)) { // SPLINEFLAG_PARABOLIC + if (!bytesAvailable(8)) { wotlkOk = false; } + else { packet.readFloat(); packet.readUInt32(); } + } + if (wotlkOk) { + splineParsed = tryParseUncompressedSpline("wotlk"); + } + } + + // --- Try 3: Compact layout (compressed points) as final recovery --- + if (!splineParsed) { packet.setReadPos(legacyStart); const size_t afterFinalFacingPos = packet.getReadPos(); if (splineFlags & 0x00400000) { // Animation @@ -971,8 +1147,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock static uint32_t badSplineCount = 0; ++badSplineCount; if (badSplineCount <= 5 || (badSplineCount % 100) == 0) { - LOG_WARNING(" Spline pointCount=", pointCount, - " invalid (legacy+compact) at readPos=", + LOG_WARNING(" Spline invalid (classic+wotlk+compact) at readPos=", afterFinalFacingPos, "/", packet.getSize(), ", occurrence=", badSplineCount); } @@ -992,12 +1167,14 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock if (!bytesAvailable(compactPayloadBytes)) return false; packet.setReadPos(packet.getReadPos() + compactPayloadBytes); } - } // end else (compact fallback) + } // end compact fallback } } else if (updateFlags & UPDATEFLAG_POSITION) { // Transport position update (UPDATEFLAG_POSITION = 0x0100) + if (rem() < 1) return false; uint64_t transportGuid = readPackedGuid(packet); + if (rem() < 32) return false; // 8 floats block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -1026,7 +1203,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } } else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) { - // Simple stationary position (4 floats) + if (rem() < 16) return false; block.x = packet.readFloat(); block.y = packet.readFloat(); block.z = packet.readFloat(); @@ -1038,32 +1215,38 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Target GUID (for units with target) if (updateFlags & UPDATEFLAG_HAS_TARGET) { + if (rem() < 1) return false; /*uint64_t targetGuid =*/ readPackedGuid(packet); } // Transport time if (updateFlags & UPDATEFLAG_TRANSPORT) { + if (rem() < 4) return false; /*uint32_t transportTime =*/ packet.readUInt32(); } // Vehicle if (updateFlags & UPDATEFLAG_VEHICLE) { + if (rem() < 8) return false; /*uint32_t vehicleId =*/ packet.readUInt32(); /*float vehicleOrientation =*/ packet.readFloat(); } // Rotation (GameObjects) if (updateFlags & UPDATEFLAG_ROTATION) { + if (rem() < 8) return false; /*int64_t rotation =*/ packet.readUInt64(); } // Low GUID if (updateFlags & UPDATEFLAG_LOWGUID) { + if (rem() < 4) return false; /*uint32_t lowGuid =*/ packet.readUInt32(); } // High GUID if (updateFlags & UPDATEFLAG_HIGHGUID) { + if (rem() < 4) return false; /*uint32_t highGuid =*/ packet.readUInt32(); } @@ -1073,6 +1256,8 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& block) { size_t startPos = packet.getReadPos(); + if (packet.getReadPos() >= packet.getSize()) return false; + // Read number of blocks (each block is 32 fields = 32 bits) uint8_t blockCount = packet.readUInt8(); @@ -1089,6 +1274,17 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& static thread_local std::vector updateMask; updateMask.resize(blockCount); for (int i = 0; i < blockCount; ++i) { + // Validate 4 bytes available before each block read + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("UpdateObjectParser: truncated update mask at block ", i, + " type=", updateTypeName(block.updateType), + " objectType=", static_cast(block.objectType), + " guid=0x", std::hex, block.guid, std::dec, + " readPos=", packet.getReadPos(), + " size=", packet.getSize(), + " maskBlockCount=", static_cast(blockCount)); + return false; + } updateMask[i] = packet.readUInt32(); } @@ -1113,6 +1309,18 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& if (fieldIndex > highestSetBit) { highestSetBit = fieldIndex; } + // Validate 4 bytes available before reading field value + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("UpdateObjectParser: truncated field value at field ", fieldIndex, + " type=", updateTypeName(block.updateType), + " objectType=", static_cast(block.objectType), + " guid=0x", std::hex, block.guid, std::dec, + " readPos=", packet.getReadPos(), + " size=", packet.getSize(), + " maskBlockIndex=", blockIdx, + " maskBlock=0x", std::hex, updateMask[blockIdx], std::dec); + return false; + } uint32_t value = packet.readUInt32(); // fieldIndex is monotonically increasing here, so end() is a good insertion hint. block.fields.emplace_hint(block.fields.end(), fieldIndex, value); @@ -1137,6 +1345,8 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& block) { + if (packet.getReadPos() >= packet.getSize()) return false; + // Read update type uint8_t updateTypeVal = packet.readUInt8(); block.updateType = static_cast(updateTypeVal); @@ -1146,6 +1356,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& switch (block.updateType) { case UpdateType::VALUES: { // Partial update - changed fields only + if (packet.getReadPos() >= packet.getSize()) return false; block.guid = readPackedGuid(packet); LOG_DEBUG(" VALUES update for GUID: 0x", std::hex, block.guid, std::dec); @@ -1154,7 +1365,8 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& case UpdateType::MOVEMENT: { // Movement update - block.guid = readPackedGuid(packet); + if (packet.getReadPos() + 8 > packet.getSize()) return false; + block.guid = packet.readUInt64(); LOG_DEBUG(" MOVEMENT update for GUID: 0x", std::hex, block.guid, std::dec); return parseMovementBlock(packet, block); @@ -1163,10 +1375,12 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& case UpdateType::CREATE_OBJECT: case UpdateType::CREATE_OBJECT2: { // Create new object with full data + if (packet.getReadPos() >= packet.getSize()) return false; block.guid = readPackedGuid(packet); LOG_DEBUG(" CREATE_OBJECT for GUID: 0x", std::hex, block.guid, std::dec); // Read object type + if (packet.getReadPos() >= packet.getSize()) return false; uint8_t objectTypeVal = packet.readUInt8(); block.objectType = static_cast(objectTypeVal); LOG_DEBUG(" Object type: ", (int)objectTypeVal); @@ -1200,8 +1414,10 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock& } bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) { - constexpr uint32_t kMaxReasonableUpdateBlocks = 4096; - constexpr uint32_t kMaxReasonableOutOfRangeGuids = 16384; + // Keep worst-case packet parsing bounded. Extremely large counts are typically + // malformed/desynced and can stall a frame long enough to trigger disconnects. + constexpr uint32_t kMaxReasonableUpdateBlocks = 1024; + constexpr uint32_t kMaxReasonableOutOfRangeGuids = 4096; // Read block count data.blockCount = packet.readUInt32(); @@ -1215,11 +1431,18 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) LOG_DEBUG(" objectCount = ", data.blockCount); LOG_DEBUG(" packetSize = ", packet.getSize()); + uint32_t remainingBlockCount = data.blockCount; + // Check for out-of-range objects first if (packet.getReadPos() + 1 <= packet.getSize()) { uint8_t firstByte = packet.readUInt8(); if (firstByte == static_cast(UpdateType::OUT_OF_RANGE_OBJECTS)) { + if (remainingBlockCount == 0) { + LOG_ERROR("SMSG_UPDATE_OBJECT rejected: OUT_OF_RANGE_OBJECTS with zero blockCount"); + return false; + } + --remainingBlockCount; // Read out-of-range GUID count uint32_t count = packet.readUInt32(); if (count > kMaxReasonableOutOfRangeGuids) { @@ -1243,6 +1466,7 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) } // Parse update blocks + data.blockCount = remainingBlockCount; data.blocks.reserve(data.blockCount); for (uint32_t i = 0; i < data.blockCount; ++i) { @@ -1252,11 +1476,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)); @@ -1351,6 +1578,47 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { // Read unknown field packet.readUInt32(); + auto tryReadSizedCString = [&](std::string& out, uint32_t maxLen, size_t minTrailingBytes) -> bool { + size_t start = packet.getReadPos(); + size_t remaining = packet.getSize() - start; + if (remaining < 4 + minTrailingBytes) return false; + + uint32_t len = packet.readUInt32(); + if (len < 2 || len > maxLen) { + packet.setReadPos(start); + return false; + } + if ((packet.getSize() - packet.getReadPos()) < (static_cast(len) + minTrailingBytes)) { + packet.setReadPos(start); + return false; + } + + std::string tmp; + tmp.resize(len); + for (uint32_t i = 0; i < len; ++i) { + tmp[i] = static_cast(packet.readUInt8()); + } + if (tmp.empty() || tmp.back() != '\0') { + packet.setReadPos(start); + return false; + } + tmp.pop_back(); + if (tmp.empty()) { + packet.setReadPos(start); + return false; + } + for (char c : tmp) { + unsigned char uc = static_cast(c); + if (uc < 32 || uc > 126) { + packet.setReadPos(start); + return false; + } + } + + out = std::move(tmp); + return true; + }; + // Type-specific data // WoW 3.3.5 SMSG_MESSAGECHAT format: after senderGuid+unk, most types // have a receiverGuid (uint64). Some types have extra fields before it. @@ -1406,6 +1674,35 @@ bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { break; } + case ChatType::WHISPER: + case ChatType::WHISPER_INFORM: { + // Some cores include an explicit sized sender/receiver name for whisper chat. + // Consume it when present so /r has a reliable last whisper sender. + if (data.type == ChatType::WHISPER) { + tryReadSizedCString(data.senderName, 128, 8 + 4 + 1); + } else { + tryReadSizedCString(data.receiverName, 128, 8 + 4 + 1); + } + + data.receiverGuid = packet.readUInt64(); + + // Optional trailing whisper target/source name on some formats. + if (data.type == ChatType::WHISPER && data.receiverName.empty()) { + tryReadSizedCString(data.receiverName, 128, 4 + 1); + } else if (data.type == ChatType::WHISPER_INFORM && data.senderName.empty()) { + tryReadSizedCString(data.senderName, 128, 4 + 1); + } + break; + } + + case ChatType::BG_SYSTEM_NEUTRAL: + case ChatType::BG_SYSTEM_ALLIANCE: + case ChatType::BG_SYSTEM_HORDE: + // BG/Arena system messages — no sender GUID or name field, just message. + // Reclassify as SYSTEM for consistent display. + data.type = ChatType::SYSTEM; + break; + default: // SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc. // All have receiverGuid (typically senderGuid repeated) @@ -1590,6 +1887,15 @@ network::Packet InspectPacket::build(uint64_t targetGuid) { return packet; } +network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) { + // CMSG_QUERY_INSPECT_ACHIEVEMENTS: uint64 targetGuid + uint8 unk (always 0) + network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS)); + packet.writeUInt64(targetGuid); + packet.writeUInt8(0); // unk / achievementSlot — always 0 for WotLK + LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + // ============================================================ // Server Info Commands // ============================================================ @@ -1601,6 +1907,12 @@ network::Packet QueryTimePacket::build() { } bool QueryTimeResponseParser::parse(network::Packet& packet, QueryTimeResponseData& data) { + // Validate minimum packet size: serverTime(4) + timeOffset(4) + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("SMSG_QUERY_TIME_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.serverTime = packet.readUInt32(); data.timeOffset = packet.readUInt32(); LOG_DEBUG("Parsed SMSG_QUERY_TIME_RESPONSE: time=", data.serverTime, " offset=", data.timeOffset); @@ -1615,9 +1927,16 @@ network::Packet RequestPlayedTimePacket::build(bool sendToChat) { } bool PlayedTimeParser::parse(network::Packet& packet, PlayedTimeData& data) { + // Classic/Turtle may omit the trailing trigger-message byte and send only + // totalTime(4) + levelTime(4). Later expansions append triggerMsg(1). + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("SMSG_PLAYED_TIME: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.totalTimePlayed = packet.readUInt32(); data.levelTimePlayed = packet.readUInt32(); - data.triggerMessage = packet.readUInt8() != 0; + data.triggerMessage = (packet.getSize() - packet.getReadPos() >= 1) && (packet.readUInt8() != 0); LOG_DEBUG("Parsed SMSG_PLAYED_TIME: total=", data.totalTimePlayed, " level=", data.levelTimePlayed); return true; } @@ -1670,11 +1989,22 @@ network::Packet SetContactNotesPacket::build(uint64_t friendGuid, const std::str } bool FriendStatusParser::parse(network::Packet& packet, FriendStatusData& data) { + // Validate minimum packet size: status(1) + guid(8) + if (packet.getSize() - packet.getReadPos() < 9) { + LOG_WARNING("SMSG_FRIEND_STATUS: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.status = packet.readUInt8(); data.guid = packet.readUInt64(); if (data.status == 1) { // Online - data.note = packet.readString(); - data.chatFlag = packet.readUInt8(); + // Conditional: note (string) + chatFlag (1) + if (packet.getReadPos() < packet.getSize()) { + data.note = packet.readString(); + if (packet.getReadPos() + 1 <= packet.getSize()) { + data.chatFlag = packet.readUInt8(); + } + } } LOG_DEBUG("Parsed SMSG_FRIEND_STATUS: status=", (int)data.status, " guid=0x", std::hex, data.guid, std::dec); return true; @@ -1711,6 +2041,12 @@ network::Packet LogoutCancelPacket::build() { } bool LogoutResponseParser::parse(network::Packet& packet, LogoutResponseData& data) { + // Validate minimum packet size: result(4) + instant(1) + if (packet.getSize() - packet.getReadPos() < 5) { + LOG_WARNING("SMSG_LOGOUT_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.result = packet.readUInt32(); data.instant = packet.readUInt8(); LOG_DEBUG("Parsed SMSG_LOGOUT_RESPONSE: result=", data.result, " instant=", (int)data.instant); @@ -1728,6 +2064,42 @@ network::Packet StandStateChangePacket::build(uint8_t state) { return packet; } +// ============================================================ +// Action Bar +// ============================================================ + +network::Packet SetActionButtonPacket::build(uint8_t button, uint8_t type, uint32_t id, bool isClassic) { + // Classic/Turtle (1.12): uint8 button + uint16 id + uint8 type + uint8 misc(0) + // type encoding: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint8 button + uint32 packed (type<<24 | id) + // type encoding: 0x00=spell, 0x80=item, 0x40=macro + // packed=0 means clear the slot + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTION_BUTTON)); + packet.writeUInt8(button); + if (isClassic) { + // Classic: 16-bit id, 8-bit type code, 8-bit misc + // Map ActionBarSlot::Type (0=EMPTY,1=SPELL,2=ITEM,3=MACRO) → classic type byte + uint8_t classicType = 0; // 0 = spell + if (type == 2 /* ITEM */) classicType = 1; + if (type == 3 /* MACRO */) classicType = 64; + packet.writeUInt16(static_cast(id)); + packet.writeUInt8(classicType); + packet.writeUInt8(0); // misc + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (Classic): button=", (int)button, + " id=", id, " type=", (int)classicType); + } else { + // TBC/WotLK: type in bits 24–31, id in bits 0–23; packed=0 clears slot + uint8_t packedType = 0x00; // spell + if (type == 2 /* ITEM */) packedType = 0x80; + if (type == 3 /* MACRO */) packedType = 0x40; + uint32_t packed = (id == 0) ? 0 : (static_cast(packedType) << 24) | (id & 0x00FFFFFF); + packet.writeUInt32(packed); + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (TBC/WotLK): button=", (int)button, + " packed=0x", std::hex, packed, std::dec); + } + return packet; +} + // ============================================================ // Display Toggles // ============================================================ @@ -1946,15 +2318,42 @@ bool GuildQueryResponseParser::parse(network::Packet& packet, GuildQueryResponse return false; } data.guildId = packet.readUInt32(); - data.guildName = packet.readString(); - for (int i = 0; i < 10; ++i) { - data.rankNames[i] = packet.readString(); + + // Validate before reading guild name + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("GuildQueryResponseParser: truncated before guild name"); + data.guildName.clear(); + return true; } + data.guildName = packet.readString(); + + // Read 10 rank names with validation + for (int i = 0; i < 10; ++i) { + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("GuildQueryResponseParser: truncated at rank name ", i); + data.rankNames[i].clear(); + } else { + data.rankNames[i] = packet.readString(); + } + } + + // Validate before reading emblem fields (5 uint32s = 20 bytes) + if (packet.getReadPos() + 20 > packet.getSize()) { + LOG_WARNING("GuildQueryResponseParser: truncated before emblem data"); + data.emblemStyle = 0; + data.emblemColor = 0; + data.borderStyle = 0; + data.borderColor = 0; + data.backgroundColor = 0; + return true; + } + data.emblemStyle = packet.readUInt32(); data.emblemColor = packet.readUInt32(); data.borderStyle = packet.readUInt32(); data.borderColor = packet.readUInt32(); data.backgroundColor = packet.readUInt32(); + if ((packet.getSize() - packet.getReadPos()) >= 4) { data.rankCount = packet.readUInt32(); } @@ -1983,16 +2382,49 @@ bool GuildRosterParser::parse(network::Packet& packet, GuildRosterData& data) { return false; } uint32_t numMembers = packet.readUInt32(); + + // Cap members and ranks to prevent unbounded memory allocation + const uint32_t MAX_GUILD_MEMBERS = 1000; + if (numMembers > MAX_GUILD_MEMBERS) { + LOG_WARNING("GuildRosterParser: numMembers capped (requested=", numMembers, ")"); + numMembers = MAX_GUILD_MEMBERS; + } + data.motd = packet.readString(); data.guildInfo = packet.readString(); + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("GuildRosterParser: truncated before rankCount"); + data.ranks.clear(); + data.members.clear(); + return true; + } + uint32_t rankCount = packet.readUInt32(); + + // Cap rank count to prevent unbounded allocation + const uint32_t MAX_GUILD_RANKS = 20; + if (rankCount > MAX_GUILD_RANKS) { + LOG_WARNING("GuildRosterParser: rankCount capped (requested=", rankCount, ")"); + rankCount = MAX_GUILD_RANKS; + } + data.ranks.resize(rankCount); for (uint32_t i = 0; i < rankCount; ++i) { + // Validate 4 bytes before each rank rights read + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("GuildRosterParser: truncated rank at index ", i); + break; + } data.ranks[i].rights = packet.readUInt32(); - data.ranks[i].goldLimit = packet.readUInt32(); + if (packet.getReadPos() + 4 > packet.getSize()) { + data.ranks[i].goldLimit = 0; + } else { + data.ranks[i].goldLimit = packet.readUInt32(); + } // 6 bank tab flags + 6 bank tab items per day for (int t = 0; t < 6; ++t) { + if (packet.getReadPos() + 8 > packet.getSize()) break; packet.readUInt32(); // tabFlags packet.readUInt32(); // tabItemsPerDay } @@ -2000,20 +2432,68 @@ bool GuildRosterParser::parse(network::Packet& packet, GuildRosterData& data) { data.members.resize(numMembers); for (uint32_t i = 0; i < numMembers; ++i) { + // Validate minimum bytes before reading member (guid+online+name at minimum is 9+ bytes) + if (packet.getReadPos() + 9 > packet.getSize()) { + LOG_WARNING("GuildRosterParser: truncated member at index ", i); + break; + } auto& m = data.members[i]; m.guid = packet.readUInt64(); m.online = (packet.readUInt8() != 0); - m.name = packet.readString(); - m.rankIndex = packet.readUInt32(); - m.level = packet.readUInt8(); - m.classId = packet.readUInt8(); - m.gender = packet.readUInt8(); - m.zoneId = packet.readUInt32(); - if (!m.online) { - m.lastOnline = packet.readFloat(); + + // Validate before reading name string + if (packet.getReadPos() >= packet.getSize()) { + m.name.clear(); + } else { + m.name = packet.readString(); + } + + // Validate before reading rank/level/class/gender/zone + if (packet.getReadPos() + 1 > packet.getSize()) { + m.rankIndex = 0; + m.level = 1; + m.classId = 0; + m.gender = 0; + m.zoneId = 0; + } else { + m.rankIndex = packet.readUInt32(); + if (packet.getReadPos() + 3 > packet.getSize()) { + m.level = 1; + m.classId = 0; + m.gender = 0; + } else { + m.level = packet.readUInt8(); + m.classId = packet.readUInt8(); + m.gender = packet.readUInt8(); + } + if (packet.getReadPos() + 4 > packet.getSize()) { + m.zoneId = 0; + } else { + m.zoneId = packet.readUInt32(); + } + } + + // Online status affects next fields + if (!m.online) { + if (packet.getReadPos() + 4 > packet.getSize()) { + m.lastOnline = 0.0f; + } else { + m.lastOnline = packet.readFloat(); + } + } + + // Read notes + if (packet.getReadPos() >= packet.getSize()) { + m.publicNote.clear(); + m.officerNote.clear(); + } else { + m.publicNote = packet.readString(); + if (packet.getReadPos() >= packet.getSize()) { + m.officerNote.clear(); + } else { + m.officerNote = packet.readString(); + } } - m.publicNote = packet.readString(); - m.officerNote = packet.readString(); } LOG_INFO("Parsed SMSG_GUILD_ROSTER: ", numMembers, " members, motd=", data.motd); return true; @@ -2153,6 +2633,35 @@ network::Packet AcceptTradePacket::build() { return packet; } +network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + packet.writeUInt8(bag); + packet.writeUInt8(bagSlot); + LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", (int)tradeSlot, " bag=", (int)bag, " bagSlot=", (int)bagSlot); + return packet; +} + +network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", (int)tradeSlot); + return packet; +} + +network::Packet SetTradeGoldPacket::build(uint64_t copper) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD)); + packet.writeUInt64(copper); + LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper); + return packet; +} + +network::Packet UnacceptTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE)); + LOG_DEBUG("Built CMSG_UNACCEPT_TRADE"); + return packet; +} + network::Packet InitiateTradePacket::build(uint64_t targetGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE)); packet.writeUInt64(targetGuid); @@ -2194,6 +2703,12 @@ network::Packet RandomRollPacket::build(uint32_t minRoll, uint32_t maxRoll) { } bool RandomRollParser::parse(network::Packet& packet, RandomRollData& data) { + // Validate minimum packet size: rollerGuid(8) + targetGuid(8) + minRoll(4) + maxRoll(4) + result(4) + if (packet.getSize() - packet.getReadPos() < 28) { + LOG_WARNING("SMSG_RANDOM_ROLL: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.rollerGuid = packet.readUInt64(); data.targetGuid = packet.readUInt64(); data.minRoll = packet.readUInt32(); @@ -2214,7 +2729,17 @@ network::Packet NameQueryPacket::build(uint64_t playerGuid) { bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseData& data) { // 3.3.5a: packedGuid, uint8 found // If found==0: CString name, CString realmName, uint8 race, uint8 gender, uint8 classId + // Validation: packed GUID (1-8 bytes) + found flag (1 byte minimum) + if (packet.getSize() - packet.getReadPos() < 2) return false; // At least 1 for packed GUID + 1 for found + + size_t startPos = packet.getReadPos(); data.guid = UpdateObjectParser::readPackedGuid(packet); + + // Validate found flag read + if (packet.getSize() - packet.getReadPos() < 1) { + packet.setReadPos(startPos); + return false; + } data.found = packet.readUInt8(); if (data.found != 0) { @@ -2222,8 +2747,25 @@ bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseDa return true; // Valid response, just not found } + // Validate strings: need at least 2 null terminators for empty strings + if (packet.getSize() - packet.getReadPos() < 2) { + data.name.clear(); + data.realmName.clear(); + return !data.name.empty(); // Fail if name was required + } + data.name = packet.readString(); data.realmName = packet.readString(); + + // Validate final 3 uint8 fields (race, gender, classId) + if (packet.getSize() - packet.getReadPos() < 3) { + LOG_WARNING("Name query: truncated fields after realmName, expected 3 uint8s"); + data.race = 0; + data.gender = 0; + data.classId = 0; + return !data.name.empty(); + } + data.race = packet.readUInt8(); data.gender = packet.readUInt8(); data.classId = packet.readUInt8(); @@ -2242,6 +2784,12 @@ network::Packet CreatureQueryPacket::build(uint32_t entry, uint64_t guid) { } bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_CREATURE_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); // High bit set means creature not found @@ -2259,6 +2807,18 @@ bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryRe packet.readString(); // name4 data.subName = packet.readString(); data.iconName = packet.readString(); + + // WotLK: 4 fixed fields after iconName (typeFlags, creatureType, family, rank) + // Validate minimum size for these fields: 4×4 = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) { + LOG_WARNING("SMSG_CREATURE_QUERY_RESPONSE: truncated before typeFlags (entry=", data.entry, ")"); + data.typeFlags = 0; + data.creatureType = 0; + data.family = 0; + data.rank = 0; + return true; // Have name/sub/icon; base fields are important but optional + } + data.typeFlags = packet.readUInt32(); data.creatureType = packet.readUInt32(); data.family = packet.readUInt32(); @@ -2283,6 +2843,12 @@ network::Packet GameObjectQueryPacket::build(uint32_t entry, uint64_t guid) { } bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); // High bit set means gameobject not found @@ -2293,6 +2859,12 @@ bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQue return true; } + // Validate minimum size for fixed fields: type(4) + displayId(4) + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_ERROR("SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + data.type = packet.readUInt32(); // GameObjectType data.displayId = packet.readUInt32(); // 4 name strings (only first is usually populated) @@ -2314,6 +2886,16 @@ bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQue data.data[i] = packet.readUInt32(); } data.hasData = true; + } else if (remaining > 0) { + // Partial data field; read what we can + uint32_t fieldsToRead = remaining / 4; + for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + if (fieldsToRead < 24) { + LOG_WARNING("SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead, + " of 24 read, entry=", data.entry, ")"); + } } LOG_DEBUG("GameObject query response: ", data.name, " (type=", data.type, " entry=", data.entry, ")"); @@ -2376,6 +2958,12 @@ static const char* getItemSubclassName(uint32_t itemClass, uint32_t subClass) { } bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseData& data) { + // Validate minimum packet size: entry(4) + item not found check + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.entry = packet.readUInt32(); // High bit set means item not found @@ -2385,6 +2973,13 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa return true; } + // Validate minimum size for fixed fields before reading: itemClass(4) + subClass(4) + soundOverride(4) + // + 4 name strings + displayInfoId(4) + quality(4) = at least 24 bytes more + if (packet.getSize() - packet.getReadPos() < 24) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before displayInfoId (entry=", data.entry, ")"); + return false; + } + uint32_t itemClass = packet.readUInt32(); uint32_t subClass = packet.readUInt32(); data.itemClass = itemClass; @@ -2406,7 +3001,11 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa // Some server variants omit BuyCount (4 fields instead of 5). // Read 5 fields and validate InventoryType; if it looks implausible, rewind and try 4. const size_t postQualityPos = packet.getReadPos(); - packet.readUInt32(); // Flags + if (packet.getSize() - packet.getReadPos() < 24) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before flags (entry=", data.entry, ")"); + return false; + } + data.itemFlags = packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyCount packet.readUInt32(); // BuyPrice @@ -2416,32 +3015,56 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa if (data.inventoryType > 28) { // inventoryType out of range — BuyCount probably not present; rewind and try 4 fields packet.setReadPos(postQualityPos); - packet.readUInt32(); // Flags + data.itemFlags = packet.readUInt32(); // Flags packet.readUInt32(); // Flags2 packet.readUInt32(); // BuyPrice data.sellPrice = packet.readUInt32(); // SellPrice data.inventoryType = packet.readUInt32(); } - packet.readUInt32(); // AllowableClass - packet.readUInt32(); // AllowableRace - packet.readUInt32(); // ItemLevel - packet.readUInt32(); // RequiredLevel - packet.readUInt32(); // RequiredSkill - packet.readUInt32(); // RequiredSkillRank + // Validate minimum size for remaining fixed fields before inventoryType through containerSlots: 13×4 = 52 bytes + if (packet.getSize() - packet.getReadPos() < 52) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")"); + return false; + } + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank packet.readUInt32(); // RequiredSpell packet.readUInt32(); // RequiredHonorRank packet.readUInt32(); // RequiredCityRank - packet.readUInt32(); // RequiredReputationFaction - packet.readUInt32(); // RequiredReputationRank - packet.readUInt32(); // MaxCount + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) data.maxStack = static_cast(packet.readUInt32()); // Stackable data.containerSlots = packet.readUInt32(); + // Read statsCount with bounds validation + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated at statsCount (entry=", data.entry, ")"); + return true; // Have enough for core fields; stats are optional + } uint32_t statsCount = packet.readUInt32(); + + // Cap statsCount to prevent excessive iteration + constexpr uint32_t kMaxItemStats = 10; + if (statsCount > kMaxItemStats) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: statsCount=", statsCount, " exceeds max ", + kMaxItemStats, " (entry=", data.entry, "), capping"); + statsCount = kMaxItemStats; + } + // Server sends exactly statsCount stat pairs (not always 10). uint32_t statsToRead = std::min(statsCount, 10u); for (uint32_t i = 0; i < statsToRead; i++) { + // Each stat is 2 uint32s (type + value) = 8 bytes + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")"); + break; + } uint32_t statType = packet.readUInt32(); int32_t statValue = static_cast(packet.readUInt32()); switch (statType) { @@ -2450,10 +3073,18 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa case 5: data.intellect = statValue; break; case 6: data.spirit = statValue; break; case 7: data.stamina = statValue; break; - default: break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; } } + // ScalingStatDistribution and ScalingStatValue + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before scaling stats (entry=", data.entry, ")"); + return true; // Have core fields; scaling is optional + } packet.readUInt32(); // ScalingStatDistribution packet.readUInt32(); // ScalingStatValue @@ -2473,12 +3104,12 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa } data.armor = static_cast(packet.readUInt32()); - packet.readUInt32(); // HolyRes - packet.readUInt32(); // FireRes - packet.readUInt32(); // NatureRes - packet.readUInt32(); // FrostRes - packet.readUInt32(); // ShadowRes - packet.readUInt32(); // ArcaneRes + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes data.delayMs = packet.readUInt32(); packet.readUInt32(); // AmmoType packet.readFloat(); // RangedModRange @@ -2494,6 +3125,45 @@ bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseDa packet.readUInt32(); // SpellCategoryCooldown } + // Bonding type (0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ) + if (packet.getReadPos() + 4 <= packet.getSize()) + data.bindType = packet.readUInt32(); + + // Flavor/lore text (Description cstring) + if (packet.getReadPos() < packet.getSize()) + data.description = packet.readString(); + + // Post-description fields: PageText, LanguageID, PageMaterial, StartQuest + if (packet.getReadPos() + 16 <= packet.getSize()) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + + // WotLK 3.3.5a: additional fields after StartQuest (read up to socket data) + // LockID(4), Material(4), Sheath(4), RandomProperty(4), RandomSuffix(4), + // Block(4), ItemSet(4), MaxDurability(4), Area(4), Map(4), BagFamily(4), + // TotemCategory(4) = 48 bytes before sockets + constexpr size_t kPreSocketSkip = 48; + if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) { + // LockID(0), Material(1), Sheath(2), RandomProperty(3), RandomSuffix(4), Block(5) + for (size_t i = 0; i < 6; ++i) packet.readUInt32(); + data.itemSetId = packet.readUInt32(); // ItemSet(6) + // MaxDurability(7), Area(8), Map(9), BagFamily(10), TotemCategory(11) + for (size_t i = 0; i < 5; ++i) packet.readUInt32(); + // 3 socket slots: socketColor (4 bytes each) + data.socketColor[0] = packet.readUInt32(); + data.socketColor[1] = packet.readUInt32(); + data.socketColor[2] = packet.readUInt32(); + // 3 socket content (gem enchantment IDs — skip, not currently displayed) + packet.readUInt32(); + packet.readUInt32(); + packet.readUInt32(); + // socketBonus (enchantmentId) + data.socketBonus = packet.readUInt32(); + } + data.valid = !data.name.empty(); return true; } @@ -2583,6 +3253,13 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { if (pointCount == 0) return true; + constexpr uint32_t kMaxSplinePoints = 1000; + if (pointCount > kMaxSplinePoints) { + LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, + " (guid=0x", std::hex, data.guid, std::dec, ")"); + return false; + } + // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). // Otherwise: first float3 is final destination, remaining are packed deltas. bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; @@ -2684,10 +3361,20 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d uint32_t pointCount = packet.readUInt32(); if (pointCount == 0) return true; - if (pointCount > 16384) return false; // sanity + + // Reject extreme point counts from malformed packets. + constexpr uint32_t kMaxSplinePoints = 1000; + if (pointCount > kMaxSplinePoints) { + return false; + } + + size_t requiredBytes = 12; + if (pointCount > 1) { + requiredBytes += static_cast(pointCount - 1) * 4ull; + } + if (packet.getReadPos() + requiredBytes > packet.getSize()) return false; // First float[3] is destination. - if (packet.getReadPos() + 12 > packet.getSize()) return true; data.destX = packet.readFloat(); data.destY = packet.readFloat(); data.destZ = packet.readFloat(); @@ -2697,9 +3384,8 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d if (pointCount > 1) { size_t skipBytes = static_cast(pointCount - 1) * 4; size_t newPos = packet.getReadPos() + skipBytes; - if (newPos <= packet.getSize()) { - packet.setReadPos(newPos); - } + if (newPos > packet.getSize()) return false; + packet.setReadPos(newPos); } LOG_DEBUG("MonsterMove(turtle): guid=0x", std::hex, data.guid, std::dec, @@ -2734,13 +3420,53 @@ bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { } bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpdateData& data) { + // Upfront validation: hitInfo(4) + packed GUIDs(1-8 each) + totalDamage(4) + subDamageCount(1) = 13 bytes minimum + if (packet.getSize() - packet.getReadPos() < 13) return false; + + size_t startPos = packet.getReadPos(); data.hitInfo = packet.readUInt32(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + + // Validate totalDamage + subDamageCount can be read (5 bytes) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(startPos); + return false; + } + data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); + // Cap subDamageCount: each entry is 20 bytes. If the claimed count + // exceeds what the remaining bytes can hold, a GUID was mis-parsed + // (off by one byte), causing the school-mask byte to be read as count. + // In that case clamp to the number of full entries that fit. + { + size_t remaining = packet.getSize() - packet.getReadPos(); + size_t maxFit = remaining / 20; + if (data.subDamageCount > maxFit) { + data.subDamageCount = static_cast(std::min(maxFit, 64)); + } else if (data.subDamageCount > 64) { + data.subDamageCount = 64; + } + } + if (data.subDamageCount == 0) return false; + + data.subDamages.reserve(data.subDamageCount); for (uint8_t i = 0; i < data.subDamageCount; ++i) { + // Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4) + if (packet.getSize() - packet.getReadPos() < 20) { + data.subDamageCount = i; + break; + } SubDamage sub; sub.schoolMask = packet.readUInt32(); sub.damage = packet.readFloat(); @@ -2750,14 +3476,28 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda data.subDamages.push_back(sub); } - data.victimState = packet.readUInt32(); - data.overkill = static_cast(packet.readUInt32()); - - // Read blocked amount - if (packet.getReadPos() < packet.getSize()) { - data.blocked = packet.readUInt32(); + // Validate victimState + overkill fields (8 bytes) + if (packet.getSize() - packet.getReadPos() < 8) { + data.victimState = 0; + data.overkill = 0; + return !data.subDamages.empty(); } + data.victimState = packet.readUInt32(); + // WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill. + // Older parsers omitted these, reading overkill from the wrong offset. + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() >= 4) packet.readUInt32(); // unk1 (always 0) + if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack) + data.overkill = (rem() >= 4) ? static_cast(packet.readUInt32()) : -1; + + // hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40) + if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32(); + else data.blocked = 0; + // RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip + if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain + if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total + LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", data.isCrit() ? " (CRIT)" : "", data.isMiss() ? " (MISS)" : ""); @@ -2765,8 +3505,30 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda } bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& data) { + // Upfront validation: + // packed GUIDs(1-8 each) + spellId(4) + damage(4) + overkill(4) + schoolMask(1) + // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) + // = 33 bytes minimum. + if (packet.getSize() - packet.getReadPos() < 33) return false; + + size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); + + // Validate core fields (spellId + damage + overkill + schoolMask + absorbed + resisted = 21 bytes) + if (packet.getSize() - packet.getReadPos() < 21) { + packet.setReadPos(startPos); + return false; + } + data.spellId = packet.readUInt32(); data.damage = packet.readUInt32(); data.overkill = packet.readUInt32(); @@ -2774,7 +3536,13 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da data.absorbed = packet.readUInt32(); data.resisted = packet.readUInt32(); - // Skip remaining fields + // Remaining fields are required for a complete event. + // Reject truncated packets so we do not emit partial/incorrect combat entries. + if (packet.getSize() - packet.getReadPos() < 10) { + packet.setReadPos(startPos); + return false; + } + uint8_t periodicLog = packet.readUInt8(); (void)periodicLog; packet.readUInt8(); // unused @@ -2790,8 +3558,27 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da } bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) { + // Upfront validation: packed GUIDs(1-8 each) + spellId(4) + heal(4) + overheal(4) + absorbed(4) + critFlag(1) = 21 bytes minimum + if (packet.getSize() - packet.getReadPos() < 21) return false; + + size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + + // Validate remaining fields (spellId + heal + overheal + absorbed + critFlag = 17 bytes) + if (packet.getSize() - packet.getReadPos() < 17) { + packet.setReadPos(startPos); + return false; + } + data.spellId = packet.readUInt32(); data.heal = packet.readUInt32(); data.overheal = packet.readUInt32(); @@ -2809,16 +3596,25 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) // ============================================================ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { + // Validate minimum packet size: victimGuid(8) + totalXp(4) + type(1) + if (packet.getSize() - packet.getReadPos() < 13) { + LOG_WARNING("SMSG_LOG_XPGAIN: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.victimGuid = packet.readUInt64(); data.totalXp = packet.readUInt32(); data.type = packet.readUInt8(); if (data.type == 0) { // Kill XP: float groupRate (1.0 = solo) + uint8 RAF flag - float groupRate = packet.readFloat(); - packet.readUInt8(); // RAF bonus flag - // Group bonus = total - (total / rate); only if grouped (rate > 1) - if (groupRate > 1.0f) { - data.groupBonus = data.totalXp - static_cast(data.totalXp / groupRate); + // Validate before reading conditional fields + if (packet.getReadPos() + 5 <= packet.getSize()) { + float groupRate = packet.readFloat(); + packet.readUInt8(); // RAF bonus flag + // Group bonus = total - (total / rate); only if grouped (rate > 1) + if (groupRate > 1.0f) { + data.groupBonus = data.totalXp - static_cast(data.totalXp / groupRate); + } } } LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); @@ -2829,21 +3625,40 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { // Phase 3: Spells, Action Bar, Auras // ============================================================ -bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data) { - size_t packetSize = packet.getSize(); +bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data, + bool vanillaFormat) { + // Validate minimum packet size for header: talentSpec(1) + spellCount(2) + if (packet.getSize() - packet.getReadPos() < 3) { + LOG_ERROR("SMSG_INITIAL_SPELLS: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.talentSpec = packet.readUInt8(); uint16_t spellCount = packet.readUInt16(); - // Detect vanilla (uint16 spellId) vs WotLK (uint32 spellId) format - // Vanilla: 4 bytes/spell (uint16 id + uint16 slot), WotLK: 6 bytes/spell (uint32 id + uint16 unk) - size_t remainingAfterHeader = packetSize - 3; // subtract talentSpec(1) + spellCount(2) - bool vanillaFormat = remainingAfterHeader < static_cast(spellCount) * 6 + 2; + // Cap spell count to prevent excessive iteration. + // WotLK characters with all ranks, mounts, professions, and racials can + // know 400-600 spells; 1024 covers all practical cases with headroom. + constexpr uint16_t kMaxSpells = 1024; + if (spellCount > kMaxSpells) { + LOG_WARNING("SMSG_INITIAL_SPELLS: spellCount=", spellCount, " exceeds max ", kMaxSpells, + ", capping"); + spellCount = kMaxSpells; + } - LOG_DEBUG("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount, - vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)"); + LOG_DEBUG("SMSG_INITIAL_SPELLS: spellCount=", spellCount, + vanillaFormat ? " (vanilla uint16 format)" : " (TBC/WotLK uint32 format)"); data.spellIds.reserve(spellCount); for (uint16_t i = 0; i < spellCount; ++i) { + // Vanilla spell: spellId(2) + slot(2) = 4 bytes + // TBC/WotLK spell: spellId(4) + unknown(2) = 6 bytes + size_t spellEntrySize = vanillaFormat ? 4 : 6; + if (packet.getSize() - packet.getReadPos() < spellEntrySize) { + LOG_WARNING("SMSG_INITIAL_SPELLS: spell ", i, " truncated (", spellCount, " expected)"); + break; + } + uint32_t spellId; if (vanillaFormat) { spellId = packet.readUInt16(); @@ -2857,9 +3672,35 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data } } + // Validate minimum packet size for cooldownCount (2 bytes) + if (packet.getSize() - packet.getReadPos() < 2) { + LOG_WARNING("SMSG_INITIAL_SPELLS: truncated before cooldownCount (parsed ", data.spellIds.size(), + " spells)"); + return true; // Have spells; cooldowns are optional + } + uint16_t cooldownCount = packet.readUInt16(); + + // Cap cooldown count to prevent excessive iteration. + // Some servers include entries for all spells (even with zero remaining time) + // to communicate category cooldown data, so the count can be high. + constexpr uint16_t kMaxCooldowns = 1024; + if (cooldownCount > kMaxCooldowns) { + LOG_WARNING("SMSG_INITIAL_SPELLS: cooldownCount=", cooldownCount, " exceeds max ", kMaxCooldowns, + ", capping"); + cooldownCount = kMaxCooldowns; + } + data.cooldowns.reserve(cooldownCount); for (uint16_t i = 0; i < cooldownCount; ++i) { + // Vanilla cooldown: spellId(2) + itemId(2) + categoryId(2) + cooldownMs(4) + categoryCooldownMs(4) = 14 bytes + // TBC/WotLK cooldown: spellId(4) + itemId(2) + categoryId(2) + cooldownMs(4) + categoryCooldownMs(4) = 16 bytes + size_t cooldownEntrySize = vanillaFormat ? 14 : 16; + if (packet.getSize() - packet.getReadPos() < cooldownEntrySize) { + LOG_WARNING("SMSG_INITIAL_SPELLS: cooldown ", i, " truncated (", cooldownCount, " expected)"); + break; + } + SpellCooldownEntry entry; if (vanillaFormat) { entry.spellId = packet.readUInt16(); @@ -2940,6 +3781,9 @@ network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action, uint64 } bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { + // WotLK format: castCount(1) + spellId(4) + result(1) = 6 bytes minimum + if (packet.getSize() - packet.getReadPos() < 6) return false; + data.castCount = packet.readUInt8(); data.spellId = packet.readUInt32(); data.result = packet.readUInt8(); @@ -2948,19 +3792,79 @@ bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { } bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { + data = SpellStartData{}; + + // Packed GUIDs are variable-length; only require minimal packet shape up front: + // two GUID masks + castCount(1) + spellId(4) + castFlags(4) + castTime(4). + if (packet.getSize() - packet.getReadPos() < 15) return false; + + size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + + // Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 13 bytes) + if (packet.getSize() - packet.getReadPos() < 13) { + packet.setReadPos(startPos); + return false; + } + data.castCount = packet.readUInt8(); data.spellId = packet.readUInt32(); data.castFlags = packet.readUInt32(); data.castTime = packet.readUInt32(); - // Read target flags and target (simplified) - if (packet.getReadPos() < packet.getSize()) { - uint32_t targetFlags = packet.readUInt32(); - if (targetFlags & 0x02) { // TARGET_FLAG_UNIT - data.targetGuid = UpdateObjectParser::readPackedGuid(packet); - } + // SpellCastTargets starts with target flags and is mandatory. + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_WARNING("Spell start: missing targetFlags"); + packet.setReadPos(startPos); + return false; + } + + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // subsequent fields (e.g. school mask, cast flags 0x20 extra data) are not + // misaligned for ground-targeted or AoE spells. + uint32_t targetFlags = packet.readUInt32(); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!hasFullPackedGuid(packet)) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!hasFullPackedGuid(packet)) return false; + UpdateObjectParser::readPackedGuid(packet); // transport GUID (may be zero) + if (packet.getSize() - packet.getReadPos() < 12) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share a single object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); // best-effort; ignore failure + } + // ITEM/TRADE_ITEM share a single item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {} } LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); @@ -2968,27 +3872,176 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { } bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { + // Always reset output to avoid stale targets when callers reuse buffers. + data = SpellGoData{}; + + // Packed GUIDs are variable-length, so only require the smallest possible + // shape up front: 2 GUID masks + fixed fields through hitCount. + if (packet.getSize() - packet.getReadPos() < 16) return false; + + size_t startPos = packet.getReadPos(); + if (!hasFullPackedGuid(packet)) { + return false; + } data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (!hasFullPackedGuid(packet)) { + packet.setReadPos(startPos); + return false; + } data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + + // Validate remaining fixed fields up to hitCount/missCount + if (packet.getSize() - packet.getReadPos() < 14) { // castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1) + packet.setReadPos(startPos); + return false; + } + data.castCount = packet.readUInt8(); data.spellId = packet.readUInt32(); data.castFlags = packet.readUInt32(); // Timestamp in 3.3.5a packet.readUInt32(); - data.hitCount = packet.readUInt8(); - data.hitTargets.reserve(data.hitCount); - for (uint8_t i = 0; i < data.hitCount; ++i) { - data.hitTargets.push_back(packet.readUInt64()); + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("Spell go: hitCount capped (requested=", (int)rawHitCount, ")"); + } + const uint8_t storedHitLimit = std::min(rawHitCount, 128); + + bool truncatedTargets = false; + + data.hitTargets.reserve(storedHitLimit); + for (uint16_t i = 0; i < rawHitCount; ++i) { + // WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid). + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount); + truncatedTargets = true; + break; + } + const uint64_t targetGuid = packet.readUInt64(); + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } + } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } + data.hitCount = static_cast(data.hitTargets.size()); + + // missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. + if (packet.getSize() - packet.getReadPos() < 1) { + LOG_WARNING("Spell go: missing missCount after hit target list"); + packet.setReadPos(startPos); + return false; } - data.missCount = packet.readUInt8(); - data.missTargets.reserve(data.missCount); - for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 2 <= packet.getSize(); ++i) { + const size_t missCountPos = packet.getReadPos(); + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 20) { + // Likely offset error — dump context bytes for diagnostics. + const auto& raw = packet.getData(); + std::string hexCtx; + size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos; + size_t dumpEnd = std::min(missCountPos + 16, raw.size()); + for (size_t i = dumpStart; i < dumpEnd; ++i) { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%02x ", raw[i]); + hexCtx += buf; + if (i == missCountPos - 1) hexCtx += "["; + if (i == missCountPos) hexCtx += "] "; + } + LOG_WARNING("Spell go: suspect missCount=", (int)rawMissCount, + " spell=", data.spellId, " hits=", (int)data.hitCount, + " castFlags=0x", std::hex, data.castFlags, std::dec, + " missCountPos=", missCountPos, " pktSize=", packet.getSize(), + " ctx=", hexCtx); + } + if (rawMissCount > 128) { + LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, + ") spell=", data.spellId, " hits=", (int)data.hitCount, + " remaining=", packet.getSize() - packet.getReadPos()); + } + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount; ++i) { + // WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType. + // REFLECT additionally appends uint8 reflectResult. + if (packet.getSize() - packet.getReadPos() < 9) { // 8 GUID + 1 missType + LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount, + " spell=", data.spellId, " hits=", (int)data.hitCount); + truncatedTargets = true; + break; + } SpellGoMissEntry m; - m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK - m.missType = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; - data.missTargets.push_back(m); + m.targetGuid = packet.readUInt64(); + m.missType = packet.readUInt8(); + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (packet.getSize() - packet.getReadPos() < 1) { + LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount); + truncatedTargets = true; + break; + } + (void)packet.readUInt8(); // reflectResult + } + if (i < storedMissLimit) { + data.missTargets.push_back(m); + } + } + data.missCount = static_cast(data.missTargets.size()); + + // If miss targets were truncated, salvage the successfully-parsed hit data + // rather than discarding the entire spell. The server already applied effects; + // we just need the hit list for UI feedback (combat text, health bars). + if (truncatedTargets) { + LOG_DEBUG("Spell go: salvaging ", (int)data.hitCount, " hits despite miss truncation"); + packet.setReadPos(packet.getSize()); // consume remaining bytes + return true; + } + + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // any trailing fields after the target section are not misaligned for + // ground-targeted or AoE spells. Same layout as SpellStartParser. + if (packet.getReadPos() < packet.getSize()) { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t targetFlags = packet.readUInt32(); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!hasFullPackedGuid(packet)) return false; + uint64_t g = UpdateObjectParser::readPackedGuid(packet); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!hasFullPackedGuid(packet)) return false; + UpdateObjectParser::readPackedGuid(packet); // transport GUID + if (packet.getSize() - packet.getReadPos() < 12) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); + } + // ITEM/TRADE_ITEM share one item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.getReadPos() < packet.getSize() && packet.readUInt8() != 0) {} + } + } } LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, @@ -2997,34 +4050,71 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool isAll) { + // Validation: packed GUID (1-8 bytes minimum for reading) + if (packet.getSize() - packet.getReadPos() < 1) return false; + data.guid = UpdateObjectParser::readPackedGuid(packet); - while (packet.getReadPos() < packet.getSize()) { + // Cap number of aura entries to prevent unbounded loop DoS + uint32_t maxAuras = isAll ? 512 : 1; + uint32_t auraCount = 0; + + while (packet.getReadPos() < packet.getSize() && auraCount < maxAuras) { + // Validate we can read slot (1) + spellId (4) = 5 bytes minimum + if (packet.getSize() - packet.getReadPos() < 5) { + LOG_DEBUG("Aura update: truncated entry at position ", auraCount); + break; + } + uint8_t slot = packet.readUInt8(); uint32_t spellId = packet.readUInt32(); + auraCount++; AuraSlot aura; if (spellId != 0) { aura.spellId = spellId; - aura.flags = packet.readUInt8(); - aura.level = packet.readUInt8(); - aura.charges = packet.readUInt8(); - if (!(aura.flags & 0x08)) { // NOT_CASTER flag - aura.casterGuid = UpdateObjectParser::readPackedGuid(packet); + // Validate flags + level + charges (3 bytes) + if (packet.getSize() - packet.getReadPos() < 3) { + LOG_WARNING("Aura update: truncated flags/level/charges at entry ", auraCount); + aura.flags = 0; + aura.level = 0; + aura.charges = 0; + } else { + aura.flags = packet.readUInt8(); + aura.level = packet.readUInt8(); + aura.charges = packet.readUInt8(); } - if (aura.flags & 0x20) { // DURATION - aura.maxDurationMs = static_cast(packet.readUInt32()); - aura.durationMs = static_cast(packet.readUInt32()); + if (!(aura.flags & 0x08)) { // NOT_CASTER flag + // Validate space for packed GUID read (minimum 1 byte) + if (packet.getSize() - packet.getReadPos() < 1) { + aura.casterGuid = 0; + } else { + aura.casterGuid = UpdateObjectParser::readPackedGuid(packet); + } + } + + if (aura.flags & 0x20) { // DURATION - need 8 bytes (two uint32s) + if (packet.getSize() - packet.getReadPos() < 8) { + LOG_WARNING("Aura update: truncated duration fields at entry ", auraCount); + aura.maxDurationMs = 0; + aura.durationMs = 0; + } else { + aura.maxDurationMs = static_cast(packet.readUInt32()); + aura.durationMs = static_cast(packet.readUInt32()); + } } if (aura.flags & 0x40) { // EFFECT_AMOUNTS // Only read amounts for active effect indices (flags 0x01, 0x02, 0x04) for (int i = 0; i < 3; ++i) { if (aura.flags & (1 << i)) { - if (packet.getReadPos() < packet.getSize()) { + if (packet.getSize() - packet.getReadPos() >= 4) { packet.readUInt32(); + } else { + LOG_WARNING("Aura update: truncated effect amount ", i, " at entry ", auraCount); + break; } } } @@ -3037,19 +4127,35 @@ bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool if (!isAll) break; } + if (auraCount >= maxAuras && packet.getReadPos() < packet.getSize()) { + LOG_WARNING("Aura update: capped at ", maxAuras, " entries, remaining data ignored"); + } + LOG_DEBUG("Aura update for 0x", std::hex, data.guid, std::dec, ": ", data.updates.size(), " slots"); return true; } bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data) { + // Upfront validation: guid(8) + flags(1) = 9 bytes minimum + if (packet.getSize() - packet.getReadPos() < 9) return false; + data.guid = packet.readUInt64(); data.flags = packet.readUInt8(); - while (packet.getReadPos() + 8 <= packet.getSize()) { + // Cap cooldown entries to prevent unbounded memory allocation (each entry is 8 bytes) + uint32_t maxCooldowns = 512; + uint32_t cooldownCount = 0; + + while (packet.getReadPos() + 8 <= packet.getSize() && cooldownCount < maxCooldowns) { uint32_t spellId = packet.readUInt32(); uint32_t cooldownMs = packet.readUInt32(); data.cooldowns.push_back({spellId, cooldownMs}); + cooldownCount++; + } + + if (cooldownCount >= maxCooldowns && packet.getReadPos() + 8 <= packet.getSize()) { + LOG_WARNING("Spell cooldowns: capped at ", maxCooldowns, " entries, remaining data ignored"); } LOG_DEBUG("Spell cooldowns: ", data.cooldowns.size(), " entries"); @@ -3069,7 +4175,14 @@ network::Packet GroupInvitePacket::build(const std::string& playerName) { } bool GroupInviteResponseParser::parse(network::Packet& packet, GroupInviteResponseData& data) { + // Validate minimum packet size: canAccept(1) + if (packet.getSize() - packet.getReadPos() < 1) { + LOG_WARNING("SMSG_GROUP_INVITE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + data.canAccept = packet.readUInt8(); + // Note: inviterName is a string, which is always safe to read even if empty data.inviterName = packet.readString(); LOG_INFO("Group invite from: ", data.inviterName, " (canAccept=", (int)data.canAccept, ")"); return true; @@ -3170,14 +4283,27 @@ bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool h } bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResultData& data) { + // Upfront validation: command(4) + name(var) + result(4) = 8 bytes minimum (plus name string) + if (packet.getSize() - packet.getReadPos() < 8) return false; + data.command = static_cast(packet.readUInt32()); data.name = packet.readString(); + + // Validate result field exists (4 bytes) + if (packet.getSize() - packet.getReadPos() < 4) { + data.result = static_cast(0); + return true; // Partial read is acceptable + } + data.result = static_cast(packet.readUInt32()); LOG_DEBUG("Party command result: ", (int)data.result); return true; } bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData& data) { + // Upfront validation: playerName is a CString (minimum 1 null terminator) + if (packet.getSize() - packet.getReadPos() < 1) return false; + data.playerName = packet.readString(); LOG_INFO("Group decline from: ", data.playerName); return true; @@ -3214,6 +4340,13 @@ network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64 return packet; } +network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) { + network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + return packet; +} + network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM)); packet.writeUInt8(srcBag); @@ -3230,6 +4363,17 @@ network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t s return packet; } +network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot, + uint8_t dstBag, uint8_t dstSlot, uint8_t count) { + network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM)); + packet.writeUInt8(srcBag); + packet.writeUInt8(srcSlot); + packet.writeUInt8(dstBag); + packet.writeUInt8(dstSlot); + packet.writeUInt8(count); + return packet; +} + network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) { network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM)); packet.writeUInt8(srcSlot); @@ -3248,57 +4392,52 @@ network::Packet LootReleasePacket::build(uint64_t lootGuid) { return packet; } -bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data) { +bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) { data = LootResponseData{}; - if (packet.getSize() - packet.getReadPos() < 14) { - LOG_WARNING("LootResponseParser: packet too short"); + size_t avail = packet.getSize() - packet.getReadPos(); + + // Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with + // lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened, + // needs a key, or another player is looting). We treat this as an empty-loot + // signal and return false so the caller knows not to open the loot window. + if (avail < 9) { + LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)"); return false; } data.lootGuid = packet.readUInt64(); data.lootType = packet.readUInt8(); + + // Short failure packet — no gold/item data follows. + avail = packet.getSize() - packet.getReadPos(); + if (avail < 5) { + LOG_DEBUG("LootResponseParser: lootType=", (int)data.lootType, " (empty/failure response)"); + return false; + } + data.gold = packet.readUInt32(); uint8_t itemCount = packet.readUInt8(); + // Per-item wire size is 22 bytes across all expansions: + // slot(1)+itemId(4)+count(4)+displayInfo(4)+randSuffix(4)+randProp(4)+slotType(1) = 22 + constexpr size_t kItemSize = 22u; + auto parseLootItemList = [&](uint8_t listCount, bool markQuestItems) -> bool { for (uint8_t i = 0; i < listCount; ++i) { size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining < 10) { + if (remaining < kItemSize) { return false; } - // Prefer the richest format when possible: - // 22-byte (WotLK/full): slot+id+count+display+randSuffix+randProp+slotType - // 14-byte (compact): slot+id+count+display+slotType - // 10-byte (minimal): slot+id+count+slotType - uint8_t bytesPerItem = 10; - if (remaining >= 22) { - bytesPerItem = 22; - } else if (remaining >= 14) { - bytesPerItem = 14; - } - LootItem item; - item.slotIndex = packet.readUInt8(); - item.itemId = packet.readUInt32(); - item.count = packet.readUInt32(); - - if (bytesPerItem >= 14) { - item.displayInfoId = packet.readUInt32(); - } else { - item.displayInfoId = 0; - } - - if (bytesPerItem == 22) { - item.randomSuffix = packet.readUInt32(); - item.randomPropertyId = packet.readUInt32(); - } else { - item.randomSuffix = 0; - item.randomPropertyId = 0; - } - - item.lootSlotType = packet.readUInt8(); - item.isQuestItem = markQuestItems; + item.slotIndex = packet.readUInt8(); + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.randomSuffix = packet.readUInt32(); + item.randomPropertyId = packet.readUInt32(); + item.lootSlotType = packet.readUInt8(); + item.isQuestItem = markQuestItems; data.items.push_back(item); } return true; @@ -3310,8 +4449,9 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data) return false; } + // Quest item section only present in WotLK 3.3.5a uint8_t questItemCount = 0; - if (packet.getSize() - packet.getReadPos() >= 1) { + if (isWotlkFormat && packet.getSize() - packet.getReadPos() >= 1) { questItemCount = packet.readUInt8(); data.items.reserve(data.items.size() + questItemCount); if (!parseLootItemList(questItemCount, true)) { @@ -3403,9 +4543,15 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) /*choiceCount*/ packet.readUInt32(); for (int i = 0; i < 6; i++) { if (packet.getReadPos() + 12 > packet.getSize()) break; - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = static_cast(i); + data.rewardChoiceItems.push_back(ri); + } } } @@ -3414,9 +4560,14 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) /*rewardCount*/ packet.readUInt32(); for (int i = 0; i < 4; i++) { if (packet.getReadPos() + 12 > packet.getSize()) break; - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } } } @@ -3431,14 +4582,30 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) } bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) { + // Upfront validation: npcGuid(8) + menuId(4) + titleTextId(4) + optionCount(4) = 20 bytes minimum + if (packet.getSize() - packet.getReadPos() < 20) return false; + data.npcGuid = packet.readUInt64(); data.menuId = packet.readUInt32(); data.titleTextId = packet.readUInt32(); uint32_t optionCount = packet.readUInt32(); + // Cap option count to prevent unbounded memory allocation + const uint32_t MAX_GOSSIP_OPTIONS = 64; + if (optionCount > MAX_GOSSIP_OPTIONS) { + LOG_WARNING("GossipMessageParser: optionCount capped (requested=", optionCount, ")"); + optionCount = MAX_GOSSIP_OPTIONS; + } + data.options.clear(); data.options.reserve(optionCount); for (uint32_t i = 0; i < optionCount; ++i) { + // Each option: id(4) + icon(1) + isCoded(1) + boxMoney(4) + text(var) + boxText(var) + // Minimum: 10 bytes + 2 empty strings (2 null terminators) = 12 bytes + if (packet.getSize() - packet.getReadPos() < 12) { + LOG_WARNING("GossipMessageParser: truncated options at index ", i, "/", optionCount); + break; + } GossipOption opt; opt.id = packet.readUInt32(); opt.icon = packet.readUInt8(); @@ -3449,10 +4616,29 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data data.options.push_back(opt); } + // Validate questCount field exists (4 bytes) + if (packet.getSize() - packet.getReadPos() < 4) { + LOG_DEBUG("Gossip: ", data.options.size(), " options (no quest data)"); + return true; + } + uint32_t questCount = packet.readUInt32(); + // Cap quest count to prevent unbounded memory allocation + const uint32_t MAX_GOSSIP_QUESTS = 64; + if (questCount > MAX_GOSSIP_QUESTS) { + LOG_WARNING("GossipMessageParser: questCount capped (requested=", questCount, ")"); + questCount = MAX_GOSSIP_QUESTS; + } + data.quests.clear(); data.quests.reserve(questCount); for (uint32_t i = 0; i < questCount; ++i) { + // Each quest: questId(4) + questIcon(4) + questLevel(4) + questFlags(4) + isRepeatable(1) + title(var) + // Minimum: 17 bytes + empty string (1 null terminator) = 18 bytes + if (packet.getSize() - packet.getReadPos() < 18) { + LOG_WARNING("GossipMessageParser: truncated quests at index ", i, "/", questCount); + break; + } GossipQuestItem quest; quest.questId = packet.readUInt32(); quest.questIcon = packet.readUInt32(); @@ -3463,7 +4649,7 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data data.quests.push_back(quest); } - LOG_DEBUG("Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("Gossip: ", data.options.size(), " options, ", data.quests.size(), " quests"); return true; } @@ -3583,11 +4769,19 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.title = normalizeWowTextTokens(packet.readString()); data.rewardText = normalizeWowTextTokens(packet.readString()); - if (packet.getReadPos() + 10 > packet.getSize()) { + if (packet.getReadPos() + 8 > packet.getSize()) { LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); return true; } + // After the two strings the packet contains a variable prefix (autoFinish + optional fields) + // before the emoteCount. Different expansions and server emulator versions differ: + // Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes + // TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays) + // WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays) + // Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix). + // We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each. + struct ParsedTail { uint32_t rewardMoney = 0; uint32_t rewardXp = 0; @@ -3595,28 +4789,27 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData std::vector fixedRewards; bool ok = false; int score = -1000; + size_t prefixSkip = 0; + bool fixedArrays = false; }; - auto parseTail = [&](size_t startPos, bool hasFlags, bool fixedArrays) -> ParsedTail { + auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail { ParsedTail out; + out.prefixSkip = prefixSkip; + out.fixedArrays = fixedArrays; packet.setReadPos(startPos); - if (packet.getReadPos() + 1 > packet.getSize()) return out; - /*autoFinish*/ packet.readUInt8(); - if (hasFlags) { - if (packet.getReadPos() + 4 > packet.getSize()) return out; - /*flags*/ packet.readUInt32(); - } - if (packet.getReadPos() + 4 > packet.getSize()) return out; - /*suggestedPlayers*/ packet.readUInt32(); + // Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount) + if (packet.getReadPos() + prefixSkip > packet.getSize()) return out; + packet.setReadPos(packet.getReadPos() + prefixSkip); if (packet.getReadPos() + 4 > packet.getSize()) return out; uint32_t emoteCount = packet.readUInt32(); - if (emoteCount > 64) return out; // guard against misalignment + if (emoteCount > 32) return out; // guard against misalignment for (uint32_t i = 0; i < emoteCount; ++i) { if (packet.getReadPos() + 8 > packet.getSize()) return out; packet.readUInt32(); // delay - packet.readUInt32(); // emote + packet.readUInt32(); // emote type } if (packet.getReadPos() + 4 > packet.getSize()) return out; @@ -3634,7 +4827,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData item.choiceSlot = i; if (item.itemId > 0) { out.choiceRewards.push_back(item); - nonZeroChoice++; + ++nonZeroChoice; } } @@ -3652,7 +4845,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData item.displayInfoId = packet.readUInt32(); if (item.itemId > 0) { out.fixedRewards.push_back(item); - nonZeroFixed++; + ++nonZeroFixed; } } @@ -3663,43 +4856,56 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData out.ok = true; out.score = 0; - if (hasFlags) out.score += 1; - if (fixedArrays) out.score += 1; + // Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers) + if (prefixSkip == 8) out.score += 3; + else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers + // Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots) + if (fixedArrays) out.score += 2; + // Valid counts if (choiceCount <= 6) out.score += 3; if (rewardCount <= 4) out.score += 3; - if (fixedArrays) { - if (nonZeroChoice <= choiceCount) out.score += 3; - if (nonZeroFixed <= rewardCount) out.score += 3; - } else { - out.score += 3; // variable arrays align naturally with count - } - if (packet.getReadPos() <= packet.getSize()) out.score += 2; + // All non-zero items are within declared counts + if (nonZeroChoice <= choiceCount) out.score += 2; + if (nonZeroFixed <= rewardCount) out.score += 2; + // No bytes left over (or only a few) size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining <= 32) out.score += 2; + if (remaining == 0) out.score += 5; + else if (remaining <= 4) out.score += 3; + else if (remaining <= 8) out.score += 2; + else if (remaining <= 16) out.score += 1; + else out.score -= static_cast(remaining / 4); + // Plausible money/XP values + if (out.rewardMoney < 5000000u) out.score += 1; // < 500g + if (out.rewardXp < 200000u) out.score += 1; // < 200k XP return out; }; size_t tailStart = packet.getReadPos(); - ParsedTail a = parseTail(tailStart, true, true); // WotLK-like (flags + fixed 6/4 arrays) - ParsedTail b = parseTail(tailStart, false, true); // no flags + fixed 6/4 arrays - ParsedTail c = parseTail(tailStart, true, false); // flags + variable arrays - ParsedTail d = parseTail(tailStart, false, false); // classic-like variable arrays + // Try prefix sizes 0..16 bytes with both fixed and variable array layouts + std::vector candidates; + candidates.reserve(34); + for (size_t skip = 0; skip <= 16; ++skip) { + candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays + candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays + } const ParsedTail* best = nullptr; - for (const ParsedTail* cand : {&a, &b, &c, &d}) { - if (!cand->ok) continue; - if (!best || cand->score > best->score) best = cand; + for (const auto& cand : candidates) { + if (!cand.ok) continue; + if (!best || cand.score > best->score) best = &cand; } if (best) { data.choiceRewards = best->choiceRewards; - data.fixedRewards = best->fixedRewards; - data.rewardMoney = best->rewardMoney; - data.rewardXp = best->rewardXp; + data.fixedRewards = best->fixedRewards; + data.rewardMoney = best->rewardMoney; + data.rewardXp = best->rewardXp; } LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, - "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size()); + "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(), + " prefix=", (best ? best->prefixSkip : size_t(0)), + (best && best->fixedArrays ? " fixed" : " var")); return true; } @@ -3741,7 +4947,9 @@ network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint3 packet.writeUInt32(itemId); // item entry packet.writeUInt32(slot); // vendor slot index from SMSG_LIST_INVENTORY packet.writeUInt32(count); - // WotLK/AzerothCore expects a trailing byte on CMSG_BUY_ITEM. + // Note: WotLK/AzerothCore expects a trailing byte; Classic/TBC do not. + // This static helper always adds it (appropriate for CMaNGOS/AzerothCore). + // For Classic/TBC, use the GameHandler::buyItem() path which checks expansion. packet.writeUInt8(0); return packet; } @@ -3825,6 +5033,8 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bo // Classic per-entry: spellId(4) + state(1) + cost(4) + reqLevel(1) + // reqSkill(4) + reqSkillValue(4) + chain×3(12) + unk(4) = 34 bytes data = TrainerListData{}; + if (packet.getSize() - packet.getReadPos() < 16) return false; // guid(8) + type(4) + count(4) + data.trainerGuid = packet.readUInt64(); data.trainerType = packet.readUInt32(); uint32_t spellCount = packet.readUInt32(); @@ -3836,6 +5046,13 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bo data.spells.reserve(spellCount); for (uint32_t i = 0; i < spellCount; ++i) { + // Validate minimum entry size before reading + const size_t minEntrySize = isClassic ? 34 : 38; + if (packet.getReadPos() + minEntrySize > packet.getSize()) { + LOG_WARNING("TrainerListParser: truncated at spell ", i); + break; + } + TrainerSpell spell; spell.spellId = packet.readUInt32(); spell.state = packet.readUInt8(); @@ -3862,7 +5079,12 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bo data.spells.push_back(spell); } - data.greeting = packet.readString(); + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("TrainerListParser: truncated before greeting"); + data.greeting.clear(); + } else { + data.greeting = packet.readString(); + } LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ", spellCount, " spells, type=", data.trainerType, @@ -4008,6 +5230,12 @@ network::Packet RepopRequestPacket::build() { return packet; } +network::Packet ReclaimCorpsePacket::build(uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + packet.writeUInt64(guid); + return packet; +} + network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE)); packet.writeUInt64(npcGuid); @@ -4128,11 +5356,12 @@ network::Packet MailTakeMoneyPacket::build(uint64_t mailboxGuid, uint32_t mailId return packet; } -network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemIndex) { +network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) { network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_ITEM)); packet.writeUInt64(mailboxGuid); packet.writeUInt32(mailId); - packet.writeUInt32(itemIndex); + // WotLK expects attachment item GUID low, not attachment slot index. + packet.writeUInt32(itemGuidLow); return packet; } @@ -4199,10 +5428,9 @@ bool PacketParsers::parseMailList(network::Packet& packet, std::vector packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated before tabCount"); + data.tabs.clear(); + } else { + uint8_t tabCount = packet.readUInt8(); + // Cap at 8 (normal guild bank tab limit in WoW) + if (tabCount > 8) { + LOG_WARNING("GuildBankListParser: tabCount capped (requested=", (int)tabCount, ")"); + tabCount = 8; + } + data.tabs.resize(tabCount); + for (uint8_t i = 0; i < tabCount; ++i) { + // Validate before reading strings + if (packet.getReadPos() >= packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated tab at index ", (int)i); + break; + } + data.tabs[i].tabName = packet.readString(); + if (packet.getReadPos() >= packet.getSize()) { + data.tabs[i].tabIcon.clear(); + } else { + data.tabs[i].tabIcon = packet.readString(); + } + } } } + if (packet.getReadPos() + 1 > packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated before numSlots"); + data.tabItems.clear(); + return true; + } + uint8_t numSlots = packet.readUInt8(); data.tabItems.clear(); for (uint8_t i = 0; i < numSlots; ++i) { + // Validate minimum bytes before reading slot (slotId(1) + itemEntry(4) = 5) + if (packet.getReadPos() + 5 > packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated slot at index ", (int)i); + break; + } GuildBankItemSlot slot; slot.slotId = packet.readUInt8(); slot.itemEntry = packet.readUInt32(); if (slot.itemEntry != 0) { + // Validate before reading enchant mask + if (packet.getReadPos() + 4 > packet.getSize()) break; // Enchant info uint32_t enchantMask = packet.readUInt32(); for (int bit = 0; bit < 10; ++bit) { if (enchantMask & (1u << bit)) { + if (packet.getReadPos() + 12 > packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated enchant data"); + break; + } uint32_t enchId = packet.readUInt32(); uint32_t enchDur = packet.readUInt32(); uint32_t enchCharges = packet.readUInt32(); @@ -4384,10 +5650,19 @@ bool GuildBankListParser::parse(network::Packet& packet, GuildBankData& data) { (void)enchDur; (void)enchCharges; } } + // Validate before reading remaining item fields + if (packet.getReadPos() + 12 > packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated item fields"); + break; + } slot.stackCount = packet.readUInt32(); /*spare=*/ packet.readUInt32(); slot.randomPropertyId = packet.readUInt32(); if (slot.randomPropertyId) { + if (packet.getReadPos() + 4 > packet.getSize()) { + LOG_WARNING("GuildBankListParser: truncated suffix factor"); + break; + } /*suffixFactor=*/ packet.readUInt32(); } } @@ -4510,6 +5785,13 @@ bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& if (packet.getSize() - packet.getReadPos() < 4) return false; uint32_t count = packet.readUInt32(); + // Cap auction count to prevent unbounded memory allocation + const uint32_t MAX_AUCTION_RESULTS = 256; + if (count > MAX_AUCTION_RESULTS) { + LOG_WARNING("AuctionListResultParser: count capped (requested=", count, ")"); + count = MAX_AUCTION_RESULTS; + } + data.auctions.clear(); data.auctions.reserve(count); @@ -4562,5 +5844,53 @@ bool AuctionCommandResultParser::parse(network::Packet& packet, AuctionCommandRe return true; } +// ============================================================ +// Pet Stable System +// ============================================================ + +network::Packet ListStabledPetsPacket::build(uint64_t stableMasterGuid) { + network::Packet p(wireOpcode(Opcode::MSG_LIST_STABLED_PETS)); + p.writeUInt64(stableMasterGuid); + return p; +} + +network::Packet StablePetPacket::build(uint64_t stableMasterGuid, uint8_t slot) { + network::Packet p(wireOpcode(Opcode::CMSG_STABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt8(slot); + return p; +} + +network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t petNumber) { + network::Packet p(wireOpcode(Opcode::CMSG_UNSTABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt32(petNumber); + return p; +} + +network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name, uint8_t isDeclined) { + network::Packet p(wireOpcode(Opcode::CMSG_PET_RENAME)); + p.writeUInt64(petGuid); + p.writeString(name); // null-terminated + p.writeUInt8(isDeclined); + return p; +} + +network::Packet SetTitlePacket::build(int32_t titleBit) { + // CMSG_SET_TITLE: int32 titleBit (-1 = remove active title) + network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE)); + p.writeUInt32(static_cast(titleBit)); + return p; +} + +network::Packet AlterAppearancePacket::build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { + // CMSG_ALTER_APPEARANCE: uint32 hairStyle + uint32 hairColor + uint32 facialHair + network::Packet p(wireOpcode(Opcode::CMSG_ALTER_APPEARANCE)); + p.writeUInt32(hairStyle); + p.writeUInt32(hairColor); + p.writeUInt32(facialHair); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/main.cpp b/src/main.cpp index d3811b3b..8ae707e8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,8 @@ #include #ifdef __linux__ #include +#include +#include // Keep a persistent X11 connection for emergency mouse release in signal handlers. // XOpenDisplay inside a signal handler is unreliable, so we open it once at startup. @@ -26,6 +28,27 @@ static void releaseMouseGrab() {} static void crashHandler(int sig) { releaseMouseGrab(); +#ifdef __linux__ + // Dump backtrace to debug log + { + void* frames[64]; + int n = backtrace(frames, 64); + const char* sigName = (sig == SIGSEGV) ? "SIGSEGV" : + (sig == SIGABRT) ? "SIGABRT" : + (sig == SIGFPE) ? "SIGFPE" : "UNKNOWN"; + // Write to stderr and to the debug log file + fprintf(stderr, "\n=== CRASH: signal %s (%d) ===\n", sigName, sig); + backtrace_symbols_fd(frames, n, STDERR_FILENO); + FILE* f = fopen("/tmp/wowee_debug.log", "a"); + if (f) { + fprintf(f, "\n=== CRASH: signal %s (%d) ===\n", sigName, sig); + fflush(f); + // Also write backtrace to the log file fd + backtrace_symbols_fd(frames, n, fileno(f)); + fclose(f); + } + } +#endif std::signal(sig, SIG_DFL); std::raise(sig); } diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index 78c90c8e..7fe18709 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -1,6 +1,7 @@ #include "network/world_socket.hpp" #include "network/packet.hpp" #include "network/net_platform.hpp" +#include "game/opcode_table.hpp" #include "auth/crypto.hpp" #include "core/logger.hpp" #include @@ -9,10 +10,52 @@ #include #include #include +#include +#include namespace { constexpr size_t kMaxReceiveBufferBytes = 8 * 1024 * 1024; -constexpr int kMaxParsedPacketsPerUpdate = 220; +constexpr int kDefaultMaxParsedPacketsPerUpdate = 16; +constexpr int kAbsoluteMaxParsedPacketsPerUpdate = 220; +constexpr int kMinParsedPacketsPerUpdate = 8; +constexpr int kDefaultMaxPacketCallbacksPerUpdate = 6; +constexpr int kAbsoluteMaxPacketCallbacksPerUpdate = 64; +constexpr int kMinPacketCallbacksPerUpdate = 1; +constexpr int kMaxRecvCallsPerUpdate = 64; +constexpr size_t kMaxRecvBytesPerUpdate = 512 * 1024; +constexpr size_t kMaxQueuedPacketCallbacks = 4096; +constexpr int kAsyncPumpSleepMs = 2; +constexpr size_t kRecentPacketHistoryLimit = 96; +constexpr auto kRecentPacketHistoryWindow = std::chrono::seconds(15); +constexpr const char* kCloseTraceEnv = "WOWEE_NET_CLOSE_TRACE"; + +inline int parsedPacketsBudgetPerUpdate() { + static int budget = []() { + const char* raw = std::getenv("WOWEE_NET_MAX_PARSED_PACKETS"); + if (!raw || !*raw) return kDefaultMaxParsedPacketsPerUpdate; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return kDefaultMaxParsedPacketsPerUpdate; + if (parsed < kMinParsedPacketsPerUpdate) return kMinParsedPacketsPerUpdate; + if (parsed > kAbsoluteMaxParsedPacketsPerUpdate) return kAbsoluteMaxParsedPacketsPerUpdate; + return static_cast(parsed); + }(); + return budget; +} + +inline int packetCallbacksBudgetPerUpdate() { + static int budget = []() { + const char* raw = std::getenv("WOWEE_NET_MAX_PACKET_CALLBACKS"); + if (!raw || !*raw) return kDefaultMaxPacketCallbacksPerUpdate; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return kDefaultMaxPacketCallbacksPerUpdate; + if (parsed < kMinPacketCallbacksPerUpdate) return kMinPacketCallbacksPerUpdate; + if (parsed > kAbsoluteMaxPacketCallbacksPerUpdate) return kAbsoluteMaxPacketCallbacksPerUpdate; + return static_cast(parsed); + }(); + return budget; +} inline bool isLoginPipelineSmsg(uint16_t opcode) { switch (opcode) { @@ -49,6 +92,14 @@ inline bool envFlagEnabled(const char* key, bool defaultValue = false) { return !(raw[0] == '0' || raw[0] == 'f' || raw[0] == 'F' || raw[0] == 'n' || raw[0] == 'N'); } + +const char* opcodeNameForTrace(uint16_t wireOpcode) { + const auto* table = wowee::game::getActiveOpcodeTable(); + if (!table) return "UNKNOWN"; + auto logical = table->fromWire(wireOpcode); + if (!logical) return "UNKNOWN"; + return wowee::game::OpcodeTable::logicalToName(*logical); +} } // namespace namespace wowee { @@ -71,6 +122,7 @@ WorldSocket::WorldSocket() { receiveBuffer.reserve(64 * 1024); useFastRecvAppend_ = envFlagEnabled("WOWEE_NET_FAST_RECV_APPEND", true); useParseScratchQueue_ = envFlagEnabled("WOWEE_NET_PARSE_SCRATCH", false); + useAsyncPump_ = envFlagEnabled("WOWEE_NET_ASYNC_PUMP", true); if (useParseScratchQueue_) { LOG_WARNING("WOWEE_NET_PARSE_SCRATCH is temporarily disabled (known unstable); forcing off"); useParseScratchQueue_ = false; @@ -79,7 +131,10 @@ WorldSocket::WorldSocket() { parsedPacketsScratch_.reserve(64); } LOG_INFO("WorldSocket net opts: fast_recv_append=", useFastRecvAppend_ ? "on" : "off", - " parse_scratch=", useParseScratchQueue_ ? "on" : "off"); + " async_pump=", useAsyncPump_ ? "on" : "off", + " parse_scratch=", useParseScratchQueue_ ? "on" : "off", + " max_parsed_packets=", parsedPacketsBudgetPerUpdate(), + " max_packet_callbacks=", packetCallbacksBudgetPerUpdate()); } WorldSocket::~WorldSocket() { @@ -89,6 +144,8 @@ WorldSocket::~WorldSocket() { bool WorldSocket::connect(const std::string& host, uint16_t port) { LOG_INFO("Connecting to world server: ", host, ":", port); + stopAsyncPump(); + // Create socket sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == INVALID_SOCK) { @@ -165,32 +222,90 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { connected = true; LOG_INFO("Connected to world server: ", host, ":", port); + startAsyncPump(); return true; } void WorldSocket::disconnect() { + stopAsyncPump(); + { + std::lock_guard lock(ioMutex_); + closeSocketNoJoin(); + encryptionEnabled = false; + useVanillaCrypt = false; + receiveBuffer.clear(); + receiveReadOffset_ = 0; + parsedPacketsScratch_.clear(); + headerBytesDecrypted = 0; + packetTraceStart_ = {}; + packetTraceUntil_ = {}; + packetTraceReason_.clear(); + } + { + std::lock_guard lock(callbackMutex_); + pendingPacketCallbacks_.clear(); + } + LOG_INFO("Disconnected from world server"); +} + +void WorldSocket::tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason) { + std::lock_guard lock(ioMutex_); + packetTraceStart_ = std::chrono::steady_clock::now(); + packetTraceUntil_ = packetTraceStart_ + duration; + packetTraceReason_ = reason; + LOG_DEBUG("WS TRACE enabled: reason='", packetTraceReason_, + "' durationMs=", duration.count()); +} + +bool WorldSocket::isConnected() const { + std::lock_guard lock(ioMutex_); + return connected; +} + +void WorldSocket::closeSocketNoJoin() { if (sockfd != INVALID_SOCK) { net::closeSocket(sockfd); sockfd = INVALID_SOCK; } connected = false; - encryptionEnabled = false; - useVanillaCrypt = false; - receiveBuffer.clear(); - receiveReadOffset_ = 0; - parsedPacketsScratch_.clear(); - headerBytesDecrypted = 0; - LOG_INFO("Disconnected from world server"); } -bool WorldSocket::isConnected() const { - return connected; +void WorldSocket::recordRecentPacket(bool outbound, uint16_t opcode, uint16_t payloadLen) { + const auto now = std::chrono::steady_clock::now(); + recentPacketHistory_.push_back(RecentPacketTrace{now, outbound, opcode, payloadLen}); + while (!recentPacketHistory_.empty() && + (recentPacketHistory_.size() > kRecentPacketHistoryLimit || + (now - recentPacketHistory_.front().when) > kRecentPacketHistoryWindow)) { + recentPacketHistory_.pop_front(); + } +} + +void WorldSocket::dumpRecentPacketHistoryLocked(const char* reason, size_t bufferedBytes) { + if (recentPacketHistory_.empty()) { + LOG_WARNING("WS CLOSE TRACE reason='", reason, "' buffered=", bufferedBytes, + " no recent packet history"); + return; + } + + const auto lastWhen = recentPacketHistory_.back().when; + LOG_WARNING("WS CLOSE TRACE reason='", reason, "' buffered=", bufferedBytes, + " recentPackets=", recentPacketHistory_.size()); + for (const auto& entry : recentPacketHistory_) { + const auto ageMs = std::chrono::duration_cast( + lastWhen - entry.when).count(); + LOG_WARNING("WS CLOSE TRACE ", entry.outbound ? "TX" : "RX", + " -", ageMs, "ms opcode=0x", + std::hex, entry.opcode, std::dec, + " logical=", opcodeNameForTrace(entry.opcode), + " payload=", entry.payloadLen); + } } void WorldSocket::send(const Packet& packet) { - if (!connected) return; static const bool kLogCharCreatePayload = envFlagEnabled("WOWEE_NET_LOG_CHAR_CREATE", false); static const bool kLogSwapItemPackets = envFlagEnabled("WOWEE_NET_LOG_SWAP_ITEM", false); + std::lock_guard lock(ioMutex_); + if (!connected || sockfd == INVALID_SOCK) return; const auto& data = packet.getData(); uint16_t opcode = packet.getOpcode(); @@ -254,6 +369,18 @@ void WorldSocket::send(const Packet& packet) { LOG_INFO("WS TX opcode=0x", std::hex, opcode, std::dec, " payloadLen=", payloadLen, " data=[", hex, "]"); } + const auto traceNow = std::chrono::steady_clock::now(); + recordRecentPacket(true, opcode, payloadLen); + if (packetTraceUntil_ > traceNow) { + const auto elapsedMs = std::chrono::duration_cast( + traceNow - packetTraceStart_).count(); + LOG_DEBUG("WS TRACE TX +", elapsedMs, "ms opcode=0x", + std::hex, opcode, std::dec, + " logical=", opcodeNameForTrace(opcode), + " payload=", payloadLen, + " reason='", packetTraceReason_, "'"); + } + // WotLK 3.3.5 CMSG header (6 bytes total): // - size (2 bytes, big-endian) = payloadLen + 4 (opcode is 4 bytes for CMSG) // - opcode (4 bytes, little-endian) @@ -317,7 +444,46 @@ void WorldSocket::send(const Packet& packet) { } void WorldSocket::update() { - if (!connected) return; + if (!useAsyncPump_) { + pumpNetworkIO(); + } + dispatchQueuedPackets(); +} + +void WorldSocket::startAsyncPump() { + if (!useAsyncPump_ || asyncPumpRunning_.load(std::memory_order_acquire)) { + return; + } + asyncPumpStop_.store(false, std::memory_order_release); + asyncPumpThread_ = std::thread(&WorldSocket::asyncPumpLoop, this); +} + +void WorldSocket::stopAsyncPump() { + asyncPumpStop_.store(true, std::memory_order_release); + if (asyncPumpThread_.joinable()) { + asyncPumpThread_.join(); + } + asyncPumpRunning_.store(false, std::memory_order_release); +} + +void WorldSocket::asyncPumpLoop() { + asyncPumpRunning_.store(true, std::memory_order_release); + while (!asyncPumpStop_.load(std::memory_order_acquire)) { + pumpNetworkIO(); + { + std::lock_guard lock(ioMutex_); + if (!connected) { + break; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(kAsyncPumpSleepMs)); + } + asyncPumpRunning_.store(false, std::memory_order_release); +} + +void WorldSocket::pumpNetworkIO() { + std::lock_guard lock(ioMutex_); + if (!connected || sockfd == INVALID_SOCK) return; auto bufferedBytes = [&]() -> size_t { return (receiveBuffer.size() >= receiveReadOffset_) ? (receiveBuffer.size() - receiveReadOffset_) @@ -343,7 +509,8 @@ void WorldSocket::update() { bool receivedAny = false; size_t bytesReadThisTick = 0; int readOps = 0; - while (connected) { + while (connected && readOps < kMaxRecvCallsPerUpdate && + bytesReadThisTick < kMaxRecvBytesPerUpdate) { uint8_t buffer[4096]; ssize_t received = net::portableRecv(sockfd, buffer, sizeof(buffer)); @@ -362,7 +529,7 @@ void WorldSocket::update() { LOG_ERROR("World socket receive buffer would overflow (buffered=", liveBytes, " incoming=", receivedSize, " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } const size_t oldSize = receiveBuffer.size(); @@ -375,7 +542,7 @@ void WorldSocket::update() { if (newCap < needed) { LOG_ERROR("World socket receive buffer capacity growth failed (needed=", needed, " max=", kMaxReceiveBufferBytes, "). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } receiveBuffer.reserve(newCap); @@ -387,7 +554,7 @@ void WorldSocket::update() { if (bufferedBytes() > kMaxReceiveBufferBytes) { LOG_ERROR("World socket receive buffer overflow (", bufferedBytes(), " bytes). Disconnecting to recover framing."); - disconnect(); + closeSocketNoJoin(); return; } continue; @@ -409,7 +576,7 @@ void WorldSocket::update() { } LOG_ERROR("Receive failed: ", net::errorString(err)); - disconnect(); + closeSocketNoJoin(); return; } @@ -434,10 +601,16 @@ void WorldSocket::update() { } } + if (connected && (readOps >= kMaxRecvCallsPerUpdate || bytesReadThisTick >= kMaxRecvBytesPerUpdate)) { + LOG_DEBUG("World socket recv budget reached (calls=", readOps, + ", bytes=", bytesReadThisTick, "), deferring remaining socket drain"); + } + if (sawClose) { - LOG_INFO("World server connection closed (receivedAny=", receivedAny, + dumpRecentPacketHistoryLocked("peer_closed", bufferedBytes()); + LOG_WARNING("World server connection closed by peer (receivedAny=", receivedAny, " buffered=", bufferedBytes(), ")"); - disconnect(); + closeSocketNoJoin(); return; } } @@ -462,7 +635,8 @@ void WorldSocket::tryParsePackets() { } else { parsedPacketsLocal.reserve(32); } - while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < kMaxParsedPacketsPerUpdate) { + const int maxParsedThisTick = parsedPacketsBudgetPerUpdate(); + while ((receiveBuffer.size() - parseOffset) >= 4 && parsedThisTick < maxParsedThisTick) { uint8_t rawHeader[4] = {0, 0, 0, 0}; std::memcpy(rawHeader, receiveBuffer.data() + parseOffset, 4); @@ -491,10 +665,10 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), std::dec, " enc=", encryptionEnabled, ". Disconnecting to recover stream."); - disconnect(); + closeSocketNoJoin(); return; } - constexpr uint16_t kMaxWorldPacketSize = 0x4000; + constexpr uint16_t kMaxWorldPacketSize = 0x8000; // 32KB — allows large guild rosters, auction lists if (size > kMaxWorldPacketSize) { LOG_ERROR("World packet framing desync: oversized packet size=", size, " rawHdr=", std::hex, @@ -503,7 +677,7 @@ void WorldSocket::tryParsePackets() { static_cast(rawHeader[2]), " ", static_cast(rawHeader[3]), std::dec, " enc=", encryptionEnabled, ". Disconnecting to recover stream."); - disconnect(); + closeSocketNoJoin(); return; } @@ -535,6 +709,17 @@ void WorldSocket::tryParsePackets() { " buffered=", (receiveBuffer.size() - parseOffset), " enc=", encryptionEnabled ? "yes" : "no"); } + recordRecentPacket(false, opcode, payloadLen); + const auto traceNow = std::chrono::steady_clock::now(); + if (packetTraceUntil_ > traceNow) { + const auto elapsedMs = std::chrono::duration_cast( + traceNow - packetTraceStart_).count(); + LOG_DEBUG("WS TRACE RX +", elapsedMs, "ms opcode=0x", + std::hex, opcode, std::dec, + " logical=", opcodeNameForTrace(opcode), + " payload=", payloadLen, + " reason='", packetTraceReason_, "'"); + } if ((receiveBuffer.size() - parseOffset) < totalSize) { // Not enough data yet - header stays decrypted in buffer @@ -555,7 +740,7 @@ void WorldSocket::tryParsePackets() { " payload=", payloadLen, " buffered=", receiveBuffer.size(), " parseOffset=", parseOffset, " what=", e.what(), ". Disconnecting to recover."); - disconnect(); + closeSocketNoJoin(); return; } parseOffset += totalSize; @@ -578,23 +763,57 @@ void WorldSocket::tryParsePackets() { } headerBytesDecrypted = localHeaderBytesDecrypted; - if (packetCallback) { - for (const auto& packet : *parsedPackets) { - if (!connected) break; - packetCallback(packet); + // Queue parsed packets for main-thread dispatch. + if (!parsedPackets->empty()) { + std::lock_guard callbackLock(callbackMutex_); + for (auto& packet : *parsedPackets) { + pendingPacketCallbacks_.push_back(std::move(packet)); + } + if (pendingPacketCallbacks_.size() > kMaxQueuedPacketCallbacks) { + LOG_ERROR("World socket callback queue overflow (", pendingPacketCallbacks_.size(), + " packets). Disconnecting to recover."); + pendingPacketCallbacks_.clear(); + closeSocketNoJoin(); + return; } } const size_t buffered = (receiveBuffer.size() >= receiveReadOffset_) ? (receiveBuffer.size() - receiveReadOffset_) : 0; - if (parsedThisTick >= kMaxParsedPacketsPerUpdate && buffered >= 4) { + if (parsedThisTick >= maxParsedThisTick && buffered >= 4) { LOG_DEBUG("World socket parse budget reached (", parsedThisTick, " packets); deferring remaining buffered data=", buffered, " bytes"); } } +void WorldSocket::dispatchQueuedPackets() { + std::deque localPackets; + { + std::lock_guard lock(callbackMutex_); + if (!packetCallback || pendingPacketCallbacks_.empty()) { + return; + } + const int maxCallbacksThisTick = packetCallbacksBudgetPerUpdate(); + for (int i = 0; i < maxCallbacksThisTick && !pendingPacketCallbacks_.empty(); ++i) { + localPackets.push_back(std::move(pendingPacketCallbacks_.front())); + pendingPacketCallbacks_.pop_front(); + } + if (!pendingPacketCallbacks_.empty()) { + LOG_DEBUG("World socket callback budget reached (", localPackets.size(), + " callbacks); deferring ", pendingPacketCallbacks_.size(), + " queued packet callbacks"); + } + } + + while (!localPackets.empty()) { + packetCallback(localPackets.front()); + localPackets.pop_front(); + } +} + void WorldSocket::initEncryption(const std::vector& sessionKey, uint32_t build) { + std::lock_guard lock(ioMutex_); if (sessionKey.size() != 40) { LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)"); return; diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 89b063c5..469df669 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -92,7 +92,7 @@ void AssetManager::setupFileCacheBudget() { const size_t envMaxMB = parseEnvSizeMB("WOWEE_FILE_CACHE_MAX_MB"); const size_t minBudgetBytes = 256ull * 1024ull * 1024ull; - const size_t defaultMaxBudgetBytes = 32768ull * 1024ull * 1024ull; + const size_t defaultMaxBudgetBytes = 12288ull * 1024ull * 1024ull; // 12 GB max for file cache const size_t maxBudgetBytes = (envMaxMB > 0) ? (envMaxMB * 1024ull * 1024ull) : defaultMaxBudgetBytes; diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index b3d057d6..b1f82973 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector& m2Data) { } // end size check } + // Parse ribbon emitters (WotLK only; vanilla format TBD). + // WotLK M2RibbonEmitter = 0xAC (172) bytes per entry. + static constexpr uint32_t RIBBON_SIZE_WOTLK = 0xAC; + if (header.nRibbonEmitters > 0 && header.ofsRibbonEmitters > 0 && + header.nRibbonEmitters < 64 && header.version >= 264) { + + if (static_cast(header.ofsRibbonEmitters) + + static_cast(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) { + + // Build sequence flags for parseAnimTrack + std::vector ribSeqFlags; + ribSeqFlags.reserve(model.sequences.size()); + for (const auto& seq : model.sequences) { + ribSeqFlags.push_back(seq.flags); + } + + for (uint32_t ri = 0; ri < header.nRibbonEmitters; ri++) { + uint32_t base = header.ofsRibbonEmitters + ri * RIBBON_SIZE_WOTLK; + + M2RibbonEmitter rib; + rib.ribbonId = readValue(m2Data, base + 0x00); + rib.bone = readValue(m2Data, base + 0x04); + rib.position.x = readValue(m2Data, base + 0x08); + rib.position.y = readValue(m2Data, base + 0x0C); + rib.position.z = readValue(m2Data, base + 0x10); + + // textureIndices M2Array (0x14): count + offset → first element = texture lookup index + { + uint32_t nTex = readValue(m2Data, base + 0x14); + uint32_t ofsTex = readValue(m2Data, base + 0x18); + if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) { + rib.textureIndex = readValue(m2Data, ofsTex); + } + } + + // materialIndices M2Array (0x1C): count + offset → first element = material index + { + uint32_t nMat = readValue(m2Data, base + 0x1C); + uint32_t ofsMat = readValue(m2Data, base + 0x20); + if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) { + rib.materialIndex = readValue(m2Data, ofsMat); + } + } + + // colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1) + if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x24); + parseAnimTrack(m2Data, disk, rib.colorTrack, TrackType::VEC3, ribSeqFlags); + } + + // alphaTrack M2TrackDisk at 0x38 (fixed16: int16/32767) + // Same nested-array layout as parseAnimTrack but keys are int16. + if (base + 0x38 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x38); + auto& track = rib.alphaTrack; + track.interpolationType = disk.interpolationType; + track.globalSequence = disk.globalSequence; + uint32_t nSeqs = disk.nTimestamps; + if (nSeqs > 0 && nSeqs <= 4096) { + track.sequences.resize(nSeqs); + for (uint32_t s = 0; s < nSeqs; s++) { + if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue; + uint32_t tsHdr = disk.ofsTimestamps + s * 8; + uint32_t keyHdr = disk.ofsKeys + s * 8; + if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue; + uint32_t tsCount = readValue(m2Data, tsHdr); + uint32_t tsOfs = readValue(m2Data, tsHdr + 4); + uint32_t kCount = readValue(m2Data, keyHdr); + uint32_t kOfs = readValue(m2Data, keyHdr + 4); + if (tsCount == 0 || kCount == 0) continue; + if (tsOfs + tsCount * 4 > m2Data.size()) continue; + if (kOfs + kCount * sizeof(int16_t) > m2Data.size()) continue; + track.sequences[s].timestamps = readArray(m2Data, tsOfs, tsCount); + auto raw = readArray(m2Data, kOfs, kCount); + track.sequences[s].floatValues.reserve(raw.size()); + for (auto v : raw) { + track.sequences[s].floatValues.push_back( + static_cast(v) / 32767.0f); + } + } + } + } + + // heightAboveTrack M2TrackDisk at 0x4C (float) + if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x4C); + parseAnimTrack(m2Data, disk, rib.heightAboveTrack, TrackType::FLOAT, ribSeqFlags); + } + + // heightBelowTrack M2TrackDisk at 0x60 (float) + if (base + 0x60 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x60); + parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags); + } + + rib.edgesPerSecond = readValue(m2Data, base + 0x74); + rib.edgeLifetime = readValue(m2Data, base + 0x78); + rib.gravity = readValue(m2Data, base + 0x7C); + rib.textureRows = readValue(m2Data, base + 0x80); + rib.textureCols = readValue(m2Data, base + 0x82); + if (rib.textureRows == 0) rib.textureRows = 1; + if (rib.textureCols == 0) rib.textureCols = 1; + + // Clamp to sane values + if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f; + if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f; + + // visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1) + if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x98); + parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags); + } + + model.ribbonEmitters.push_back(std::move(rib)); + } + core::Logger::getInstance().debug(" Ribbon emitters: ", model.ribbonEmitters.size()); + } + } + // Read collision mesh (bounding triangles/vertices/normals) if (header.nBoundingVertices > 0 && header.ofsBoundingVertices > 0) { struct Vec3Disk { float x, y, z; }; diff --git a/src/rendering/amd_fsr3_runtime.cpp b/src/rendering/amd_fsr3_runtime.cpp index e7606fb6..26fc5ce1 100644 --- a/src/rendering/amd_fsr3_runtime.cpp +++ b/src/rendering/amd_fsr3_runtime.cpp @@ -64,7 +64,7 @@ std::string narrowWString(const wchar_t* msg) { std::string out; for (const wchar_t* p = msg; *p; ++p) { const wchar_t wc = *p; - if (wc >= 0 && wc <= 0x7f) { + if (wc <= 0x7f) { out.push_back(static_cast(wc)); } else { out.push_back('?'); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 891d53ba..53be1a25 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -140,6 +140,17 @@ std::optional CameraController::getCachedFloorHeight(float x, float y, fl return result; } +void CameraController::triggerShake(float magnitude, float frequency, float duration) { + // Allow stronger shake to override weaker; don't allow zero magnitude. + if (magnitude <= 0.0f || duration <= 0.0f) return; + if (magnitude > shakeMagnitude_ || shakeElapsed_ >= shakeDuration_) { + shakeMagnitude_ = magnitude; + shakeFrequency_ = frequency; + shakeDuration_ = duration; + shakeElapsed_ = 0.0f; + } +} + void CameraController::update(float deltaTime) { if (!enabled || !camera) { return; @@ -217,6 +228,7 @@ void CameraController::update(float deltaTime) { bool shiftDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT)); bool ctrlDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL)); bool nowJump = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyJustPressed(SDL_SCANCODE_SPACE); + bool spaceDown = !uiWantsKeyboard && !sitting && !movementSuppressed && input.isKeyPressed(SDL_SCANCODE_SPACE); // Idle camera: any input resets the timer; timeout triggers a slow orbit pan bool anyInput = leftMouseDown || rightMouseDown || keyW || keyS || keyA || keyD || keyQ || keyE || nowJump; @@ -261,8 +273,9 @@ void CameraController::update(float deltaTime) { keyW = keyS = keyA = keyD = keyQ = keyE = nowJump = false; } - // Tilde toggles auto-run; any forward/backward key cancels it - bool tildeDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_GRAVE); + // Tilde or NumLock toggles auto-run; any forward/backward key cancels it + bool tildeDown = !uiWantsKeyboard && (input.isKeyPressed(SDL_SCANCODE_GRAVE) || + input.isKeyPressed(SDL_SCANCODE_NUMLOCKCLEAR)); if (tildeDown && !tildeWasDown) { autoRunning = !autoRunning; } @@ -275,8 +288,10 @@ void CameraController::update(float deltaTime) { if (mouseAutorun) { autoRunning = false; } - bool nowForward = keyW || mouseAutorun || autoRunning; - bool nowBackward = keyS; + // When the server has rooted the player, suppress all horizontal movement input. + const bool movBlocked = movementRooted_; + bool nowForward = !movBlocked && (keyW || mouseAutorun || autoRunning); + bool nowBackward = !movBlocked && keyS; bool nowStrafeLeft = false; bool nowStrafeRight = false; bool nowTurnLeft = false; @@ -285,21 +300,27 @@ void CameraController::update(float deltaTime) { // WoW-like third-person keyboard behavior: // - RMB held: A/D strafe // - RMB released: A/D turn character+camera, Q/E strafe + // Turning is allowed even while rooted; only positional movement is blocked. if (thirdPerson && !rightMouseDown) { nowTurnLeft = keyA; nowTurnRight = keyD; - nowStrafeLeft = keyQ; - nowStrafeRight = keyE; + nowStrafeLeft = !movBlocked && keyQ; + nowStrafeRight = !movBlocked && keyE; } else { - nowStrafeLeft = keyA || keyQ; - nowStrafeRight = keyD || keyE; + nowStrafeLeft = !movBlocked && (keyA || keyQ); + nowStrafeRight = !movBlocked && (keyD || keyE); } - // Keyboard turning updates camera yaw (character follows yaw in renderer) + // Keyboard turning updates camera yaw (character follows yaw in renderer). + // Use server turn rate (rad/s) when set; otherwise fall back to WOW_TURN_SPEED (deg/s). + const float activeTurnSpeedDeg = (turnRateOverride_ > 0.0f && turnRateOverride_ < 20.0f + && !std::isnan(turnRateOverride_)) + ? glm::degrees(turnRateOverride_) + : WOW_TURN_SPEED; if (nowTurnLeft && !nowTurnRight) { - yaw += WOW_TURN_SPEED * deltaTime; + yaw += activeTurnSpeedDeg * deltaTime; } else if (nowTurnRight && !nowTurnLeft) { - yaw -= WOW_TURN_SPEED * deltaTime; + yaw -= activeTurnSpeedDeg * deltaTime; } if (nowTurnLeft || nowTurnRight) { camera->setRotation(yaw, pitch); @@ -315,9 +336,12 @@ void CameraController::update(float deltaTime) { if (useWoWSpeed) { // Movement speeds (WoW-like: Ctrl walk, default run, backpedal slower) if (nowBackward && !nowForward) { - speed = WOW_BACK_SPEED; + speed = (runBackSpeedOverride_ > 0.0f && runBackSpeedOverride_ < 100.0f + && !std::isnan(runBackSpeedOverride_)) + ? runBackSpeedOverride_ : WOW_BACK_SPEED; } else if (ctrlDown) { - speed = WOW_WALK_SPEED; + speed = (walkSpeedOverride_ > 0.0f && walkSpeedOverride_ < 100.0f && !std::isnan(walkSpeedOverride_)) + ? walkSpeedOverride_ : WOW_WALK_SPEED; } else if (runSpeedOverride_ > 0.0f && runSpeedOverride_ < 100.0f && !std::isnan(runSpeedOverride_)) { speed = runSpeedOverride_; } else { @@ -357,6 +381,7 @@ void CameraController::update(float deltaTime) { // Toggle sit/crouch with X key (edge-triggered) — only when UI doesn't want keyboard // Blocked while mounted + bool prevSitting = sitting; bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X); if (xDown && !xKeyWasDown && !mounted_) { sitting = !sitting; @@ -364,6 +389,29 @@ void CameraController::update(float deltaTime) { if (mounted_) sitting = false; xKeyWasDown = xDown; + // Reset camera angles with R key (edge-triggered) — only when UI doesn't want keyboard + // Does NOT move the player; full reset() is reserved for world-entry/respawn. + bool rDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_R); + if (rDown && !rKeyWasDown) { + resetAngles(); + } + rKeyWasDown = rDown; + + // Stand up on any movement key or jump while sitting (WoW behaviour) + if (!uiWantsKeyboard && sitting && !movementSuppressed) { + bool anyMoveKey = + input.isKeyPressed(SDL_SCANCODE_W) || input.isKeyPressed(SDL_SCANCODE_S) || + input.isKeyPressed(SDL_SCANCODE_A) || input.isKeyPressed(SDL_SCANCODE_D) || + input.isKeyPressed(SDL_SCANCODE_Q) || input.isKeyPressed(SDL_SCANCODE_E) || + input.isKeyPressed(SDL_SCANCODE_SPACE); + if (anyMoveKey) sitting = false; + } + + // Notify server when the player stands up via local input + if (prevSitting && !sitting && standUpCallback_) { + standUpCallback_(); + } + // Update eye height based on crouch state (smooth transition) float targetEyeHeight = sitting ? CROUCH_EYE_HEIGHT : STAND_EYE_HEIGHT; float heightLerpSpeed = 10.0f * deltaTime; @@ -377,11 +425,6 @@ void CameraController::update(float deltaTime) { if (nowStrafeLeft) movement += right; if (nowStrafeRight) movement -= right; - // Stand up if jumping while crouched - if (!uiWantsKeyboard && sitting && input.isKeyPressed(SDL_SCANCODE_SPACE)) { - sitting = false; - } - // Third-person orbit camera mode if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera @@ -406,7 +449,14 @@ void CameraController::update(float deltaTime) { constexpr float MAX_SWIM_DEPTH_FROM_SURFACE = 12.0f; constexpr float MIN_SWIM_WATER_DEPTH = 1.0f; bool inWater = false; - if (waterH && targetPos.z < *waterH) { + // Water Walk: treat water surface as ground — player walks on top, not through. + if (waterWalkActive_ && waterH && targetPos.z >= *waterH - 0.5f) { + // Clamp to water surface so the player stands on it + targetPos.z = *waterH; + verticalVelocity = 0.0f; + grounded = true; + inWater = false; + } else if (waterH && targetPos.z < *waterH) { std::optional waterType; if (waterRenderer) { waterType = waterRenderer->getWaterTypeAt(targetPos.x, targetPos.y); @@ -504,7 +554,8 @@ void CameraController::update(float deltaTime) { swimming = true; // Swim movement follows look pitch (forward/back), while strafe stays // lateral for stable control. - float swimSpeed = speed * SWIM_SPEED_FACTOR; + float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_)) + ? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR; float waterSurfaceZ = waterH ? (*waterH - WATER_SURFACE_OFFSET) : targetPos.z; // For auto-run/auto-swim: use character facing (immune to camera pan) @@ -523,6 +574,10 @@ void CameraController::update(float deltaTime) { // Use character's facing direction for strafe, not camera's right vector glm::vec3 swimRight = right; // Character's right (horizontal facing), not camera's + float swimBackSpeed = (swimBackSpeedOverride_ > 0.0f && swimBackSpeedOverride_ < 100.0f + && !std::isnan(swimBackSpeedOverride_)) + ? swimBackSpeedOverride_ : swimSpeed * 0.5f; + glm::vec3 swimMove(0.0f); if (nowForward) swimMove += swimForward; if (nowBackward) swimMove -= swimForward; @@ -531,7 +586,9 @@ void CameraController::update(float deltaTime) { if (glm::length(swimMove) > 0.001f) { swimMove = glm::normalize(swimMove); - targetPos += swimMove * swimSpeed * physicsDeltaTime; + // Use backward swim speed when moving backwards only (not when combining with strafe) + float applySpeed = (nowBackward && !nowForward) ? swimBackSpeed : swimSpeed; + targetPos += swimMove * applySpeed * physicsDeltaTime; } // Spacebar = swim up (continuous, not a jump) @@ -680,11 +737,60 @@ void CameraController::update(float deltaTime) { } swimming = false; + // Player-controlled flight (flying mount / druid Flight Form): + // Use 3D pitch-following movement with no gravity or grounding. + if (flyingActive_) { + grounded = true; // suppress fall-damage checks + verticalVelocity = 0.0f; + jumpBufferTimer = 0.0f; + coyoteTimer = 0.0f; + + // Forward/back follows camera 3D direction (same as swim) + glm::vec3 flyFwd = glm::normalize(forward3D); + if (glm::length(flyFwd) < 1e-4f) flyFwd = forward; + glm::vec3 flyMove(0.0f); + if (nowForward) flyMove += flyFwd; + if (nowBackward) flyMove -= flyFwd; + if (nowStrafeLeft) flyMove += right; + if (nowStrafeRight) flyMove -= right; + // Space = ascend, X = descend while airborne + bool flyDescend = !uiWantsKeyboard && xDown && mounted_; + if (nowJump) flyMove.z += 1.0f; + if (flyDescend) flyMove.z -= 1.0f; + if (glm::length(flyMove) > 0.001f) { + flyMove = glm::normalize(flyMove); + float flyFwdSpeed = (flightSpeedOverride_ > 0.0f && flightSpeedOverride_ < 200.0f + && !std::isnan(flightSpeedOverride_)) + ? flightSpeedOverride_ : speed; + float flyBackSpeed = (flightBackSpeedOverride_ > 0.0f && flightBackSpeedOverride_ < 200.0f + && !std::isnan(flightBackSpeedOverride_)) + ? flightBackSpeedOverride_ : flyFwdSpeed * 0.5f; + float flySpeed = (nowBackward && !nowForward) ? flyBackSpeed : flyFwdSpeed; + targetPos += flyMove * flySpeed * physicsDeltaTime; + } + targetPos.z += verticalVelocity * physicsDeltaTime; + // Skip all ground physics — go straight to collision/WMO sections + } else { + if (glm::length(movement) > 0.001f) { movement = glm::normalize(movement); targetPos += movement * speed * physicsDeltaTime; } + // Apply server-driven knockback horizontal velocity (decays over time). + if (knockbackActive_) { + targetPos.x += knockbackHorizVel_.x * physicsDeltaTime; + targetPos.y += knockbackHorizVel_.y * physicsDeltaTime; + // Exponential drag: reduce each frame so the player decelerates naturally. + float drag = std::exp(-KNOCKBACK_HORIZ_DRAG * physicsDeltaTime); + knockbackHorizVel_ *= drag; + // Once negligible, clear the flag so collision/grounding work normally. + if (glm::length(knockbackHorizVel_) < 0.05f) { + knockbackActive_ = false; + knockbackHorizVel_ = glm::vec2(0.0f); + } + } + // Jump with input buffering and coyote time if (nowJump) jumpBufferTimer = JUMP_BUFFER_TIME; if (grounded) coyoteTimer = COYOTE_TIME; @@ -700,10 +806,20 @@ void CameraController::update(float deltaTime) { jumpBufferTimer -= physicsDeltaTime; coyoteTimer -= physicsDeltaTime; - // Apply gravity - verticalVelocity += gravity * physicsDeltaTime; - targetPos.z += verticalVelocity * physicsDeltaTime; + // Apply gravity (skip when server has disabled gravity, e.g. Levitate spell) + if (gravityDisabled_) { + // Float in place: bleed off any downward velocity, allow upward to decay slowly + if (verticalVelocity < 0.0f) verticalVelocity = 0.0f; + else verticalVelocity *= std::max(0.0f, 1.0f - 3.0f * physicsDeltaTime); + } else { + verticalVelocity += gravity * physicsDeltaTime; + // Feather Fall / Slow Fall: cap downward terminal velocity to ~2 m/s + if (featherFallActive_ && verticalVelocity < -2.0f) + verticalVelocity = -2.0f; } + targetPos.z += verticalVelocity * physicsDeltaTime; + } // end !flyingActive_ ground physics + } // end !inWater } else { // External follow (e.g., taxi): trust server position without grounding. swimming = false; @@ -1180,7 +1296,10 @@ void CameraController::update(float deltaTime) { dz >= -0.25f && dz <= stepUp * 1.5f); if (dz >= -fallCatch && (nearGround || airFalling || slopeGrace)) { - targetPos.z = *groundH; + // HOVER: float at fixed height above ground instead of standing on it + static constexpr float HOVER_HEIGHT = 4.0f; // ~4 yards above ground + const float snapH = hoverActive_ ? (*groundH + HOVER_HEIGHT) : *groundH; + targetPos.z = snapH; verticalVelocity = 0.0f; grounded = true; lastGroundZ = *groundH; @@ -1316,12 +1435,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 { @@ -1421,6 +1564,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) @@ -1468,7 +1614,8 @@ void CameraController::update(float deltaTime) { if (inWater) { swimming = true; - float swimSpeed = speed * SWIM_SPEED_FACTOR; + float swimSpeed = (swimSpeedOverride_ > 0.0f && swimSpeedOverride_ < 100.0f && !std::isnan(swimSpeedOverride_)) + ? swimSpeedOverride_ : speed * SWIM_SPEED_FACTOR; float waterSurfaceCamZ = waterH ? (*waterH - WATER_SURFACE_OFFSET + eyeHeight) : newPos.z; bool diveIntent = nowForward && (forward3D.z < -0.28f); @@ -1680,6 +1827,35 @@ void CameraController::update(float deltaTime) { } } + // Flight ascend/descend transitions (Space = ascend, X = descend while mounted+flying) + if (movementCallback && !externalFollow_) { + const bool nowAscending = flyingActive_ && spaceDown; + const bool nowDescending = flyingActive_ && xDown && mounted_; + + if (flyingActive_) { + if (nowAscending && !wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_START_ASCEND)); + } else if (!nowAscending && wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + if (nowDescending && !wasDescending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_START_DESCEND)); + } else if (!nowDescending && wasDescending_) { + // No separate STOP_DESCEND opcode; STOP_ASCEND ends all vertical movement + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + } else { + // Left flight mode: clear any lingering vertical movement states + if (wasAscending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } else if (wasDescending_) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_STOP_ASCEND)); + } + } + wasAscending_ = nowAscending; + wasDescending_ = nowDescending; + } + // Update previous-frame state wasSwimming = swimming; wasMovingForward = nowForward; @@ -1695,8 +1871,24 @@ void CameraController::update(float deltaTime) { wasJumping = nowJump; wasFalling = !grounded && verticalVelocity <= 0.0f; - // R key disabled — was camera reset, conflicts with chat reply - rKeyWasDown = false; + // R key is now handled above with chat safeguard (WantTextInput check) + + // Camera shake (SMSG_CAMERA_SHAKE): apply sinusoidal offset to final camera position. + if (shakeElapsed_ < shakeDuration_) { + shakeElapsed_ += deltaTime; + float t = shakeElapsed_ / shakeDuration_; + // Envelope: fade out over the last 30% of shake duration + float envelope = (t < 0.7f) ? 1.0f : (1.0f - (t - 0.7f) / 0.3f); + float theta = shakeElapsed_ * shakeFrequency_ * 2.0f * 3.14159265f; + glm::vec3 offset( + shakeMagnitude_ * envelope * std::sin(theta), + shakeMagnitude_ * envelope * std::cos(theta * 1.3f), + shakeMagnitude_ * envelope * std::sin(theta * 0.7f) * 0.5f + ); + if (camera) { + camera->setPosition(camera->getPosition() + offset); + } + } } void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { @@ -1713,7 +1905,9 @@ void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { // Directly update stored yaw/pitch (no lossy forward-vector derivation) yaw -= event.xrel * mouseSensitivity; - float invert = invertMouse ? -1.0f : 1.0f; + // SDL yrel > 0 = mouse moved DOWN. In WoW, mouse-down = look down = pitch decreases. + // invertMouse flips to flight-sim style (mouse-down = look up). + float invert = invertMouse ? 1.0f : -1.0f; pitch += event.yrel * mouseSensitivity * invert; // WoW-style pitch limits: can look almost straight down, limited upward @@ -1749,6 +1943,14 @@ void CameraController::processMouseButton(const SDL_MouseButtonEvent& event) { mouseButtonDown = anyDown; } +void CameraController::resetAngles() { + if (!camera) return; + yaw = defaultYaw; + facingYaw = defaultYaw; + pitch = defaultPitch; + camera->setRotation(yaw, pitch); +} + void CameraController::reset() { if (!camera) { return; @@ -2069,5 +2271,28 @@ void CameraController::triggerMountJump() { } } +void CameraController::applyKnockBack(float vcos, float vsin, float hspeed, float vspeed) { + // The server sends (vcos, vsin) as the 2D direction vector in server/wire + // coordinate space. After the server→canonical→render swaps, the direction + // in render space is simply (vcos, vsin) — the two swaps cancel each other. + knockbackHorizVel_ = glm::vec2(vcos, vsin) * hspeed; + knockbackActive_ = true; + + // vspeed in the wire packet is negative when the server wants to launch the + // player upward (matches TrinityCore: data << float(-speedZ)). Negate it + // here to obtain the correct upward initial velocity. + verticalVelocity = -vspeed; + grounded = false; + coyoteTimer = 0.0f; + jumpBufferTimer = 0.0f; + + // Notify the server that the player left the ground so the FALLING flag is + // set in subsequent movement heartbeats. The normal jump detection + // (nowJump && grounded) does not fire during a server-driven knockback. + if (movementCallback) { + movementCallback(static_cast(game::Opcode::MSG_MOVE_JUMP)); + } +} + } // namespace rendering } // namespace wowee diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 2314e6e9..306509ed 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -64,9 +64,9 @@ bool CharacterPreview::initialize(pipeline::AssetManager* am) { return false; } - // Disable fog and shadows for the preview + // Configure lighting for character preview + // Use distant fog to avoid clipping, enable shadows for visual depth charRenderer_->setFog(glm::vec3(0.05f, 0.05f, 0.1f), 9999.0f, 10000.0f); - charRenderer_->clearShadowMap(); camera_ = std::make_unique(); // Portrait-style camera: WoW Z-up coordinate system @@ -462,6 +462,17 @@ bool CharacterPreview::loadCharacter(game::Race race, game::Gender gender, } } } + } else { + // Single layer (body skin only, no face/underwear overlays) — load directly + VkTexture* skinTex = charRenderer_->loadTexture(bodySkinPath_); + if (skinTex != nullptr) { + for (size_t ti = 0; ti < model.textures.size(); ti++) { + if (model.textures[ti].type == 1) { + charRenderer_->setModelTexture(PREVIEW_MODEL_ID, static_cast(ti), skinTex); + break; + } + } + } } } @@ -819,8 +830,8 @@ void CharacterPreview::compositePass(VkCommandBuffer cmd, uint32_t frameIndex) { // No fog in preview ubo.fogColor = glm::vec4(0.05f, 0.05f, 0.1f, 0.0f); ubo.fogParams = glm::vec4(9999.0f, 10000.0f, 0.0f, 0.0f); - // Shadows disabled - ubo.shadowParams = glm::vec4(0.0f, 0.0f, 0.0f, 0.0f); + // Enable shadows for visual depth in preview (strength=0.5 for subtle effect) + ubo.shadowParams = glm::vec4(1.0f, 0.5f, 0.0f, 0.0f); std::memcpy(previewUBOMapped_[fi], &ubo, sizeof(GPUPerFrameData)); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 59965ec8..6b4e00b8 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -21,7 +21,9 @@ #include "rendering/vk_shader.hpp" #include "rendering/vk_buffer.hpp" #include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" #include "rendering/camera.hpp" +#include "rendering/frustum.hpp" #include "pipeline/asset_manager.hpp" #include "pipeline/blp_loader.hpp" #include "core/logger.hpp" @@ -38,7 +40,6 @@ #include #include #include -#include #include #include @@ -46,25 +47,6 @@ namespace wowee { namespace rendering { namespace { -size_t envSizeMBOrDefault(const char* name, size_t defMb) { - const char* v = std::getenv(name); - if (!v || !*v) return defMb; - char* end = nullptr; - unsigned long long mb = std::strtoull(v, &end, 10); - if (end == v || mb == 0) return defMb; - if (mb > (std::numeric_limits::max() / (1024ull * 1024ull))) return defMb; - return static_cast(mb); -} - -size_t envSizeOrDefault(const char* name, size_t defValue) { - const char* v = std::getenv(name); - if (!v || !*v) return defValue; - char* end = nullptr; - unsigned long long n = std::strtoull(v, &end, 10); - if (end == v || n == 0) return defValue; - return static_cast(n); -} - size_t approxTextureBytesWithMips(int w, int h) { if (w <= 0 || h <= 0) return 0; size_t base = static_cast(w) * static_cast(h) * 4ull; @@ -361,6 +343,8 @@ void CharacterRenderer::shutdown() { // Clean up composite cache compositeCache_.clear(); failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); + textureLookupSerial_ = 0; whiteTexture_.reset(); transparentTexture_.reset(); @@ -448,6 +432,8 @@ void CharacterRenderer::clear() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; loggedTextureLoadFails_.clear(); + failedTextureRetryAt_.clear(); + textureLookupSerial_ = 0; // Clear composite and failed caches compositeCache_.clear(); @@ -622,6 +608,7 @@ CharacterRenderer::NormalMapResult CharacterRenderer::generateNormalHeightMapCPU } VkTexture* CharacterRenderer::loadTexture(const std::string& path) { + constexpr uint64_t kFailedTextureRetryLookups = 512; // Skip empty or whitespace-only paths (type-0 textures have no filename) if (path.empty()) return whiteTexture_.get(); bool allWhitespace = true; @@ -637,6 +624,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { return key; }; std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; }; @@ -652,6 +640,10 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } if (!assetManager || !assetManager->isInitialized()) { return whiteTexture_.get(); @@ -670,8 +662,9 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { blpImage = assetManager->loadTexture(key); } if (!blpImage.isValid()) { - // Return white fallback but don't cache the failure — allow retry - // on next character load in case the asset becomes available. + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; if (loggedTextureLoadFails_.insert(key).second) { core::Logger::getInstance().warning("Failed to load texture: ", path); } @@ -684,6 +677,7 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { if (failedTextureCache_.size() < kMaxFailedTextureCache) { // Budget is saturated; avoid repeatedly decoding/uploading this texture. failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; } if (textureBudgetRejectWarnings_ < 3) { core::Logger::getInstance().warning( @@ -742,6 +736,8 @@ VkTexture* CharacterRenderer::loadTexture(const std::string& path) { textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); core::Logger::getInstance().debug("Loaded character texture: ", path, " (", blpImage.width, "x", blpImage.height, ")"); return texPtr; @@ -1061,19 +1057,6 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector& } } - // 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(composite.data()), - static_cast(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(); tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true); @@ -1341,12 +1324,12 @@ VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath, blitOverlay(composite, width, height, overlay, dstX, dstY); } } else { + // Size mismatch — blit at natural size (may clip or leave gap) + core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx, + " at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height, + " expected=", expectedW, "x", expectedH, " from ", rl.second); blitOverlay(composite, width, height, overlay, dstX, dstY); } - - core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx, - " at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height, - " expected=", expectedW, "x", expectedH, " from ", rl.second); } // Upload to GPU via VkTexture @@ -1594,12 +1577,20 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, instance.animationTime = 0.0f; instance.animationLoop = loop; + // Prefer variationIndex==0 (primary animation); fall back to first match + int firstMatch = -1; for (size_t i = 0; i < model.sequences.size(); i++) { if (model.sequences[i].id == animationId) { - instance.currentSequenceIndex = static_cast(i); - break; + if (firstMatch < 0) firstMatch = static_cast(i); + if (model.sequences[i].variationIndex == 0) { + instance.currentSequenceIndex = static_cast(i); + break; + } } } + if (instance.currentSequenceIndex < 0 && firstMatch >= 0) { + instance.currentSequenceIndex = firstMatch; + } if (instance.currentSequenceIndex < 0) { // Fall back to first sequence @@ -1646,10 +1637,6 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { if (t >= 1.0f) { inst.position = inst.moveEnd; inst.isMoving = false; - // Return to idle when movement completes - if (inst.currentAnimationId == 4 || inst.currentAnimationId == 5) { - playAnimation(pair.first, 0, true); - } } else { inst.position = glm::mix(inst.moveStart, inst.moveEnd, t); } @@ -1671,9 +1658,21 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { inst.animationTime += deltaTime * 1000.0f; if (seq.duration > 0 && inst.animationTime >= static_cast(seq.duration)) { if (inst.animationLoop) { - inst.animationTime = std::fmod(inst.animationTime, static_cast(seq.duration)); + // Subtract duration instead of fmod to preserve float precision + // fmod() loses precision with large animationTime values + inst.animationTime -= static_cast(seq.duration); + // Clamp to [0, duration) to handle multiple loops in one frame + while (inst.animationTime >= static_cast(seq.duration)) { + inst.animationTime -= static_cast(seq.duration); + } } else { - inst.animationTime = static_cast(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(seq.duration); + } } } } @@ -1979,16 +1978,18 @@ void CharacterRenderer::prepareRender(uint32_t frameIndex) { } } -void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) { +void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { if (instances.empty() || !opaquePipeline_) { return; } const float renderRadius = static_cast(envSizeOrDefault("WOWEE_CHAR_RENDER_RADIUS", 130)); const float renderRadiusSq = renderRadius * renderRadius; - const float nearNoConeCullSq = 16.0f * 16.0f; - const float backfaceDotCull = -0.30f; + const float characterCullRadius = 2.0f; // Estimate character radius for frustum testing const glm::vec3 camPos = camera.getPosition(); - const glm::vec3 camForward = camera.getForward(); + + // Extract frustum planes for per-instance visibility testing + Frustum frustum; + frustum.extractFromMatrix(camera.getViewProjectionMatrix()); uint32_t frameIndex = vkCtx_->getCurrentFrame(); uint32_t frameSlot = frameIndex % 2u; @@ -2019,22 +2020,17 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, // Skip invisible instances (e.g., player in first-person mode) if (!instance.visible) continue; - // Character instance culling: avoid drawing far-away / strongly behind-camera - // actors in dense city scenes. + + // Character instance culling: test both distance and frustum visibility if (!instance.hasOverrideModelMatrix) { glm::vec3 toInst = instance.position - camPos; float distSq = glm::dot(toInst, toInst); + + // Distance cull: skip if beyond render radius if (distSq > renderRadiusSq) continue; - if (distSq > nearNoConeCullSq) { - // Backface cull without sqrt: dot(toInst, camFwd) / |toInst| < threshold - // ⟺ dot < 0 || dot² < threshold² * distSq (when threshold < 0, dot must be negative) - float rawDot = glm::dot(toInst, camForward); - if (backfaceDotCull >= 0.0f) { - if (rawDot < 0.0f || rawDot * rawDot < backfaceDotCull * backfaceDotCull * distSq) continue; - } else { - if (rawDot < 0.0f && rawDot * rawDot > backfaceDotCull * backfaceDotCull * distSq) continue; - } - } + + // Frustum cull: skip if outside view frustum + if (!frustum.intersectsSphere(instance.position, characterCullRadius)) continue; } if (!instance.cachedModel) continue; @@ -2207,7 +2203,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) { @@ -2679,8 +2674,6 @@ void CharacterRenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& light vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, 1, 1, &shadowParamsSet_, 0, nullptr); - struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; - const float shadowRadiusSq = shadowRadius * shadowRadius; for (auto& pair : instances) { auto& inst = pair.second; @@ -2885,6 +2878,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& geosets) { auto it = instances.find(instanceId); if (it != instances.end()) { @@ -3026,6 +3028,65 @@ bool CharacterRenderer::getInstanceModelName(uint32_t instanceId, std::string& m return !modelName.empty(); } +bool CharacterRenderer::findAttachmentBone(uint32_t modelId, uint32_t attachmentId, + uint16_t& outBoneIndex, glm::vec3& outOffset) const { + auto modelIt = models.find(modelId); + if (modelIt == models.end()) return false; + const auto& model = modelIt->second.data; + + outBoneIndex = 0; + outOffset = glm::vec3(0.0f); + bool found = false; + + // Try attachment lookup first + if (attachmentId < model.attachmentLookup.size()) { + uint16_t attIdx = model.attachmentLookup[attachmentId]; + if (attIdx < model.attachments.size()) { + outBoneIndex = model.attachments[attIdx].bone; + outOffset = model.attachments[attIdx].position; + found = true; + } + } + + // Fallback: scan attachments by id + if (!found) { + for (const auto& att : model.attachments) { + if (att.id == attachmentId) { + outBoneIndex = att.bone; + outOffset = att.position; + found = true; + break; + } + } + } + + // Fallback: key-bone lookup for weapon hand attachment IDs (ID 1 = right hand, ID 2 = left hand) + if (!found && (attachmentId == 1 || attachmentId == 2)) { + int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; + for (size_t i = 0; i < model.bones.size(); i++) { + if (model.bones[i].keyBoneId == targetKeyBone) { + outBoneIndex = static_cast(i); + outOffset = glm::vec3(0.0f); + found = true; + break; + } + } + } + + // Fallback for head attachment (ID 11): use bone 0 if attachment not defined + if (!found && attachmentId == 11 && model.bones.size() > 0) { + outBoneIndex = 0; + found = true; + } + + // Validate bone index + if (found && outBoneIndex >= model.bones.size()) { + found = false; + } + + return found; +} + bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmentId, const pipeline::M2Model& weaponModel, uint32_t weaponModelId, const std::string& texturePath) { @@ -3037,62 +3098,11 @@ bool CharacterRenderer::attachWeapon(uint32_t charInstanceId, uint32_t attachmen auto& charInstance = charIt->second; auto charModelIt = models.find(charInstance.modelId); if (charModelIt == models.end()) return false; - const auto& charModel = charModelIt->second.data; // Find bone index for this attachment point uint16_t boneIndex = 0; glm::vec3 offset(0.0f); - bool found = false; - - // Try attachment lookup first - if (attachmentId < charModel.attachmentLookup.size()) { - uint16_t attIdx = charModel.attachmentLookup[attachmentId]; - if (attIdx < charModel.attachments.size()) { - boneIndex = charModel.attachments[attIdx].bone; - offset = charModel.attachments[attIdx].position; - found = true; - } - } - // Fallback: scan attachments by id - if (!found) { - for (const auto& att : charModel.attachments) { - if (att.id == attachmentId) { - boneIndex = att.bone; - offset = att.position; - found = true; - break; - } - } - } - // Fallback to key-bone lookup only for weapon hand attachment IDs. - if (!found && (attachmentId == 1 || attachmentId == 2)) { - int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; - for (size_t i = 0; i < charModel.bones.size(); i++) { - if (charModel.bones[i].keyBoneId == targetKeyBone) { - boneIndex = static_cast(i); - found = true; - break; - } - } - } - - // Validate bone index (bad attachment tables should not silently bind to origin) - if (found && boneIndex >= charModel.bones.size()) { - found = false; - } - if (!found && (attachmentId == 1 || attachmentId == 2)) { - int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; - for (size_t i = 0; i < charModel.bones.size(); i++) { - if (charModel.bones[i].keyBoneId == targetKeyBone) { - boneIndex = static_cast(i); - offset = glm::vec3(0.0f); - found = true; - break; - } - } - } - - if (!found) { + if (!findAttachmentBone(charInstance.modelId, attachmentId, boneIndex, offset)) { core::Logger::getInstance().warning("attachWeapon: no bone found for attachment ", attachmentId); return false; } @@ -3203,57 +3213,11 @@ bool CharacterRenderer::getAttachmentTransform(uint32_t instanceId, uint32_t att if (instIt == instances.end()) return false; const auto& instance = instIt->second; - auto modelIt = models.find(instance.modelId); - if (modelIt == models.end()) return false; - const auto& model = modelIt->second.data; - - // Find attachment point + // Find attachment point using shared lookup logic uint16_t boneIndex = 0; glm::vec3 offset(0.0f); - bool found = false; - - // Try attachment lookup first - if (attachmentId < model.attachmentLookup.size()) { - uint16_t attIdx = model.attachmentLookup[attachmentId]; - if (attIdx < model.attachments.size()) { - boneIndex = model.attachments[attIdx].bone; - offset = model.attachments[attIdx].position; - found = true; - } - } - - // Fallback: scan attachments by id - if (!found) { - for (const auto& att : model.attachments) { - if (att.id == attachmentId) { - boneIndex = att.bone; - offset = att.position; - found = true; - break; - } - } - } - - if (!found) return false; - - // Validate bone index; invalid indices bind attachments to origin (looks like weapons at feet). - if (boneIndex >= model.bones.size()) { - // Fallback: key bones (26/27) only for hand attachments. - if (attachmentId == 1 || attachmentId == 2) { - int32_t targetKeyBone = (attachmentId == 1) ? 26 : 27; - found = false; - for (size_t i = 0; i < model.bones.size(); i++) { - if (model.bones[i].keyBoneId == targetKeyBone) { - boneIndex = static_cast(i); - offset = glm::vec3(0.0f); - found = true; - break; - } - } - if (!found) return false; - } else { - return false; - } + if (!findAttachmentBone(instance.modelId, attachmentId, boneIndex, offset)) { + return false; } // Get bone matrix diff --git a/src/rendering/lens_flare.cpp b/src/rendering/lens_flare.cpp index 820641af..3dd6b734 100644 --- a/src/rendering/lens_flare.cpp +++ b/src/rendering/lens_flare.cpp @@ -313,8 +313,12 @@ void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec return; } + // Sun height attenuation — flare weakens when sun is near horizon (sunrise/sunset) + float sunHeight = sunDir.z; // z = up in render space; 0 = horizon, 1 = zenith + float heightFactor = glm::smoothstep(-0.05f, 0.25f, sunHeight); + // Atmospheric attenuation — fog, clouds, and weather reduce lens flare - float atmosphericFactor = 1.0f; + float atmosphericFactor = heightFactor; atmosphericFactor *= (1.0f - glm::clamp(fogDensity * 0.8f, 0.0f, 0.9f)); // Heavy fog nearly kills flare atmosphericFactor *= (1.0f - glm::clamp(cloudDensity * 0.6f, 0.0f, 0.7f)); // Clouds attenuate atmosphericFactor *= (1.0f - glm::clamp(weatherIntensity * 0.9f, 0.0f, 0.95f)); // Rain/snow heavily attenuates @@ -339,6 +343,9 @@ void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &vertexBuffer, &offset); + // Warm tint at sunrise/sunset — shift flare color toward orange/amber when sun is low + float warmTint = 1.0f - glm::smoothstep(0.05f, 0.35f, sunHeight); + // Render each flare element for (const auto& element : flareElements) { // Calculate position along sun-to-center axis @@ -347,12 +354,19 @@ void LensFlare::render(VkCommandBuffer cmd, const Camera& camera, const glm::vec // Apply visibility, intensity, and atmospheric attenuation float brightness = element.brightness * visibility * intensityMultiplier * atmosphericFactor; + // Apply warm sunset/sunrise color shift + glm::vec3 tintedColor = element.color; + if (warmTint > 0.01f) { + glm::vec3 warmColor(1.0f, 0.6f, 0.25f); // amber/orange + tintedColor = glm::mix(tintedColor, warmColor, warmTint * 0.5f); + } + // Set push constants FlarePushConstants push{}; push.position = position; push.size = element.size; push.aspectRatio = aspectRatio; - push.colorBrightness = glm::vec4(element.color, brightness); + push.colorBrightness = glm::vec4(tintedColor, brightness); vkCmdPushConstants(cmd, pipelineLayout, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, diff --git a/src/rendering/loading_screen.cpp b/src/rendering/loading_screen.cpp index a2e83a2b..92c1fe1c 100644 --- a/src/rendering/loading_screen.cpp +++ b/src/rendering/loading_screen.cpp @@ -261,6 +261,20 @@ void LoadingScreen::renderOverlay() { ImVec2(0, 0), ImVec2(screenW, screenH)); } + // Zone name header + if (!zoneName.empty()) { + ImFont* font = ImGui::GetFont(); + float zoneTextSize = 24.0f; + ImVec2 zoneSize = font->CalcTextSizeA(zoneTextSize, FLT_MAX, 0.0f, zoneName.c_str()); + float zoneX = (screenW - zoneSize.x) * 0.5f; + float zoneY = screenH * 0.06f - 44.0f; + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText(font, zoneTextSize, ImVec2(zoneX + 2.0f, zoneY + 2.0f), + IM_COL32(0, 0, 0, 200), zoneName.c_str()); + dl->AddText(font, zoneTextSize, ImVec2(zoneX, zoneY), + IM_COL32(255, 220, 120, 255), zoneName.c_str()); + } + // Progress bar { const float barWidthFrac = 0.6f; @@ -332,6 +346,22 @@ void LoadingScreen::render() { ImVec2(0, 0), ImVec2(screenW, screenH)); } + // Zone name header (large text centered above progress bar) + if (!zoneName.empty()) { + ImFont* font = ImGui::GetFont(); + float zoneTextSize = 24.0f; + ImVec2 zoneSize = font->CalcTextSizeA(zoneTextSize, FLT_MAX, 0.0f, zoneName.c_str()); + float zoneX = (screenW - zoneSize.x) * 0.5f; + float zoneY = screenH * 0.06f - 44.0f; // above percentage text + ImDrawList* dl = ImGui::GetWindowDrawList(); + // Drop shadow + dl->AddText(font, zoneTextSize, ImVec2(zoneX + 2.0f, zoneY + 2.0f), + IM_COL32(0, 0, 0, 200), zoneName.c_str()); + // Gold text + dl->AddText(font, zoneTextSize, ImVec2(zoneX, zoneY), + IM_COL32(255, 220, 120, 255), zoneName.c_str()); + } + // Progress bar (top of screen) { const float barWidthFrac = 0.6f; diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index b079f50a..654717ab 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -30,6 +30,9 @@ namespace rendering { namespace { +// Shared lava UV scroll timer — ensures consistent animation across all render passes +const auto kLavaAnimStart = std::chrono::steady_clock::now(); + bool envFlagEnabled(const char* key, bool defaultValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; @@ -40,24 +43,6 @@ bool envFlagEnabled(const char* key, bool defaultValue) { return !(v == "0" || v == "false" || v == "off" || v == "no"); } -size_t envSizeMBOrDefault(const char* name, size_t defMb) { - const char* raw = std::getenv(name); - if (!raw || !*raw) return defMb; - char* end = nullptr; - unsigned long long mb = std::strtoull(raw, &end, 10); - if (end == raw || mb == 0) return defMb; - return static_cast(mb); -} - -size_t envSizeOrDefault(const char* name, size_t defValue) { - const char* raw = std::getenv(name); - if (!raw || !*raw) return defValue; - char* end = nullptr; - unsigned long long v = std::strtoull(raw, &end, 10); - if (end == raw || v == 0) return defValue; - return static_cast(v); -} - static constexpr uint32_t kParticleFlagRandomized = 0x40; static constexpr uint32_t kParticleFlagTiled = 0x80; @@ -210,22 +195,6 @@ float pointAABBDistanceSq(const glm::vec3& p, const glm::vec3& bmin, const glm:: return glm::dot(d, d); } -struct QueryTimer { - double* totalMs = nullptr; - uint32_t* callCount = nullptr; - std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); - QueryTimer(double* total, uint32_t* calls) : totalMs(total), callCount(calls) {} - ~QueryTimer() { - if (callCount) { - (*callCount)++; - } - if (totalMs) { - auto end = std::chrono::steady_clock::now(); - *totalMs += std::chrono::duration(end - start).count(); - } - } -}; - // Möller–Trumbore ray-triangle intersection. // Returns distance along ray if hit, negative if miss. float rayTriangleIntersect(const glm::vec3& origin, const glm::vec3& dir, @@ -400,6 +369,41 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout vkCreateDescriptorPool(device, &ci, nullptr, &boneDescPool_); } + // Create a small identity-bone SSBO + descriptor set so that non-animated + // draws always have a valid set 2 bound. The Intel ANV driver segfaults + // on vkCmdDrawIndexed when a declared descriptor set slot is unbound. + { + // Single identity matrix (bone 0 = identity) + glm::mat4 identity(1.0f); + VkBufferCreateInfo bci{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bci.size = sizeof(glm::mat4); + bci.usage = VK_BUFFER_USAGE_STORAGE_BUFFER_BIT; + VmaAllocationCreateInfo aci{}; + aci.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + aci.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + vmaCreateBuffer(ctx->getAllocator(), &bci, &aci, + &dummyBoneBuffer_, &dummyBoneAlloc_, &allocInfo); + if (allocInfo.pMappedData) { + memcpy(allocInfo.pMappedData, &identity, sizeof(identity)); + } + + dummyBoneSet_ = allocateBoneSet(); + if (dummyBoneSet_) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = dummyBoneBuffer_; + bufInfo.offset = 0; + bufInfo.range = sizeof(glm::mat4); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = dummyBoneSet_; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + // --- Pipeline layouts --- // Main M2 pipeline layout: set 0 = perFrame, set 1 = material, set 2 = bones @@ -574,6 +578,54 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout .build(device); } + // --- Build ribbon pipelines --- + // Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats = 36 bytes + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + // Reuse particleTexLayout_ for set 1 (single texture sampler) + VkDescriptorSetLayout ribLayouts[] = {perFrameLayout, particleTexLayout_}; + VkPipelineLayoutCreateInfo lci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + lci.setLayoutCount = 2; + lci.pSetLayouts = ribLayouts; + vkCreatePipelineLayout(device, &lci, nullptr, &ribbonPipelineLayout_); + + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // pos + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, // alpha + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, // uv + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + // Clean up shader modules m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); @@ -604,6 +656,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout bci.size = MAX_GLOW_SPRITES * 9 * sizeof(float); vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &glowVB_, &glowVBAlloc_, &allocInfo); glowVBMapped_ = allocInfo.pMappedData; + + // Ribbon vertex buffer — triangle strip: pos(3)+color(3)+alpha(1)+uv(2)=9 floats/vert + bci.size = MAX_RIBBON_VERTS * 9 * sizeof(float); + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &ribbonVB_, &ribbonVBAlloc_, &allocInfo); + ribbonVBMapped_ = allocInfo.pMappedData; } // --- Create white fallback texture --- @@ -695,15 +752,18 @@ void M2Renderer::shutdown() { textureHasAlphaByPtr_.clear(); textureColorKeyBlackByPtr_.clear(); failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; whiteTexture_.reset(); glowTexture_.reset(); - // Clean up particle buffers + // Clean up particle/ribbon buffers if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; } if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; } if (glowVB_) { vmaDestroyBuffer(alloc, glowVB_, glowVBAlloc_); glowVB_ = VK_NULL_HANDLE; } + if (ribbonVB_) { vmaDestroyBuffer(alloc, ribbonVB_, ribbonVBAlloc_); ribbonVB_ = VK_NULL_HANDLE; } smokeParticles.clear(); // Destroy pipelines @@ -715,12 +775,18 @@ void M2Renderer::shutdown() { destroyPipeline(particlePipeline_); destroyPipeline(particleAdditivePipeline_); destroyPipeline(smokePipeline_); + destroyPipeline(ribbonPipeline_); + destroyPipeline(ribbonAdditivePipeline_); if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; } if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; } + if (ribbonPipelineLayout_) { vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); ribbonPipelineLayout_ = VK_NULL_HANDLE; } // Destroy descriptor pools and layouts + if (dummyBoneBuffer_) { vmaDestroyBuffer(alloc, dummyBoneBuffer_, dummyBoneAlloc_); dummyBoneBuffer_ = VK_NULL_HANDLE; } + // dummyBoneSet_ is freed implicitly when boneDescPool_ is destroyed + dummyBoneSet_ = VK_NULL_HANDLE; if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } if (boneDescPool_) { vkDestroyDescriptorPool(device, boneDescPool_, nullptr); boneDescPool_ = VK_NULL_HANDLE; } if (materialSetLayout_) { vkDestroyDescriptorSetLayout(device, materialSetLayout_, nullptr); materialSetLayout_ = VK_NULL_HANDLE; } @@ -743,10 +809,21 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { VmaAllocator alloc = vkCtx_->getAllocator(); if (model.vertexBuffer) { vmaDestroyBuffer(alloc, model.vertexBuffer, model.vertexAlloc); model.vertexBuffer = VK_NULL_HANDLE; } if (model.indexBuffer) { vmaDestroyBuffer(alloc, model.indexBuffer, model.indexAlloc); model.indexBuffer = VK_NULL_HANDLE; } + VkDevice device = vkCtx_->getDevice(); for (auto& batch : model.batches) { + if (batch.materialSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &batch.materialSet); batch.materialSet = VK_NULL_HANDLE; } if (batch.materialUBO) { vmaDestroyBuffer(alloc, batch.materialUBO, batch.materialUBOAlloc); batch.materialUBO = VK_NULL_HANDLE; } - // materialSet freed when pool is reset/destroyed } + // Free pre-allocated particle texture descriptor sets + for (auto& pSet : model.particleTexSets) { + if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; } + } + model.particleTexSets.clear(); + // Free ribbon texture descriptor sets + for (auto& rSet : model.ribbonTexSets) { + if (rSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &rSet); rSet = VK_NULL_HANDLE; } + } + model.ribbonTexSets.clear(); } void M2Renderer::destroyInstanceBones(M2Instance& inst) { @@ -776,7 +853,11 @@ VkDescriptorSet M2Renderer::allocateMaterialSet() { ai.descriptorSetCount = 1; ai.pSetLayouts = &materialSetLayout_; VkDescriptorSet set = VK_NULL_HANDLE; - vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set); + VkResult result = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set); + if (result != VK_SUCCESS) { + LOG_ERROR("M2Renderer: material descriptor set allocation failed (", result, ")"); + return VK_NULL_HANDLE; + } return set; } @@ -786,7 +867,11 @@ VkDescriptorSet M2Renderer::allocateBoneSet() { ai.descriptorSetCount = 1; ai.pSetLayouts = &boneSetLayout_; VkDescriptorSet set = VK_NULL_HANDLE; - vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set); + VkResult result = vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set); + if (result != VK_SUCCESS) { + LOG_ERROR("M2Renderer: bone descriptor set allocation failed (", result, ")"); + return VK_NULL_HANDLE; + } return set; } @@ -910,8 +995,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); bool hasParticles = !model.particleEmitters.empty(); - if (!hasGeometry && !hasParticles) { - LOG_WARNING("M2 model has no geometry and no particles: ", model.name); + bool hasRibbons = !model.ribbonEmitters.empty(); + if (!hasGeometry && !hasParticles && !hasRibbons) { + LOG_WARNING("M2 model has no renderable content: ", model.name); return false; } @@ -979,8 +1065,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("monument") != std::string::npos) || (lowerName.find("sculpture") != std::string::npos); gpuModel.collisionStatue = statueName; + // Sittable furniture: chairs/benches/stools cause players to get stuck against + // invisible bounding boxes; WMOs already handle room collision. + bool sittableFurnitureName = + (lowerName.find("chair") != std::string::npos) || + (lowerName.find("bench") != std::string::npos) || + (lowerName.find("stool") != std::string::npos) || + (lowerName.find("seat") != std::string::npos) || + (lowerName.find("throne") != std::string::npos); bool smallSolidPropName = - statueName || + (statueName && !sittableFurnitureName) || (lowerName.find("crate") != std::string::npos) || (lowerName.find("box") != std::string::npos) || (lowerName.find("chest") != std::string::npos) || @@ -1023,6 +1117,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("bamboo") != std::string::npos) || (lowerName.find("banana") != std::string::npos) || (lowerName.find("coconut") != std::string::npos) || + (lowerName.find("watermelon") != std::string::npos) || + (lowerName.find("melon") != std::string::npos) || + (lowerName.find("squash") != std::string::npos) || + (lowerName.find("gourd") != std::string::npos) || (lowerName.find("canopy") != std::string::npos) || (lowerName.find("hedge") != std::string::npos) || (lowerName.find("cactus") != std::string::npos) || @@ -1148,7 +1246,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("lavasplash") != std::string::npos) || (lowerName.find("lavabubble") != std::string::npos) || (lowerName.find("lavasteam") != std::string::npos) || - (lowerName.find("wisps") != std::string::npos); + (lowerName.find("wisps") != std::string::npos) || + (lowerName.find("levelup") != std::string::npos); gpuModel.isSpellEffect = effectByName || (hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3); @@ -1253,6 +1352,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { gpuModel.indexBuffer = buf.buffer; gpuModel.indexAlloc = buf.allocation; } + + if (!gpuModel.vertexBuffer || !gpuModel.indexBuffer) { + LOG_ERROR("M2Renderer::loadModel: GPU buffer upload failed for model ", modelId); + } } // Load ALL textures from the model into a local vector. @@ -1335,6 +1438,68 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Pre-allocate one stable descriptor set per particle emitter to avoid per-frame allocation. + // This prevents materialDescPool_ exhaustion when many emitters are active each frame. + if (particleTexLayout_ && materialDescPool_ && !model.particleEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.particleTexSets.resize(model.particleEmitters.size(), VK_NULL_HANDLE); + for (size_t ei = 0; ei < model.particleEmitters.size(); ei++) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.particleTexSets[ei]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.particleTextures[ei]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.particleTexSets[ei]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + + // Copy ribbon emitter data and resolve textures + gpuModel.ribbonEmitters = model.ribbonEmitters; + if (!model.ribbonEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get()); + gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE); + for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) { + // Resolve texture via textureLookup table + uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex; + uint32_t texIdx = (texLookupIdx < model.textureLookup.size()) + ? model.textureLookup[texLookupIdx] : UINT32_MAX; + if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { + gpuModel.ribbonTextures[ri] = allTextures[texIdx]; + } + // Allocate descriptor set (reuse particleTexLayout_ = single sampler) + if (particleTexLayout_ && materialDescPool_) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.ribbonTexSets[ri]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.ribbonTextures[ri]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.ribbonTexSets[ri]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + LOG_DEBUG(" Ribbon emitters loaded: ", model.ribbonEmitters.size()); + } + // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; @@ -1467,12 +1632,26 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { // since we don't have the full combo table — dual-UV effects are rare edge cases. bgpu.textureUnit = 0; - // Batch is hidden only when its named texture failed to load (avoids white shell artifacts). - // Do NOT bake transparency/color animation tracks here — they animate over time and - // baking the first keyframe value causes legitimate meshes to become invisible. - // Keep terrain clutter visible even when source texture paths are malformed. + // Start at full opacity; hide only if texture failed to load. bgpu.batchOpacity = (texFailed && !groundDetailModel) ? 0.0f : 1.0f; + // Apply at-rest transparency and color alpha from the M2 animation tracks. + // These provide per-batch opacity for ghosts, ethereal effects, fading doodads, etc. + // Skip zero values: some animated tracks start at 0 and animate up, and baking + // that first keyframe would make the entire batch permanently invisible. + if (bgpu.batchOpacity > 0.0f) { + float animAlpha = 1.0f; + if (batch.colorIndex < model.colorAlphas.size()) { + float ca = model.colorAlphas[batch.colorIndex]; + if (ca > 0.001f) animAlpha *= ca; + } + if (batch.transparencyIndex < model.textureWeights.size()) { + float tw = model.textureWeights[batch.transparencyIndex]; + if (tw > 0.001f) animAlpha *= tw; + } + bgpu.batchOpacity *= animAlpha; + } + // Compute batch center and radius for glow sprite positioning if ((bgpu.blendMode >= 3 || bgpu.colorKeyBlack) && batch.indexCount > 0) { glm::vec3 sum(0.0f); @@ -1625,6 +1804,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } models[modelId] = std::move(gpuModel); + spatialIndexDirty_ = true; // Map may have rehashed — refresh cachedModel pointers LOG_DEBUG("Loaded M2 model: ", model.name, " (", models[modelId].vertexCount, " vertices, ", models[modelId].indexCount / 3, " triangles, ", models[modelId].batches.size(), " batches)"); @@ -1641,6 +1821,7 @@ uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, return 0; } const auto& mdlRef = modelIt->second; + modelUnusedSince_.erase(modelId); // Deduplicate: skip if same model already at nearly the same position. // Uses hash map for O(1) lookup instead of O(N) scan. @@ -1752,6 +1933,7 @@ uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4& LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); return 0; } + modelUnusedSince_.erase(modelId); // Deduplicate: O(1) hash lookup { @@ -1870,7 +2052,15 @@ static void resolveTrackTime(const pipeline::M2AnimationTrack& track, // Global sequence: always use sub-array 0, wrap time at global duration outSeqIdx = 0; float dur = static_cast(globalSeqDurations[track.globalSequence]); - outTime = (dur > 0.0f) ? std::fmod(time, dur) : 0.0f; + if (dur > 0.0f) { + // Use iterative subtraction instead of fmod() to preserve precision + outTime = time; + while (outTime >= dur) { + outTime -= dur; + } + } else { + outTime = 0.0f; + } } else { outSeqIdx = seqIdx; outTime = time; @@ -1987,7 +2177,7 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: std::uniform_real_distribution distDrift(-0.2f, 0.2f); smokeEmitAccum += deltaTime; - float emitInterval = 1.0f / 8.0f; // 8 particles per second per emitter + float emitInterval = 1.0f / 48.0f; // 48 particles per second per emitter (was 32; increased for denser lava/magma steam effects in sparse areas) if (smokeEmitAccum >= emitInterval && static_cast(smokeParticles.size()) < MAX_SMOKE_PARTICLES) { @@ -2060,8 +2250,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: for (size_t idx : particleOnlyInstanceIndices_) { if (idx >= instances.size()) continue; auto& instance = instances[idx]; - if (instance.animTime > 3333.0f) { - instance.animTime = std::fmod(instance.animTime, 3333.0f); + // Use iterative subtraction instead of fmod() to preserve precision + while (instance.animTime > 3333.0f) { + instance.animTime -= 3333.0f; } } @@ -2104,7 +2295,11 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: instance.animTime = 0.0f; instance.variationTimer = 4000.0f + static_cast(rand() % 6000); } else { - instance.animTime = std::fmod(instance.animTime, std::max(1.0f, instance.animDuration)); + // Use iterative subtraction instead of fmod() to preserve precision + float duration = std::max(1.0f, instance.animDuration); + while (instance.animTime >= duration) { + instance.animTime -= duration; + } } } @@ -2218,6 +2413,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: if (!instance.cachedModel) continue; emitParticles(instance, *instance.cachedModel, deltaTime); updateParticles(instance, deltaTime); + if (!instance.cachedModel->ribbonEmitters.empty()) { + updateRibbons(instance, *instance.cachedModel, deltaTime); + } } } @@ -2360,6 +2558,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const uint32_t currentModelId = UINT32_MAX; const M2ModelGPU* currentModel = nullptr; + bool currentModelValid = false; // State tracking VkPipeline currentPipeline = VK_NULL_HANDLE; @@ -2375,6 +2574,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const float fadeAlpha; }; + // Validate per-frame descriptor set before any Vulkan commands + if (!perFrameSet) { + LOG_ERROR("M2Renderer::render: perFrameSet is VK_NULL_HANDLE — skipping M2 render"); + return; + } + // Bind per-frame descriptor set (set 0) — shared across all draws vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); @@ -2384,6 +2589,13 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const currentPipeline = opaquePipeline_; bool opaquePass = true; // Pass 1 = opaque, pass 2 = transparent (set below for second pass) + // Bind dummy bone set (set 2) so non-animated draws have a valid binding. + // Animated instances override this with their real bone set per-instance. + if (dummyBoneSet_) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &dummyBoneSet_, 0, nullptr); + } + for (const auto& entry : sortedVisible_) { if (entry.index >= instances.size()) continue; auto& instance = instances[entry.index]; @@ -2391,14 +2603,17 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Bind vertex + index buffers once per model group if (entry.modelId != currentModelId) { currentModelId = entry.modelId; + currentModelValid = false; auto mdlIt = models.find(currentModelId); if (mdlIt == models.end()) continue; currentModel = &mdlIt->second; - if (!currentModel->vertexBuffer) continue; + if (!currentModel->vertexBuffer || !currentModel->indexBuffer) continue; + currentModelValid = true; VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); } + if (!currentModelValid) continue; const M2ModelGPU& model = *currentModel; @@ -2528,8 +2743,12 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const (batch.blendMode >= 3) || batch.colorKeyBlack || ((batch.materialFlags & 0x01) != 0); - if ((batch.glowCardLike && lanternLikeModel) || - (cardLikeSkipMesh && !lanternLikeModel)) { + const bool lanternGlowCardSkip = + lanternLikeModel && + batch.lanternGlowHint && + smallCardLikeBatch && + cardLikeSkipMesh; + if (lanternGlowCardSkip || (cardLikeSkipMesh && !lanternLikeModel)) { continue; } } @@ -2549,10 +2768,10 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } } - // Lava M2 models: fallback UV scroll if no texture animation + // Lava M2 models: fallback UV scroll if no texture animation. + // Uses kLavaAnimStart (file-scope) for consistent timing across passes. if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { - static auto startTime = std::chrono::steady_clock::now(); - float t = std::chrono::duration(std::chrono::steady_clock::now() - startTime).count(); + float t = std::chrono::duration(std::chrono::steady_clock::now() - kLavaAnimStart).count(); uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); } @@ -2637,7 +2856,6 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const continue; } vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); - vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); lastDrawCallCount++; } @@ -2651,6 +2869,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const currentModelId = UINT32_MAX; currentModel = nullptr; + currentModelValid = false; // Reset pipeline to opaque so the first transparent bind always sets explicitly currentPipeline = opaquePipeline_; @@ -2669,14 +2888,17 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // `!opaquePass && !rawTransparent → continue` handles opaque skipping) if (entry.modelId != currentModelId) { currentModelId = entry.modelId; + currentModelValid = false; auto mdlIt = models.find(currentModelId); if (mdlIt == models.end()) continue; currentModel = &mdlIt->second; - if (!currentModel->vertexBuffer) continue; + if (!currentModel->vertexBuffer || !currentModel->indexBuffer) continue; + currentModelValid = true; VkDeviceSize offset = 0; vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); } + if (!currentModelValid) continue; const M2ModelGPU& model = *currentModel; @@ -2725,16 +2947,25 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Skip glow sprites (handled after loop) const bool batchUnlit = (batch.materialFlags & 0x01) != 0; + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; + const bool smallCardLikeBatch = + (batch.glowSize <= 1.35f) || + (batch.lanternGlowHint && batch.glowSize <= 6.0f); const bool shouldUseGlowSprite = - !batch.colorKeyBlack && + !koboldFlameCard && (model.isElvenLike || model.isLanternLike) && !model.isSpellEffect && - (batch.glowSize <= 1.35f || (batch.lanternGlowHint && batch.glowSize <= 6.0f)) && + smallCardLikeBatch && (batch.lanternGlowHint || (batch.blendMode >= 3) || (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); if (shouldUseGlowSprite) { const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; - if ((batch.glowCardLike && model.isLanternLike) || (cardLikeSkipMesh && !model.isLanternLike)) + const bool lanternGlowCardSkip = + model.isLanternLike && + batch.lanternGlowHint && + smallCardLikeBatch && + cardLikeSkipMesh; + if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike)) continue; } @@ -2753,8 +2984,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { - static auto startTime2 = std::chrono::steady_clock::now(); - float t = std::chrono::duration(std::chrono::steady_clock::now() - startTime2).count(); + float t = std::chrono::duration(std::chrono::steady_clock::now() - kLavaAnimStart).count(); uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); } @@ -2839,16 +3069,6 @@ bool M2Renderer::initializeShadow(VkRenderPass shadowRenderPass) { if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; VkDevice device = vkCtx_->getDevice(); - // ShadowParams UBO: useBones, useTexture, alphaTest, foliageSway, windTime, foliageMotionDamp - struct ShadowParamsUBO { - int32_t useBones = 0; - int32_t useTexture = 0; - int32_t alphaTest = 0; - int32_t foliageSway = 0; - float windTime = 0.0f; - float foliageMotionDamp = 1.0f; - }; - // Create ShadowParams UBO VkBufferCreateInfo bufCI{}; bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; @@ -3026,15 +3246,6 @@ void M2Renderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMa if (!shadowPipeline_ || !shadowParamsSet_) return; if (instances.empty() || models.empty()) return; - struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; - struct ShadowParamsUBO { - int32_t useBones = 0; - int32_t useTexture = 0; - int32_t alphaTest = 0; - int32_t foliageSway = 0; - float windTime = 0.0f; - float foliageMotionDamp = 1.0f; - }; const float shadowRadiusSq = shadowRadius * shadowRadius; // Reset per-frame texture descriptor pool for foliage alpha-test sets @@ -3371,6 +3582,214 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { } } +// --------------------------------------------------------------------------- +// Ribbon emitter simulation +// --------------------------------------------------------------------------- +void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) { + const auto& emitters = gpu.ribbonEmitters; + if (emitters.empty()) return; + + // Grow per-instance state arrays if needed + if (inst.ribbonEdges.size() != emitters.size()) { + inst.ribbonEdges.resize(emitters.size()); + } + if (inst.ribbonEdgeAccumulators.size() != emitters.size()) { + inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f); + } + + for (size_t ri = 0; ri < emitters.size(); ri++) { + const auto& em = emitters[ri]; + auto& edges = inst.ribbonEdges[ri]; + auto& accum = inst.ribbonEdgeAccumulators[ri]; + + // Determine bone world position for spine + glm::vec3 spineWorld = inst.position; + if (em.bone < inst.boneMatrices.size()) { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); + } else { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * local); + } + + // Evaluate animated tracks (use first available sequence key, or fallback value) + auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { + for (const auto& seq : track.sequences) { + if (!seq.floatValues.empty()) return seq.floatValues[0]; + } + return fallback; + }; + auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 { + for (const auto& seq : track.sequences) { + if (!seq.vec3Values.empty()) return seq.vec3Values[0]; + } + return fallback; + }; + + float visibility = getFloatVal(em.visibilityTrack, 1.0f); + float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f); + float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f); + glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f)); + float alpha = getFloatVal(em.alphaTrack, 1.0f); + + // Age existing edges and remove expired ones + for (auto& e : edges) { + e.age += dt; + // Apply gravity + if (em.gravity != 0.0f) { + e.worldPos.z -= em.gravity * dt * dt * 0.5f; + } + } + while (!edges.empty() && edges.front().age >= em.edgeLifetime) { + edges.pop_front(); + } + + // Emit new edges based on edgesPerSecond + if (visibility > 0.5f) { + accum += em.edgesPerSecond * dt; + while (accum >= 1.0f) { + accum -= 1.0f; + M2Instance::RibbonEdge e; + e.worldPos = spineWorld; + e.color = color; + e.alpha = alpha; + e.heightAbove = heightAbove; + e.heightBelow = heightBelow; + e.age = 0.0f; + edges.push_back(e); + // Cap trail length + if (edges.size() > 128) edges.pop_front(); + } + } else { + accum = 0.0f; + } + } +} + +// --------------------------------------------------------------------------- +// Ribbon rendering +// --------------------------------------------------------------------------- +void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; + + // Build camera right vector for billboard orientation + // For ribbons we orient the quad strip along the spine with screen-space up. + // Simple approach: use world-space Z=up for the ribbon cross direction. + const glm::vec3 upWorld(0.0f, 0.0f, 1.0f); + + float* dst = static_cast(ribbonVBMapped_); + size_t written = 0; + + struct DrawCall { + VkDescriptorSet texSet; + VkPipeline pipeline; + uint32_t firstVertex; + uint32_t vertexCount; + }; + std::vector draws; + + for (const auto& inst : instances) { + if (!inst.cachedModel) continue; + const auto& gpu = *inst.cachedModel; + if (gpu.ribbonEmitters.empty()) continue; + + for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) { + if (ri >= inst.ribbonEdges.size()) continue; + const auto& edges = inst.ribbonEdges[ri]; + if (edges.size() < 2) continue; + + const auto& em = gpu.ribbonEmitters[ri]; + + // Select blend pipeline based on material blend mode + bool additive = false; + if (em.materialIndex < gpu.batches.size()) { + additive = (gpu.batches[em.materialIndex].blendMode >= 3); + } + VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_; + + // Descriptor set for texture + VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) + ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; + if (!texSet) continue; + + uint32_t firstVert = static_cast(written); + + // Emit triangle strip: 2 verts per edge (top + bottom) + for (size_t ei = 0; ei < edges.size(); ei++) { + if (written + 2 > MAX_RIBBON_VERTS) break; + const auto& e = edges[ei]; + float t = (em.edgeLifetime > 0.0f) + ? 1.0f - (e.age / em.edgeLifetime) : 1.0f; + float a = e.alpha * t; + float u = static_cast(ei) / static_cast(edges.size() - 1); + + // Top vertex (above spine along upWorld) + glm::vec3 top = e.worldPos + upWorld * e.heightAbove; + dst[written * 9 + 0] = top.x; + dst[written * 9 + 1] = top.y; + dst[written * 9 + 2] = top.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 0.0f; // v = top + written++; + + // Bottom vertex (below spine) + glm::vec3 bot = e.worldPos - upWorld * e.heightBelow; + dst[written * 9 + 0] = bot.x; + dst[written * 9 + 1] = bot.y; + dst[written * 9 + 2] = bot.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 1.0f; // v = bottom + written++; + } + + uint32_t vertCount = static_cast(written) - firstVert; + if (vertCount >= 4) { + draws.push_back({texSet, pipe, firstVert, vertCount}); + } else { + // Rollback if too few verts + written = firstVert; + } + } + } + + if (draws.empty() || written == 0) return; + + VkExtent2D ext = vkCtx_->getSwapchainExtent(); + VkViewport vp{}; + vp.x = 0; vp.y = 0; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.minDepth = 0.0f; vp.maxDepth = 1.0f; + VkRect2D sc{}; + sc.offset = {0, 0}; + sc.extent = ext; + vkCmdSetViewport(cmd, 0, 1, &vp); + vkCmdSetScissor(cmd, 0, 1, &sc); + + VkPipeline lastPipe = VK_NULL_HANDLE; + for (const auto& dc : draws) { + if (dc.pipeline != lastPipe) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + lastPipe = dc.pipeline; + } + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset); + vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0); + } +} + void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (!particlePipeline_ || !m2ParticleVB_) return; @@ -3401,6 +3820,7 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame uint8_t blendType; uint16_t tilesX; uint16_t tilesY; + VkDescriptorSet preAllocSet = VK_NULL_HANDLE; // Pre-allocated stable set, avoids per-frame alloc std::vector vertexData; // 9 floats per particle }; std::unordered_map groups; @@ -3442,6 +3862,11 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame group.blendType = em.blendingType; group.tilesX = tilesX; group.tilesY = tilesY; + // Capture pre-allocated descriptor set on first insertion for this key + if (group.preAllocSet == VK_NULL_HANDLE && + p.emitterIndex < static_cast(gpu.particleTexSets.size())) { + group.preAllocSet = gpu.particleTexSets[p.emitterIndex]; + } group.vertexData.push_back(p.position.x); group.vertexData.push_back(p.position.y); @@ -3455,8 +3880,12 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { float animSeconds = inst.animTime / 1000.0f; uint32_t animFrame = static_cast(std::floor(animSeconds * totalTiles)) % totalTiles; - tileIndex = std::fmod(p.tileIndex + static_cast(animFrame), - static_cast(totalTiles)); + tileIndex = p.tileIndex + static_cast(animFrame); + float tilesFloat = static_cast(totalTiles); + // Wrap tile index within totalTiles range + while (tileIndex >= tilesFloat) { + tileIndex -= tilesFloat; + } } group.vertexData.push_back(tileIndex); totalParticles++; @@ -3485,23 +3914,27 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame currentPipeline = desiredPipeline; } - // Allocate descriptor set for this group's texture - VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; - ai.descriptorPool = materialDescPool_; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &particleTexLayout_; - VkDescriptorSet texSet = VK_NULL_HANDLE; - if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { - VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); - VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = texSet; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &imgInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); - + // Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable + VkDescriptorSet texSet = group.preAllocSet; + if (texSet == VK_NULL_HANDLE) { + // Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice) + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { + VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = texSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + if (texSet != VK_NULL_HANDLE) { vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); } @@ -3610,6 +4043,18 @@ void M2Renderer::setInstanceAnimationFrozen(uint32_t instanceId, bool frozen) { } } +float M2Renderer::getInstanceAnimDuration(uint32_t instanceId) const { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return 0.0f; + const auto& inst = instances[idxIt->second]; + if (!inst.cachedModel) return 0.0f; + const auto& seqs = inst.cachedModel->sequences; + if (seqs.empty()) return 0.0f; + int seqIdx = inst.currentSequenceIndex; + if (seqIdx < 0 || seqIdx >= static_cast(seqs.size())) seqIdx = 0; + return seqs[seqIdx].duration; // in milliseconds +} + void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { auto idxIt = instanceIndexById.find(instanceId); if (idxIt == instanceIndexById.end()) return; @@ -3664,14 +4109,67 @@ void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& tran } void M2Renderer::removeInstance(uint32_t instanceId) { - for (auto it = instances.begin(); it != instances.end(); ++it) { - if (it->id == instanceId) { - destroyInstanceBones(*it); - instances.erase(it); - rebuildSpatialIndex(); - return; + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + size_t idx = idxIt->second; + if (idx >= instances.size()) return; + + auto& inst = instances[idx]; + + // Remove from spatial grid incrementally (same pattern as the move-update path) + GridCell minCell = toCell(inst.worldBoundsMin); + GridCell maxCell = toCell(inst.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + auto gIt = spatialGrid.find(GridCell{x, y, z}); + if (gIt != spatialGrid.end()) { + auto& vec = gIt->second; + vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); + } + } } } + + // Remove from dedup map + if (!inst.cachedIsGroundDetail) { + DedupKey dk{inst.modelId, + static_cast(std::round(inst.position.x * 10.0f)), + static_cast(std::round(inst.position.y * 10.0f)), + static_cast(std::round(inst.position.z * 10.0f))}; + instanceDedupMap_.erase(dk); + } + + destroyInstanceBones(inst); + + // Swap-remove: move last element to the hole and pop_back to avoid O(n) shift + instanceIndexById.erase(instanceId); + if (idx < instances.size() - 1) { + uint32_t movedId = instances.back().id; + instances[idx] = std::move(instances.back()); + instances.pop_back(); + instanceIndexById[movedId] = idx; + } else { + instances.pop_back(); + } + + // Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.) + // These are small vectors of indices that are rebuilt cheaply. + smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); + animatedInstanceIndices_.clear(); + particleOnlyInstanceIndices_.clear(); + particleInstanceIndices_.clear(); + for (size_t i = 0; i < instances.size(); i++) { + auto& ri = instances[i]; + if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i); + if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i); + if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i); + if (ri.cachedHasAnimation && !ri.cachedDisableAnimation) + animatedInstanceIndices_.push_back(i); + else if (ri.cachedHasParticleEmitters) + particleOnlyInstanceIndices_.push_back(i); + } } void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) { @@ -3743,6 +4241,21 @@ void M2Renderer::clear() { } if (boneDescPool_) { vkResetDescriptorPool(device, boneDescPool_, 0); + // Re-allocate the dummy bone set (invalidated by pool reset) + dummyBoneSet_ = allocateBoneSet(); + if (dummyBoneSet_ && dummyBoneBuffer_) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = dummyBoneBuffer_; + bufInfo.offset = 0; + bufInfo.range = sizeof(glm::mat4); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = dummyBoneSet_; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } } } models.clear(); @@ -3879,15 +4392,39 @@ void M2Renderer::cleanupUnusedModels() { usedModelIds.insert(instance.modelId); } - // Find and remove models with no instances + const auto now = std::chrono::steady_clock::now(); + constexpr auto kGracePeriod = std::chrono::seconds(60); + + // Find models with no instances that have exceeded the grace period. + // Models that just lost their last instance get tracked but not evicted + // immediately — this prevents thrashing when GO models are briefly + // instance-free between despawn and respawn cycles. std::vector toRemove; for (const auto& [id, model] : models) { - if (usedModelIds.find(id) == usedModelIds.end()) { + if (usedModelIds.find(id) != usedModelIds.end()) { + // Model still in use — clear any pending unused timestamp + modelUnusedSince_.erase(id); + continue; + } + auto unusedIt = modelUnusedSince_.find(id); + if (unusedIt == modelUnusedSince_.end()) { + // First cycle with no instances — start the grace timer + modelUnusedSince_[id] = now; + } else if (now - unusedIt->second >= kGracePeriod) { + // Grace period expired — mark for removal toRemove.push_back(id); + modelUnusedSince_.erase(unusedIt); } } - // Delete GPU resources and remove from map + // Delete GPU resources and remove from map. + // Wait for the GPU to finish all in-flight frames before destroying any + // buffers — the previous frame's command buffer may still be referencing + // vertex/index buffers that are about to be freed. Without this wait, + // the GPU reads freed memory, which can cause VK_ERROR_DEVICE_LOST. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } for (uint32_t id : toRemove) { auto it = models.find(id); if (it != models.end()) { @@ -3902,6 +4439,7 @@ void M2Renderer::cleanupUnusedModels() { } VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { + constexpr uint64_t kFailedTextureRetryLookups = 512; auto normalizeKey = [](std::string key) { std::replace(key.begin(), key.end(), '/', '\\'); std::transform(key.begin(), key.end(), key.begin(), @@ -3909,6 +4447,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { return key; }; std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; // Check cache auto it = textureCache.find(key); @@ -3916,7 +4455,10 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { it->second.lastUse = ++textureCacheCounter_; return it->second.texture.get(); } - // No negative cache check — allow retries for transiently missing textures + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } auto containsToken = [](const std::string& haystack, const char* token) { return haystack.find(token) != std::string::npos; @@ -3947,8 +4489,9 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { blp = assetManager->loadTexture(key); } if (!blp.isValid()) { - // Return white fallback but don't cache the failure — MPQ reads can - // fail transiently during streaming; allow retry on next model load. + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; if (loggedTextureLoadFails_.insert(key).second) { LOG_WARNING("M2: Failed to load texture: ", path); } @@ -3963,6 +4506,7 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { // Cache budget-rejected keys too; without this we repeatedly decode/load // the same textures every frame once budget is saturated. failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; } if (textureBudgetRejectWarnings_ < 3) { LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), @@ -4001,6 +4545,8 @@ VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { e.lastUse = ++textureCacheCounter_; textureCacheBytes_ += e.approxBytes; textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); textureHasAlphaByPtr_[texPtr] = hasAlpha; textureColorKeyBlackByPtr_[texPtr] = colorKeyBlackHint; LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); @@ -4480,6 +5026,8 @@ void M2Renderer::recreatePipelines() { if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } + if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; } + if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; } // --- Load shaders --- rendering::VkShaderModule m2Vert, m2Frag; @@ -4599,6 +5147,46 @@ void M2Renderer::recreatePipelines() { .build(device); } + // --- Ribbon pipelines --- + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); smokeVert.destroy(); smokeFrag.destroy(); diff --git a/src/rendering/minimap.cpp b/src/rendering/minimap.cpp index 0f44869b..cce494d9 100644 --- a/src/rendering/minimap.cpp +++ b/src/rendering/minimap.cpp @@ -228,6 +228,7 @@ void Minimap::shutdown() { if (tex) tex->destroy(device, alloc); } tileTextureCache.clear(); + tileInsertionOrder.clear(); if (noDataTexture) { noDataTexture->destroy(device, alloc); noDataTexture.reset(); } if (compositeTarget) { compositeTarget->destroy(device, alloc); compositeTarget.reset(); } @@ -362,6 +363,15 @@ VkTexture* Minimap::getOrLoadTileTexture(int tileX, int tileY) { VkTexture* ptr = tex.get(); tileTextureCache[hash] = std::move(tex); + tileInsertionOrder.push_back(hash); + + // Evict oldest tiles when cache grows too large to bound GPU memory usage. + while (tileInsertionOrder.size() > MAX_TILE_CACHE) { + const std::string& oldest = tileInsertionOrder.front(); + tileTextureCache.erase(oldest); + tileInsertionOrder.pop_front(); + } + return ptr; } @@ -513,14 +523,15 @@ void Minimap::render(VkCommandBuffer cmd, const Camera& playerCamera, float arrowRotation = 0.0f; if (!rotateWithCamera) { - // Prefer authoritative orientation if provided. This value is expected - // to already match minimap shader rotation convention. if (hasPlayerOrientation) { arrowRotation = playerOrientation; } else { glm::vec3 fwd = playerCamera.getForward(); - arrowRotation = std::atan2(-fwd.x, fwd.y); + arrowRotation = -std::atan2(-fwd.x, fwd.y); } + } else if (hasPlayerOrientation) { + // Show character facing relative to the rotated map + arrowRotation = playerOrientation + rotation; } MinimapDisplayPush push{}; diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 09430dce..d6119e74 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -10,6 +10,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" +#include "rendering/lightning.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/m2_renderer.hpp" @@ -219,6 +220,13 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text(" Upscale Dispatches: %zu", renderer->getAmdFsr3UpscaleDispatchCount()); ImGui::Text(" FG Fallbacks: %zu", renderer->getAmdFsr3FallbackCount()); } + if (renderer->isFXAAEnabled()) { + if (renderer->isFSR2Enabled()) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.8f, 1.0f), "FXAA: ON (FSR3+FXAA combined)"); + } else { + ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON"); + } + } ImGui::Spacing(); } @@ -362,6 +370,11 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::Text("Intensity: %.0f%%", weather->getIntensity() * 100.0f); } + auto* lightning = renderer->getLightning(); + if (lightning && lightning->isEnabled()) { + ImGui::Text("Lightning: active (%.0f%%)", lightning->getIntensity() * 100.0f); + } + ImGui::Spacing(); } } @@ -448,7 +461,7 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.5f, 1.0f), "Movement"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "WASD: Move/Strafe"); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Turn left/right"); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Q/E: Strafe left/right"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "Space: Jump"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "X: Sit/Stand"); ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "~: Auto-run"); diff --git a/src/rendering/quest_marker_renderer.cpp b/src/rendering/quest_marker_renderer.cpp index bc481d5a..b274a880 100644 --- a/src/rendering/quest_marker_renderer.cpp +++ b/src/rendering/quest_marker_renderer.cpp @@ -1,5 +1,6 @@ #include "rendering/quest_marker_renderer.hpp" #include "rendering/camera.hpp" +#include "rendering/frustum.hpp" #include "rendering/vk_context.hpp" #include "rendering/vk_shader.hpp" #include "rendering/vk_pipeline.hpp" @@ -17,8 +18,9 @@ namespace wowee { namespace rendering { // Push constant layout matching quest_marker.vert.glsl / quest_marker.frag.glsl struct QuestMarkerPushConstants { - glm::mat4 model; // 64 bytes, used by vertex shader - float alpha; // 4 bytes, used by fragment shader + glm::mat4 model; // 64 bytes, used by vertex shader + float alpha; // 4 bytes, used by fragment shader + float grayscale; // 4 bytes: 0=colour, 1=desaturated (trivial quests) }; QuestMarkerRenderer::QuestMarkerRenderer() { @@ -340,8 +342,9 @@ void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) { } } -void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight) { - markers_[guid] = {position, markerType, boundingHeight}; +void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, + float boundingHeight, float grayscale) { + markers_[guid] = {position, markerType, boundingHeight, grayscale}; } void QuestMarkerRenderer::removeMarker(uint64_t guid) { @@ -372,6 +375,10 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe glm::mat4 view = camera.getViewMatrix(); glm::vec3 cameraPos = camera.getPosition(); + // Extract frustum planes for visibility testing + Frustum frustum; + frustum.extractFromMatrix(camera.getViewProjectionMatrix()); + // Get camera right and up vectors for billboarding glm::vec3 cameraRight = glm::vec3(view[0][0], view[1][0], view[2][0]); glm::vec3 cameraUp = glm::vec3(view[0][1], view[1][1], view[2][1]); @@ -396,6 +403,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe glm::vec3 toCamera = cameraPos - marker.position; float distSq = glm::dot(toCamera, toCamera); if (distSq > CULL_DIST_SQ) continue; + + // Frustum cull quest markers (small sphere for icon) + constexpr float markerCullRadius = 0.5f; + if (!frustum.intersectsSphere(marker.position, markerCullRadius)) continue; + float dist = std::sqrt(distSq); // Calculate fade alpha @@ -436,10 +448,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 1, 1, &texDescSets_[marker.type], 0, nullptr); - // Push constants: model matrix + alpha + // Push constants: model matrix + alpha + grayscale tint QuestMarkerPushConstants push{}; push.model = model; push.alpha = fadeAlpha; + push.grayscale = marker.grayscale; vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 6da94182..7199273d 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -12,6 +12,7 @@ #include "rendering/clouds.hpp" #include "rendering/lens_flare.hpp" #include "rendering/weather.hpp" +#include "rendering/lightning.hpp" #include "rendering/lighting_manager.hpp" #include "rendering/sky_system.hpp" #include "rendering/swim_effects.hpp" @@ -66,6 +67,10 @@ #include #include #include +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include "stb_image_write.h" #include #include #include @@ -699,6 +704,9 @@ bool Renderer::initialize(core::Window* win) { weather = std::make_unique(); weather->initialize(vkCtx, perFrameSetLayout); + lightning = std::make_unique(); + lightning->initialize(vkCtx, perFrameSetLayout); + swimEffects = std::make_unique(); swimEffects->initialize(vkCtx, perFrameSetLayout); @@ -710,6 +718,8 @@ bool Renderer::initialize(core::Window* win) { levelUpEffect = std::make_unique(); + questMarkerRenderer = std::make_unique(); + LOG_INFO("Vulkan sub-renderers initialized (Phase 3)"); // LightingManager doesn't use GL — initialize for data-only use @@ -800,6 +810,11 @@ void Renderer::shutdown() { weather.reset(); } + if (lightning) { + lightning->shutdown(); + lightning.reset(); + } + if (swimEffects) { swimEffects->shutdown(); swimEffects.reset(); @@ -856,6 +871,7 @@ void Renderer::shutdown() { destroyFSRResources(); destroyFSR2Resources(); + destroyFXAAResources(); destroyPerFrameResources(); zoneManager.reset(); @@ -939,6 +955,7 @@ void Renderer::applyMsaaChange() { if (characterRenderer) characterRenderer->recreatePipelines(); if (questMarkerRenderer) questMarkerRenderer->recreatePipelines(); if (weather) weather->recreatePipelines(); + if (lightning) lightning->recreatePipelines(); if (swimEffects) swimEffects->recreatePipelines(); if (mountDust) mountDust->recreatePipelines(); if (chargeEffect) chargeEffect->recreatePipelines(); @@ -958,8 +975,9 @@ void Renderer::applyMsaaChange() { VkDevice device = vkCtx->getDevice(); if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } - if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() + if (fsr_.sceneFramebuffer) destroyFSRResources(); // Will be lazily recreated in beginFrame() if (fsr2_.sceneFramebuffer) destroyFSR2Resources(); + if (fxaa_.sceneFramebuffer) destroyFXAAResources(); // Will be lazily recreated in beginFrame() // Reinitialize ImGui Vulkan backend with new MSAA sample count ImGui_ImplVulkan_Shutdown(); @@ -1015,6 +1033,22 @@ void Renderer::beginFrame() { } } + // FXAA resource management — FXAA can coexist with FSR1 and FSR3. + // When both FXAA and FSR3 are enabled, FXAA runs as a post-FSR3 pass. + // Do not force this pass for ghost mode; keep AA quality strictly user-controlled. + const bool useFXAAPostPass = fxaa_.enabled; + if ((fxaa_.needsRecreate || !useFXAAPostPass) && fxaa_.sceneFramebuffer) { + destroyFXAAResources(); + fxaa_.needsRecreate = false; + if (!useFXAAPostPass) LOG_INFO("FXAA: disabled"); + } + if (useFXAAPostPass && !fxaa_.sceneFramebuffer) { + if (!initFXAAResources()) { + LOG_ERROR("FXAA: initialization failed, disabling"); + fxaa_.enabled = false; + } + } + // Handle swapchain recreation if needed if (vkCtx->isSwapchainDirty()) { vkCtx->recreateSwapchain(window->getWidth(), window->getHeight()); @@ -1031,6 +1065,11 @@ void Renderer::beginFrame() { destroyFSR2Resources(); initFSR2Resources(); } + // Recreate FXAA resources for new swapchain dimensions. + if (useFXAAPostPass) { + destroyFXAAResources(); + initFXAAResources(); + } } // Acquire swapchain image and begin command buffer @@ -1117,6 +1156,11 @@ void Renderer::beginFrame() { if (fsr2_.enabled && fsr2_.sceneFramebuffer) { rpInfo.framebuffer = fsr2_.sceneFramebuffer; renderExtent = { fsr2_.internalWidth, fsr2_.internalHeight }; + } else if (useFXAAPostPass && fxaa_.sceneFramebuffer) { + // FXAA takes priority over FSR1: renders at native res with AA post-process. + // When both FSR1 and FXAA are enabled, FXAA wins (native res, no downscale). + rpInfo.framebuffer = fxaa_.sceneFramebuffer; + renderExtent = vkCtx->getSwapchainExtent(); // native resolution — no downscaling } else if (fsr_.enabled && fsr_.sceneFramebuffer) { rpInfo.framebuffer = fsr_.sceneFramebuffer; renderExtent = { fsr_.internalWidth, fsr_.internalHeight }; @@ -1206,6 +1250,35 @@ void Renderer::endFrame() { VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); } + // FSR3+FXAA combined: re-point FXAA's descriptor to the FSR3 temporal output + // so renderFXAAPass() applies spatial AA on the temporally-stabilized frame. + // This must happen outside the render pass (descriptor updates are CPU-side). + if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler) { + VkImageView fsr3OutputView = VK_NULL_HANDLE; + if (fsr2_.useAmdBackend) { + if (fsr2_.amdFsr3FramegenRuntimeActive && fsr2_.framegenOutput.image) + fsr3OutputView = fsr2_.framegenOutput.imageView; + else if (fsr2_.history[fsr2_.currentHistory].image) + fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView; + } else if (fsr2_.history[fsr2_.currentHistory].image) { + fsr3OutputView = fsr2_.history[fsr2_.currentHistory].imageView; + } + if (fsr3OutputView) { + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = fsr3OutputView; + imgInfo.sampler = fxaa_.sceneSampler; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fxaa_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &write, 0, nullptr); + } + } + // Begin swapchain render pass at full resolution for sharpening + ImGui VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; @@ -1235,8 +1308,33 @@ void Renderer::endFrame() { sc.extent = ext; vkCmdSetScissor(currentCmd, 0, 1, &sc); - // Draw RCAS sharpening from accumulated history buffer - renderFSR2Sharpen(); + // When FXAA is also enabled: apply FXAA on the FSR3 temporal output instead + // of RCAS sharpening. FXAA descriptor is temporarily pointed to the FSR3 + // history buffer (which is already in SHADER_READ_ONLY_OPTIMAL). This gives + // FSR3 temporal stability + FXAA spatial edge smoothing ("ultra quality native"). + if (fxaa_.enabled && fxaa_.pipeline && fxaa_.descSet) { + renderFXAAPass(); + } else { + // Draw RCAS sharpening from accumulated history buffer + renderFSR2Sharpen(); + } + + // Restore FXAA descriptor to its normal scene color source so standalone + // FXAA frames are not affected by the FSR3 history pointer set above. + if (fxaa_.enabled && fxaa_.descSet && fxaa_.sceneSampler && fxaa_.sceneColor.imageView) { + VkDescriptorImageInfo restoreInfo{}; + restoreInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + restoreInfo.imageView = fxaa_.sceneColor.imageView; + restoreInfo.sampler = fxaa_.sceneSampler; + VkWriteDescriptorSet restoreWrite{}; + restoreWrite.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + restoreWrite.dstSet = fxaa_.descSet; + restoreWrite.dstBinding = 0; + restoreWrite.descriptorCount = 1; + restoreWrite.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + restoreWrite.pImageInfo = &restoreInfo; + vkUpdateDescriptorSets(vkCtx->getDevice(), 1, &restoreWrite, 0, nullptr); + } // Maintain frame bookkeeping fsr2_.prevViewProjection = camera->getViewProjectionMatrix(); @@ -1247,43 +1345,33 @@ void Renderer::endFrame() { } fsr2_.frameIndex = (fsr2_.frameIndex + 1) % 256; // Wrap to keep Halton values well-distributed - } else if (fsr_.enabled && fsr_.sceneFramebuffer) { + } else if (fxaa_.enabled && fxaa_.sceneFramebuffer) { // End the off-screen scene render pass vkCmdEndRenderPass(currentCmd); - // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY - // The render pass finalLayout puts the resolve/color attachment in PRESENT_SRC_KHR - transitionImageLayout(currentCmd, fsr_.sceneColor.image, + // Transition resolved scene color: PRESENT_SRC_KHR → SHADER_READ_ONLY + transitionImageLayout(currentCmd, fxaa_.sceneColor.image, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); - // Begin swapchain render pass at full resolution + // Begin swapchain render pass (1x — no MSAA on the output pass) VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rpInfo.renderPass = vkCtx->getImGuiRenderPass(); rpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; rpInfo.renderArea.offset = {0, 0}; rpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); - - // Clear values must match the render pass attachment count - bool msaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); - VkClearValue clearValues[4]{}; - clearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; - clearValues[1].depthStencil = {1.0f, 0}; - clearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; - clearValues[3].depthStencil = {1.0f, 0}; - if (msaaOn) { - bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); - rpInfo.clearValueCount = depthRes ? 4 : 3; - } else { - rpInfo.clearValueCount = 2; - } - rpInfo.pClearValues = clearValues; + // The swapchain render pass always has 2 attachments when MSAA is off; + // FXAA output goes to the non-MSAA swapchain directly. + VkClearValue fxaaClear[2]{}; + fxaaClear[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fxaaClear[1].depthStencil = {1.0f, 0}; + rpInfo.clearValueCount = 2; + rpInfo.pClearValues = fxaaClear; vkCmdBeginRenderPass(currentCmd, &rpInfo, VK_SUBPASS_CONTENTS_INLINE); - // Set full-resolution viewport and scissor VkExtent2D ext = vkCtx->getSwapchainExtent(); VkViewport vp{}; vp.width = static_cast(ext.width); @@ -1294,12 +1382,60 @@ void Renderer::endFrame() { sc.extent = ext; vkCmdSetScissor(currentCmd, 0, 1, &sc); - // Draw FSR upscale fullscreen quad + // Draw FXAA pass + renderFXAAPass(); + + } else if (fsr_.enabled && fsr_.sceneFramebuffer) { + // FSR1 upscale path — only runs when FXAA is not active. + // When both FSR1 and FXAA are enabled, FXAA took priority above. + vkCmdEndRenderPass(currentCmd); + + // Transition scene color (1x resolve/color target): PRESENT_SRC_KHR → SHADER_READ_ONLY + transitionImageLayout(currentCmd, fsr_.sceneColor.image, + VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + + // Begin swapchain render pass at full resolution + VkRenderPassBeginInfo fsrRpInfo{}; + fsrRpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + fsrRpInfo.renderPass = vkCtx->getImGuiRenderPass(); + fsrRpInfo.framebuffer = vkCtx->getSwapchainFramebuffers()[currentImageIndex]; + fsrRpInfo.renderArea.offset = {0, 0}; + fsrRpInfo.renderArea.extent = vkCtx->getSwapchainExtent(); + + bool fsrMsaaOn = (vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT); + VkClearValue fsrClearValues[4]{}; + fsrClearValues[0].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fsrClearValues[1].depthStencil = {1.0f, 0}; + fsrClearValues[2].color = {{0.0f, 0.0f, 0.0f, 1.0f}}; + fsrClearValues[3].depthStencil = {1.0f, 0}; + if (fsrMsaaOn) { + bool depthRes = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + fsrRpInfo.clearValueCount = depthRes ? 4 : 3; + } else { + fsrRpInfo.clearValueCount = 2; + } + fsrRpInfo.pClearValues = fsrClearValues; + + vkCmdBeginRenderPass(currentCmd, &fsrRpInfo, VK_SUBPASS_CONTENTS_INLINE); + + VkExtent2D fsrExt = vkCtx->getSwapchainExtent(); + VkViewport fsrVp{}; + fsrVp.width = static_cast(fsrExt.width); + fsrVp.height = static_cast(fsrExt.height); + fsrVp.maxDepth = 1.0f; + vkCmdSetViewport(currentCmd, 0, 1, &fsrVp); + VkRect2D fsrSc{}; + fsrSc.extent = fsrExt; + vkCmdSetScissor(currentCmd, 0, 1, &fsrSc); + renderFSRUpscale(); } // ImGui rendering — must respect subpass contents mode - if (!fsr_.enabled && !fsr2_.enabled && parallelRecordingEnabled_) { + // Parallel recording only applies when no post-process pass is active. + if (!fsr_.enabled && !fsr2_.enabled && !fxaa_.enabled && parallelRecordingEnabled_) { // Scene pass was begun with VK_SUBPASS_CONTENTS_SECONDARY_COMMAND_BUFFERS, // so ImGui must be recorded into a secondary command buffer. VkCommandBuffer imguiCmd = beginSecondary(SEC_IMGUI); @@ -1724,7 +1860,18 @@ void Renderer::updateCharacterAnimation() { CharAnimState newState = charAnimState; - bool moving = cameraController->isMoving(); + const bool rawMoving = cameraController->isMoving(); + const bool rawSprinting = cameraController->isSprinting(); + constexpr float kLocomotionStopGraceSec = 0.12f; + if (rawMoving) { + locomotionStopGraceTimer_ = kLocomotionStopGraceSec; + locomotionWasSprinting_ = rawSprinting; + } else { + locomotionStopGraceTimer_ = std::max(0.0f, locomotionStopGraceTimer_ - lastDeltaTime_); + } + // Debounce brief input/state dropouts (notably during both-mouse steering) so + // locomotion clips do not restart every few frames. + bool moving = rawMoving || locomotionStopGraceTimer_ > 0.0f; bool movingForward = cameraController->isMovingForward(); bool movingBackward = cameraController->isMovingBackward(); bool autoRunning = cameraController->isAutoRunning(); @@ -1737,7 +1884,7 @@ void Renderer::updateCharacterAnimation() { bool anyStrafeRight = strafeRight && !strafeLeft && pureStrafe; bool grounded = cameraController->isGrounded(); bool jumping = cameraController->isJumping(); - bool sprinting = cameraController->isSprinting(); + bool sprinting = rawSprinting || (!rawMoving && moving && locomotionWasSprinting_); bool sitting = cameraController->isSitting(); bool swim = cameraController->isSwimming(); bool forceMelee = meleeSwingTimer > 0.0f && grounded && !swim; @@ -2006,7 +2153,12 @@ void Renderer::updateCharacterAnimation() { // Rider bob: sinusoidal motion synced to mount's run animation (only used in fallback positioning) mountBob = 0.0f; if (moving && haveMountState && curMountDur > 1.0f) { - float norm = std::fmod(curMountTime, curMountDur) / curMountDur; + // Wrap mount time preserving precision via subtraction instead of fmod + float wrappedTime = curMountTime; + while (wrappedTime >= curMountDur) { + wrappedTime -= curMountDur; + } + float norm = wrappedTime / curMountDur; // One bounce per stride cycle float bobSpeed = taxiFlight_ ? 2.0f : 1.0f; mountBob = std::sin(norm * 2.0f * 3.14159f * bobSpeed) * 0.12f; @@ -2096,8 +2248,8 @@ void Renderer::updateCharacterAnimation() { // Rider uses character facing yaw, not mount bone rotation // (rider faces character direction, seat bone only provides position) float yawRad = glm::radians(characterYaw); - float riderPitch = taxiFlight_ ? mountPitch_ * 0.35f : 0.0f; - float riderRoll = taxiFlight_ ? mountRoll_ * 0.35f : 0.0f; + float riderPitch = mountPitch_ * 0.35f; + float riderRoll = mountRoll_ * 0.35f; characterRenderer->setInstanceRotation(characterInstanceId, glm::vec3(riderPitch, riderRoll, yawRad)); } else { // Fallback to old manual positioning if attachment not found @@ -2222,6 +2374,14 @@ void Renderer::updateCharacterAnimation() { } else if (sitting) { cancelEmote(); newState = CharAnimState::SIT_DOWN; + } else if (!emoteLoop && characterRenderer && characterInstanceId > 0) { + // Auto-cancel non-looping emotes once animation completes + uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; + if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur) + && curDur > 0.1f && curT >= curDur - 0.05f) { + cancelEmote(); + newState = CharAnimState::IDLE; + } } break; @@ -2384,8 +2544,14 @@ void Renderer::updateCharacterAnimation() { float currentAnimTimeMs = 0.0f; float currentAnimDurationMs = 0.0f; bool haveState = characterRenderer->getAnimationState(characterInstanceId, currentAnimId, currentAnimTimeMs, currentAnimDurationMs); - if (!haveState || currentAnimId != animId) { + // Some frames may transiently fail getAnimationState() while resources/instance state churn. + // Avoid reissuing the same clip on those frames, which restarts locomotion and causes hitches. + const bool requestChanged = (lastPlayerAnimRequest_ != animId) || (lastPlayerAnimLoopRequest_ != loop); + const bool shouldPlay = (haveState && currentAnimId != animId) || (!haveState && requestChanged); + if (shouldPlay) { characterRenderer->playAnimation(characterInstanceId, animId, loop); + lastPlayerAnimRequest_ = animId; + lastPlayerAnimLoopRequest_ = loop; } } @@ -2412,6 +2578,101 @@ void Renderer::cancelEmote() { emoteLoop = false; } +bool Renderer::captureScreenshot(const std::string& outputPath) { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D extent = vkCtx->getSwapchainExtent(); + const auto& images = vkCtx->getSwapchainImages(); + + if (images.empty() || currentImageIndex >= images.size()) return false; + + VkImage srcImage = images[currentImageIndex]; + uint32_t w = extent.width; + uint32_t h = extent.height; + VkDeviceSize bufSize = static_cast(w) * h * 4; + + // Stall GPU so the swapchain image is idle + vkDeviceWaitIdle(device); + + // Create staging buffer + VkBufferCreateInfo bufInfo{VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO}; + bufInfo.size = bufSize; + bufInfo.usage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_ONLY; + + VkBuffer stagingBuf = VK_NULL_HANDLE; + VmaAllocation stagingAlloc = VK_NULL_HANDLE; + if (vmaCreateBuffer(alloc, &bufInfo, &allocCI, &stagingBuf, &stagingAlloc, nullptr) != VK_SUCCESS) { + LOG_WARNING("Screenshot: failed to create staging buffer"); + return false; + } + + // Record copy commands + VkCommandBuffer cmd = vkCtx->beginSingleTimeCommands(); + + // Transition swapchain image: PRESENT_SRC → TRANSFER_SRC + VkImageMemoryBarrier toTransfer{VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER}; + toTransfer.srcAccessMask = VK_ACCESS_MEMORY_READ_BIT; + toTransfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + toTransfer.oldLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + toTransfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + toTransfer.image = srcImage; + toTransfer.subresourceRange = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1}; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &toTransfer); + + // Copy image to buffer + VkBufferImageCopy region{}; + region.imageSubresource = {VK_IMAGE_ASPECT_COLOR_BIT, 0, 0, 1}; + region.imageExtent = {w, h, 1}; + vkCmdCopyImageToBuffer(cmd, srcImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + stagingBuf, 1, ®ion); + + // Transition back: TRANSFER_SRC → PRESENT_SRC + VkImageMemoryBarrier toPresent = toTransfer; + toPresent.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + toPresent.dstAccessMask = VK_ACCESS_MEMORY_READ_BIT; + toPresent.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + toPresent.newLayout = VK_IMAGE_LAYOUT_PRESENT_SRC_KHR; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, + 0, 0, nullptr, 0, nullptr, 1, &toPresent); + + vkCtx->endSingleTimeCommands(cmd); + + // Map and convert BGRA → RGBA + void* mapped = nullptr; + vmaMapMemory(alloc, stagingAlloc, &mapped); + auto* pixels = static_cast(mapped); + for (uint32_t i = 0; i < w * h; ++i) { + std::swap(pixels[i * 4 + 0], pixels[i * 4 + 2]); // B ↔ R + } + + // Ensure output directory exists + std::filesystem::path outPath(outputPath); + if (outPath.has_parent_path()) + std::filesystem::create_directories(outPath.parent_path()); + + int ok = stbi_write_png(outputPath.c_str(), + static_cast(w), static_cast(h), + 4, pixels, static_cast(w * 4)); + + vmaUnmapMemory(alloc, stagingAlloc); + vmaDestroyBuffer(alloc, stagingBuf, stagingAlloc); + + if (ok) { + LOG_INFO("Screenshot saved: ", outputPath); + } else { + LOG_WARNING("Screenshot: stbi_write_png failed for ", outputPath); + } + return ok != 0; +} + void Renderer::triggerLevelUpEffect(const glm::vec3& position) { if (!levelUpEffect) return; @@ -2465,6 +2726,201 @@ void Renderer::stopChargeEffect() { } } +// ─── Spell Visual Effects ──────────────────────────────────────────────────── + +void Renderer::loadSpellVisualDbc() { + if (spellVisualDbcLoaded_) return; + spellVisualDbcLoaded_ = true; // Set early to prevent re-entry on failure + + if (!cachedAssetManager) { + cachedAssetManager = core::Application::getInstance().getAssetManager(); + } + if (!cachedAssetManager) return; + + auto* layout = pipeline::getActiveDBCLayout(); + const pipeline::DBCFieldMap* svLayout = layout ? layout->getLayout("SpellVisual") : nullptr; + const pipeline::DBCFieldMap* kitLayout = layout ? layout->getLayout("SpellVisualKit") : nullptr; + const pipeline::DBCFieldMap* fxLayout = layout ? layout->getLayout("SpellVisualEffectName") : nullptr; + + uint32_t svCastKitField = svLayout ? (*svLayout)["CastKit"] : 2; + uint32_t svImpactKitField = svLayout ? (*svLayout)["ImpactKit"] : 3; + uint32_t svMissileField = svLayout ? (*svLayout)["MissileModel"] : 8; + uint32_t kitSpecial0Field = kitLayout ? (*kitLayout)["SpecialEffect0"] : 11; + uint32_t kitBaseField = kitLayout ? (*kitLayout)["BaseEffect"] : 5; + uint32_t fxFilePathField = fxLayout ? (*fxLayout)["FilePath"] : 2; + + // Helper to look up effectName path from a kit ID + // Load SpellVisualEffectName.dbc — ID → M2 path + auto fxDbc = cachedAssetManager->loadDBC("SpellVisualEffectName.dbc"); + if (!fxDbc || !fxDbc->isLoaded() || fxDbc->getFieldCount() <= fxFilePathField) { + LOG_DEBUG("SpellVisual: SpellVisualEffectName.dbc unavailable (fc=", + fxDbc ? fxDbc->getFieldCount() : 0, ")"); + return; + } + std::unordered_map effectPaths; // effectNameId → path + for (uint32_t i = 0; i < fxDbc->getRecordCount(); ++i) { + uint32_t id = fxDbc->getUInt32(i, 0); + std::string p = fxDbc->getString(i, fxFilePathField); + if (id && !p.empty()) effectPaths[id] = p; + } + + // Load SpellVisualKit.dbc — kitId → best SpellVisualEffectName ID + auto kitDbc = cachedAssetManager->loadDBC("SpellVisualKit.dbc"); + std::unordered_map kitToEffectName; // kitId → effectNameId + if (kitDbc && kitDbc->isLoaded()) { + uint32_t fc = kitDbc->getFieldCount(); + for (uint32_t i = 0; i < kitDbc->getRecordCount(); ++i) { + uint32_t kitId = kitDbc->getUInt32(i, 0); + if (!kitId) continue; + // Prefer SpecialEffect0, fall back to BaseEffect + uint32_t eff = 0; + if (kitSpecial0Field < fc) eff = kitDbc->getUInt32(i, kitSpecial0Field); + if (!eff && kitBaseField < fc) eff = kitDbc->getUInt32(i, kitBaseField); + if (eff) kitToEffectName[kitId] = eff; + } + } + + // Helper: resolve path for a given kit ID + auto kitPath = [&](uint32_t kitId) -> std::string { + if (!kitId) return {}; + auto kitIt = kitToEffectName.find(kitId); + if (kitIt == kitToEffectName.end()) return {}; + auto fxIt = effectPaths.find(kitIt->second); + return (fxIt != effectPaths.end()) ? fxIt->second : std::string{}; + }; + auto missilePath = [&](uint32_t effId) -> std::string { + if (!effId) return {}; + auto fxIt = effectPaths.find(effId); + return (fxIt != effectPaths.end()) ? fxIt->second : std::string{}; + }; + + // Load SpellVisual.dbc — visualId → cast/impact M2 paths via kit chain + auto svDbc = cachedAssetManager->loadDBC("SpellVisual.dbc"); + if (!svDbc || !svDbc->isLoaded()) { + LOG_DEBUG("SpellVisual: SpellVisual.dbc unavailable"); + return; + } + uint32_t svFc = svDbc->getFieldCount(); + uint32_t loadedCast = 0, loadedImpact = 0; + for (uint32_t i = 0; i < svDbc->getRecordCount(); ++i) { + uint32_t vid = svDbc->getUInt32(i, 0); + if (!vid) continue; + + // Cast path: CastKit → SpecialEffect0/BaseEffect, fallback to MissileModel + { + std::string path; + if (svCastKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svCastKitField)); + if (path.empty() && svMissileField < svFc) + path = missilePath(svDbc->getUInt32(i, svMissileField)); + if (!path.empty()) { spellVisualCastPath_[vid] = path; ++loadedCast; } + } + // Impact path: ImpactKit → SpecialEffect0/BaseEffect, fallback to MissileModel + { + std::string path; + if (svImpactKitField < svFc) + path = kitPath(svDbc->getUInt32(i, svImpactKitField)); + if (path.empty() && svMissileField < svFc) + path = missilePath(svDbc->getUInt32(i, svMissileField)); + if (!path.empty()) { spellVisualImpactPath_[vid] = path; ++loadedImpact; } + } + } + LOG_INFO("SpellVisual: loaded cast=", loadedCast, " impact=", loadedImpact, + " visual→M2 mappings (of ", svDbc->getRecordCount(), " records)"); +} + +void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, + bool useImpactKit) { + if (!m2Renderer || visualId == 0) return; + + if (!cachedAssetManager) + cachedAssetManager = core::Application::getInstance().getAssetManager(); + if (!cachedAssetManager) return; + + if (!spellVisualDbcLoaded_) loadSpellVisualDbc(); + + // Select cast or impact path map + auto& pathMap = useImpactKit ? spellVisualImpactPath_ : spellVisualCastPath_; + auto pathIt = pathMap.find(visualId); + if (pathIt == pathMap.end()) return; // No model for this visual + + const std::string& modelPath = pathIt->second; + + // Get or assign a model ID for this path + auto midIt = spellVisualModelIds_.find(modelPath); + uint32_t modelId = 0; + if (midIt != spellVisualModelIds_.end()) { + modelId = midIt->second; + } else { + if (nextSpellVisualModelId_ >= 999800) { + LOG_WARNING("SpellVisual: model ID pool exhausted"); + return; + } + modelId = nextSpellVisualModelId_++; + spellVisualModelIds_[modelPath] = modelId; + } + + // Skip models that have previously failed to load (avoid repeated I/O) + if (spellVisualFailedModels_.count(modelId)) return; + + // Load the M2 model if not already loaded + if (!m2Renderer->hasModel(modelId)) { + auto m2Data = cachedAssetManager->readFile(modelPath); + if (m2Data.empty()) { + LOG_DEBUG("SpellVisual: could not read model: ", modelPath); + spellVisualFailedModels_.insert(modelId); + return; + } + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty() && model.particleEmitters.empty()) { + LOG_DEBUG("SpellVisual: empty model: ", modelPath); + spellVisualFailedModels_.insert(modelId); + return; + } + // Load skin file for WotLK-format M2s + if (model.version >= 264) { + std::string skinPath = modelPath.substr(0, modelPath.rfind('.')) + "00.skin"; + auto skinData = cachedAssetManager->readFile(skinPath); + if (!skinData.empty()) pipeline::M2Loader::loadSkin(skinData, model); + } + if (!m2Renderer->loadModel(model, modelId)) { + LOG_WARNING("SpellVisual: failed to load model to GPU: ", modelPath); + spellVisualFailedModels_.insert(modelId); + return; + } + LOG_DEBUG("SpellVisual: loaded model id=", modelId, " path=", modelPath); + } + + // Spawn instance at world position + uint32_t instanceId = m2Renderer->createInstance(modelId, worldPosition, + glm::vec3(0.0f), 1.0f); + if (instanceId == 0) { + LOG_WARNING("SpellVisual: failed to create instance for visualId=", visualId); + return; + } + // Determine lifetime from M2 animation duration (clamp to reasonable range) + float animDurMs = m2Renderer->getInstanceAnimDuration(instanceId); + float duration = (animDurMs > 100.0f) + ? std::clamp(animDurMs / 1000.0f, 0.5f, SPELL_VISUAL_MAX_DURATION) + : SPELL_VISUAL_DEFAULT_DURATION; + activeSpellVisuals_.push_back({instanceId, 0.0f, duration}); + LOG_DEBUG("SpellVisual: spawned visualId=", visualId, " instanceId=", instanceId, + " duration=", duration, "s model=", modelPath); +} + +void Renderer::updateSpellVisuals(float deltaTime) { + if (activeSpellVisuals_.empty() || !m2Renderer) return; + for (auto it = activeSpellVisuals_.begin(); it != activeSpellVisuals_.end(); ) { + it->elapsed += deltaTime; + if (it->elapsed >= it->duration) { + m2Renderer->removeInstance(it->instanceId); + it = activeSpellVisuals_.erase(it); + } else { + ++it; + } + } +} + void Renderer::triggerMeleeSwing() { if (!characterRenderer || characterInstanceId == 0) return; if (meleeSwingCooldown > 0.0f) return; @@ -2556,6 +3012,21 @@ void Renderer::setTargetPosition(const glm::vec3* pos) { targetPosition = pos; } +void Renderer::resetCombatVisualState() { + inCombat_ = false; + targetPosition = nullptr; + meleeSwingTimer = 0.0f; + meleeSwingCooldown = 0.0f; + // Clear lingering spell visual instances from the previous map/combat session. + // Without this, old effects could remain visible after teleport or map change. + for (auto& sv : activeSpellVisuals_) { + if (m2Renderer) m2Renderer->removeInstance(sv.instanceId); + } + activeSpellVisuals_.clear(); + // Reset the negative cache so models that failed during asset loading can retry. + spellVisualFailedModels_.clear(); +} + bool Renderer::isMoving() const { return cameraController && cameraController->isMoving(); } @@ -2570,8 +3041,13 @@ bool Renderer::shouldTriggerFootstepEvent(uint32_t animationId, float animationT return false; } - float norm = std::fmod(animationTimeMs, animationDurationMs) / animationDurationMs; - if (norm < 0.0f) norm += 1.0f; + // Wrap animation time preserving precision via subtraction instead of fmod + float wrappedTime = animationTimeMs; + while (wrappedTime >= animationDurationMs) { + wrappedTime -= animationDurationMs; + } + if (wrappedTime < 0.0f) wrappedTime += animationDurationMs; + float norm = wrappedTime / animationDurationMs; if (animationId != footstepLastAnimationId) { footstepLastAnimationId = animationId; @@ -2716,6 +3192,7 @@ void Renderer::update(float deltaTime) { // Server-driven weather (SMSG_WEATHER) — authoritative if (wType == 1) weather->setWeatherType(Weather::Type::RAIN); else if (wType == 2) weather->setWeatherType(Weather::Type::SNOW); + else if (wType == 3) weather->setWeatherType(Weather::Type::STORM); else weather->setWeatherType(Weather::Type::NONE); weather->setIntensity(wInt); } else { @@ -2723,6 +3200,11 @@ void Renderer::update(float deltaTime) { weather->updateZoneWeather(currentZoneId, deltaTime); } weather->setEnabled(true); + + // Lightning flash disabled + if (lightning) { + lightning->setEnabled(false); + } } else if (weather) { // No game handler (single-player without network) — zone weather only weather->updateZoneWeather(currentZoneId, deltaTime); @@ -2792,6 +3274,11 @@ void Renderer::update(float deltaTime) { weather->update(*camera, deltaTime); } + // Update lightning (storm / heavy rain) + if (lightning && camera && lightning->isEnabled()) { + lightning->update(deltaTime, *camera); + } + // Update swim effects if (swimEffects && camera && cameraController && waterRenderer) { swimEffects->update(*camera, *cameraController, *waterRenderer, deltaTime); @@ -2827,6 +3314,8 @@ void Renderer::update(float deltaTime) { if (chargeEffect) { chargeEffect->update(deltaTime); } + // Update transient spell visual instances + updateSpellVisuals(deltaTime); // Launch M2 doodad animation on background thread (overlaps with character animation + audio) @@ -2865,8 +3354,13 @@ void Renderer::update(float deltaTime) { float animTimeMs = 0.0f, animDurationMs = 0.0f; if (characterRenderer->getAnimationState(mountInstanceId_, animId, animTimeMs, animDurationMs) && animDurationMs > 1.0f && cameraController->isMoving()) { - float norm = std::fmod(animTimeMs, animDurationMs) / animDurationMs; - if (norm < 0.0f) norm += 1.0f; + // Wrap animation time preserving precision via subtraction instead of fmod + float wrappedTime = animTimeMs; + while (wrappedTime >= animDurationMs) { + wrappedTime -= animDurationMs; + } + if (wrappedTime < 0.0f) wrappedTime += animDurationMs; + float norm = wrappedTime / animDurationMs; if (animId != mountFootstepLastAnimId) { mountFootstepLastAnimId = animId; @@ -2990,6 +3484,7 @@ void Renderer::update(float deltaTime) { uint32_t insideWmoId = 0; const bool insideWmo = canQueryWmo && wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &insideWmoId); + playerIndoors_ = insideWmo; // Ambient environmental sounds: fireplaces, water, birds, etc. if (ambientSoundManager && camera && wmoRenderer && cameraController) { @@ -3720,7 +4215,13 @@ void Renderer::setFSREnabled(bool enabled) { if (fsr_.enabled == enabled) return; fsr_.enabled = enabled; - if (!enabled) { + if (enabled) { + // FSR1 upscaling renders its own AA — disable MSAA to avoid redundant work + if (vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) { + pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + msaaChangePending_ = true; + } + } else { // Defer destruction to next beginFrame() — can't destroy mid-render fsr_.needsRecreate = true; } @@ -4673,6 +5174,259 @@ void Renderer::setAmdFsr3FramegenEnabled(bool enabled) { // ========================= End FSR 2.2 ========================= +// ========================= FXAA Post-Process ========================= + +bool Renderer::initFXAAResources() { + if (!vkCtx) return false; + + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + VkExtent2D ext = vkCtx->getSwapchainExtent(); + VkSampleCountFlagBits msaa = vkCtx->getMsaaSamples(); + bool useMsaa = (msaa > VK_SAMPLE_COUNT_1_BIT); + bool useDepthResolve = (vkCtx->getDepthResolveImageView() != VK_NULL_HANDLE); + + LOG_INFO("FXAA: initializing at ", ext.width, "x", ext.height, + " (MSAA=", static_cast(msaa), "x)"); + + VkFormat colorFmt = vkCtx->getSwapchainFormat(); + VkFormat depthFmt = vkCtx->getDepthFormat(); + + // sceneColor: 1x resolved color target — FXAA reads from here + fxaa_.sceneColor = createImage(device, alloc, ext.width, ext.height, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + if (!fxaa_.sceneColor.image) { + LOG_ERROR("FXAA: failed to create scene color image"); + return false; + } + + // sceneDepth: depth buffer at current MSAA sample count + fxaa_.sceneDepth = createImage(device, alloc, ext.width, ext.height, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT, msaa); + if (!fxaa_.sceneDepth.image) { + LOG_ERROR("FXAA: failed to create scene depth image"); + destroyFXAAResources(); + return false; + } + + if (useMsaa) { + fxaa_.sceneMsaaColor = createImage(device, alloc, ext.width, ext.height, + colorFmt, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT, msaa); + if (!fxaa_.sceneMsaaColor.image) { + LOG_ERROR("FXAA: failed to create MSAA color image"); + destroyFXAAResources(); + return false; + } + if (useDepthResolve) { + fxaa_.sceneDepthResolve = createImage(device, alloc, ext.width, ext.height, + depthFmt, VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT); + if (!fxaa_.sceneDepthResolve.image) { + LOG_ERROR("FXAA: failed to create depth resolve image"); + destroyFXAAResources(); + return false; + } + } + } + + // Framebuffer — same attachment layout as main render pass + VkImageView fbAttachments[4]{}; + uint32_t fbCount; + if (useMsaa) { + fbAttachments[0] = fxaa_.sceneMsaaColor.imageView; + fbAttachments[1] = fxaa_.sceneDepth.imageView; + fbAttachments[2] = fxaa_.sceneColor.imageView; // resolve target + fbCount = 3; + if (useDepthResolve) { + fbAttachments[3] = fxaa_.sceneDepthResolve.imageView; + fbCount = 4; + } + } else { + fbAttachments[0] = fxaa_.sceneColor.imageView; + fbAttachments[1] = fxaa_.sceneDepth.imageView; + fbCount = 2; + } + + VkFramebufferCreateInfo fbInfo{}; + fbInfo.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + fbInfo.renderPass = vkCtx->getImGuiRenderPass(); + fbInfo.attachmentCount = fbCount; + fbInfo.pAttachments = fbAttachments; + fbInfo.width = ext.width; + fbInfo.height = ext.height; + fbInfo.layers = 1; + if (vkCreateFramebuffer(device, &fbInfo, nullptr, &fxaa_.sceneFramebuffer) != VK_SUCCESS) { + LOG_ERROR("FXAA: failed to create scene framebuffer"); + destroyFXAAResources(); + return false; + } + + // Sampler + VkSamplerCreateInfo samplerInfo{}; + samplerInfo.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + samplerInfo.minFilter = VK_FILTER_LINEAR; + samplerInfo.magFilter = VK_FILTER_LINEAR; + samplerInfo.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + samplerInfo.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + if (vkCreateSampler(device, &samplerInfo, nullptr, &fxaa_.sceneSampler) != VK_SUCCESS) { + LOG_ERROR("FXAA: failed to create sampler"); + destroyFXAAResources(); + return false; + } + + // Descriptor set layout: binding 0 = combined image sampler + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutInfo{}; + layoutInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutInfo.bindingCount = 1; + layoutInfo.pBindings = &binding; + vkCreateDescriptorSetLayout(device, &layoutInfo, nullptr, &fxaa_.descSetLayout); + + VkDescriptorPoolSize poolSize{}; + poolSize.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSize.descriptorCount = 1; + VkDescriptorPoolCreateInfo poolInfo{}; + poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.maxSets = 1; + poolInfo.poolSizeCount = 1; + poolInfo.pPoolSizes = &poolSize; + vkCreateDescriptorPool(device, &poolInfo, nullptr, &fxaa_.descPool); + + VkDescriptorSetAllocateInfo dsAllocInfo{}; + dsAllocInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + dsAllocInfo.descriptorPool = fxaa_.descPool; + dsAllocInfo.descriptorSetCount = 1; + dsAllocInfo.pSetLayouts = &fxaa_.descSetLayout; + vkAllocateDescriptorSets(device, &dsAllocInfo, &fxaa_.descSet); + + // Bind the resolved 1x sceneColor + VkDescriptorImageInfo imgInfo{}; + imgInfo.sampler = fxaa_.sceneSampler; + imgInfo.imageView = fxaa_.sceneColor.imageView; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkWriteDescriptorSet write{}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = fxaa_.descSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + + // Pipeline layout — push constant holds vec4(rcpFrame.xy, sharpness, pad) + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 16; // vec4 + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.setLayoutCount = 1; + plCI.pSetLayouts = &fxaa_.descSetLayout; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &fxaa_.pipelineLayout); + + // FXAA pipeline — fullscreen triangle into the swapchain render pass + // Uses VK_SAMPLE_COUNT_1_BIT: it always runs after MSAA resolve. + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/fxaa.frag.spv")) { + LOG_ERROR("FXAA: failed to load shaders"); + destroyFXAAResources(); + return false; + } + + fxaa_.pipeline = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendDisabled()) + .setMultisample(VK_SAMPLE_COUNT_1_BIT) // swapchain pass is always 1x + .setLayout(fxaa_.pipelineLayout) + .setRenderPass(vkCtx->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + + vertMod.destroy(); + fragMod.destroy(); + + if (!fxaa_.pipeline) { + LOG_ERROR("FXAA: failed to create pipeline"); + destroyFXAAResources(); + return false; + } + + LOG_INFO("FXAA: initialized successfully"); + return true; +} + +void Renderer::destroyFXAAResources() { + if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); + VmaAllocator alloc = vkCtx->getAllocator(); + vkDeviceWaitIdle(device); + + if (fxaa_.pipeline) { vkDestroyPipeline(device, fxaa_.pipeline, nullptr); fxaa_.pipeline = VK_NULL_HANDLE; } + if (fxaa_.pipelineLayout) { vkDestroyPipelineLayout(device, fxaa_.pipelineLayout, nullptr); fxaa_.pipelineLayout = VK_NULL_HANDLE; } + if (fxaa_.descPool) { vkDestroyDescriptorPool(device, fxaa_.descPool, nullptr); fxaa_.descPool = VK_NULL_HANDLE; fxaa_.descSet = VK_NULL_HANDLE; } + if (fxaa_.descSetLayout) { vkDestroyDescriptorSetLayout(device, fxaa_.descSetLayout, nullptr); fxaa_.descSetLayout = VK_NULL_HANDLE; } + if (fxaa_.sceneFramebuffer) { vkDestroyFramebuffer(device, fxaa_.sceneFramebuffer, nullptr); fxaa_.sceneFramebuffer = VK_NULL_HANDLE; } + if (fxaa_.sceneSampler) { vkDestroySampler(device, fxaa_.sceneSampler, nullptr); fxaa_.sceneSampler = VK_NULL_HANDLE; } + destroyImage(device, alloc, fxaa_.sceneDepthResolve); + destroyImage(device, alloc, fxaa_.sceneMsaaColor); + destroyImage(device, alloc, fxaa_.sceneDepth); + destroyImage(device, alloc, fxaa_.sceneColor); +} + +void Renderer::renderFXAAPass() { + if (!fxaa_.pipeline || currentCmd == VK_NULL_HANDLE) return; + VkExtent2D ext = vkCtx->getSwapchainExtent(); + + vkCmdBindPipeline(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipeline); + vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); + + // Pass rcpFrame + sharpness + effect flag (vec4, 16 bytes). + // When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the + // post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes. + float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f; + float pc[4] = { + 1.0f / static_cast(ext.width), + 1.0f / static_cast(ext.height), + sharpness, + 0.0f + }; + vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc); + + vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle +} + +void Renderer::setFXAAEnabled(bool enabled) { + if (fxaa_.enabled == enabled) return; + // FXAA is a post-process AA pass intended to supplement FSR temporal output. + // It conflicts with MSAA (which resolves AA during the scene render pass), so + // refuse to enable FXAA when hardware MSAA is active. + if (enabled && vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) { + LOG_INFO("FXAA: blocked while MSAA is active — disable MSAA first"); + return; + } + fxaa_.enabled = enabled; + if (!enabled) { + fxaa_.needsRecreate = true; // defer destruction to next beginFrame() + } +} + +// ========================= End FXAA ========================= + void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { (void)world; @@ -4688,6 +5442,9 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { lastWMORenderMs = 0.0; lastM2RenderMs = 0.0; + // Cache ghost state for use in overlay and FXAA passes this frame. + ghostMode_ = (gameHandler && gameHandler->isPlayerGhost()); + uint32_t frameIdx = vkCtx->getCurrentFrame(); VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx]; const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f); @@ -4737,7 +5494,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { auto t0 = std::chrono::steady_clock::now(); VkCommandBuffer cmd = beginSecondary(SEC_WMO); setSecondaryViewportScissor(cmd); - wmoRenderer->render(cmd, perFrameSet, *camera); + wmoRenderer->render(cmd, perFrameSet, *camera, &characterPosition); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -4752,6 +5509,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(cmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(cmd, perFrameSet); m2Renderer->renderM2Particles(cmd, perFrameSet); + m2Renderer->renderM2Ribbons(cmd, perFrameSet); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -4809,6 +5567,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (waterRenderer && camera) waterRenderer->render(cmd, perFrameSet, *camera, globalTime, false, frameIdx); if (weather && camera) weather->render(cmd, perFrameSet); + if (lightning && camera && lightning->isEnabled()) lightning->render(cmd, perFrameSet); if (swimEffects && camera) swimEffects->render(cmd, perFrameSet); if (mountDust && camera) mountDust->render(cmd, perFrameSet); if (chargeEffect && camera) chargeEffect->render(cmd, perFrameSet); @@ -4833,6 +5592,17 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint, cmd); } } + // Ghost mode desaturation: cold blue-grey overlay when dead/ghost + if (ghostMode_) { + renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f), cmd); + } + // Brightness overlay (applied before minimap so it doesn't affect UI) + if (brightness_ < 0.99f) { + renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - brightness_), cmd); + } else if (brightness_ > 1.01f) { + float alpha = (brightness_ - 1.0f) / 1.0f; // maps 1.0-2.0 → 0.0-1.0 + renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha), cmd); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -4842,10 +5612,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (cameraController) { float facingRad = glm::radians(characterYaw); glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + // atan2(-x,y) = canonical yaw (0=North); negate for shader convention. + minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + // movementInfo.orientation is canonical yaw: 0=North, π/2=East. + // Minimap shader: arrowRotation=0 points up (North), positive rotates CW + // (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw. + minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation; hasMinimapPlayerOrientation = true; } minimap->render(cmd, *camera, minimapCenter, @@ -4905,7 +5679,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (wmoRenderer && camera && !skipWMO) { wmoRenderer->prepareRender(); auto wmoStart = std::chrono::steady_clock::now(); - wmoRenderer->render(currentCmd, perFrameSet, *camera); + wmoRenderer->render(currentCmd, perFrameSet, *camera, &characterPosition); lastWMORenderMs = std::chrono::duration( std::chrono::steady_clock::now() - wmoStart).count(); } @@ -4927,6 +5701,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(currentCmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); m2Renderer->renderM2Particles(currentCmd, perFrameSet); + m2Renderer->renderM2Ribbons(currentCmd, perFrameSet); lastM2RenderMs = std::chrono::duration( std::chrono::steady_clock::now() - m2Start).count(); } @@ -4934,6 +5709,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (waterRenderer && camera) waterRenderer->render(currentCmd, perFrameSet, *camera, globalTime, false, frameIdx); if (weather && camera) weather->render(currentCmd, perFrameSet); + if (lightning && camera && lightning->isEnabled()) lightning->render(currentCmd, perFrameSet); if (swimEffects && camera) swimEffects->render(currentCmd, perFrameSet); if (mountDust && camera) mountDust->render(currentCmd, perFrameSet); if (chargeEffect && camera) chargeEffect->render(currentCmd, perFrameSet); @@ -4961,6 +5737,17 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint); } } + // Ghost mode desaturation: cold blue-grey overlay when dead/ghost + if (ghostMode_) { + renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f)); + } + // Brightness overlay (applied before minimap so it doesn't affect UI) + if (brightness_ < 0.99f) { + renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - brightness_)); + } else if (brightness_ > 1.01f) { + float alpha = (brightness_ - 1.0f) / 1.0f; + renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha)); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -4970,10 +5757,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (cameraController) { float facingRad = glm::radians(characterYaw); glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + // atan2(-x,y) = canonical yaw (0=North); negate for shader convention. + minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + // movementInfo.orientation is canonical yaw: 0=North, π/2=East. + // Minimap shader: arrowRotation=0 points up (North), positive rotates CW + // (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw. + minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation; hasMinimapPlayerOrientation = true; } minimap->render(currentCmd, *camera, minimapCenter, diff --git a/src/rendering/sky_system.cpp b/src/rendering/sky_system.cpp index 9509cdc2..98e27621 100644 --- a/src/rendering/sky_system.cpp +++ b/src/rendering/sky_system.cpp @@ -135,6 +135,14 @@ void SkySystem::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, // --- Clouds (DBC-driven colors + sun lighting) --- if (clouds_) { + // Sync cloud density with weather/DBC-driven cloud coverage. + // Active weather (rain/snow/storm) increases cloud density for visual consistency. + float effectiveDensity = params.cloudDensity; + if (params.weatherIntensity > 0.05f) { + float weatherBoost = params.weatherIntensity * 0.4f; // storms add up to 0.4 density + effectiveDensity = glm::min(1.0f, effectiveDensity + weatherBoost); + } + clouds_->setDensity(effectiveDensity); clouds_->render(cmd, perFrameSet, params); } diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 579a909a..ba929d7c 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -54,9 +54,11 @@ int computeTerrainWorkerCount() { unsigned hc = std::thread::hardware_concurrency(); if (hc > 0) { - // Use most cores for loading — leave 1-2 for render/update threads. - const unsigned reserved = (hc >= 8u) ? 2u : 1u; - const unsigned targetWorkers = std::max(4u, hc - reserved); + // Keep terrain workers conservative by default. Over-subscribing loader + // threads can starve main-thread networking/render updates on large-core CPUs. + const unsigned reserved = (hc >= 16u) ? 4u : ((hc >= 8u) ? 2u : 1u); + const unsigned maxDefaultWorkers = 8u; + const unsigned targetWorkers = std::max(4u, std::min(maxDefaultWorkers, hc - reserved)); return static_cast(targetWorkers); } return 4; // Fallback @@ -560,7 +562,17 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { // Pre-load WMO doodads (M2 models inside WMO) if (!workerRunning.load()) return nullptr; - if (!wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) { + + // Skip WMO doodads if this placement was already prepared by another tile's worker. + // This prevents 15+ copies of Stormwind's ~6000 doodads from being parsed + // simultaneously, which was the primary cause of OOM during world load. + bool wmoAlreadyPrepared = false; + if (placement.uniqueId != 0) { + std::lock_guard lock(preparedWmoUniqueIdsMutex_); + wmoAlreadyPrepared = !preparedWmoUniqueIds_.insert(placement.uniqueId).second; + } + + if (!wmoAlreadyPrepared && !wmoModel.doodadSets.empty() && !wmoModel.doodads.empty()) { glm::mat4 wmoMatrix(1.0f); wmoMatrix = glm::translate(wmoMatrix, pos); wmoMatrix = glm::rotate(wmoMatrix, rot.z, glm::vec3(0, 0, 1)); @@ -573,6 +585,7 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { setsToLoad.push_back(placement.doodadSet); } std::unordered_set loadedDoodadIndices; + std::unordered_set wmoPreparedModelIds; // within-WMO model dedup for (uint32_t setIdx : setsToLoad) { const auto& doodadSet = wmoModel.doodadSets[setIdx]; for (uint32_t di = 0; di < doodadSet.count; di++) { @@ -597,15 +610,16 @@ std::shared_ptr TerrainManager::prepareTile(int x, int y) { uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); - // Skip file I/O if model already uploaded from a previous tile + // Skip file I/O if model already uploaded or already prepared within this WMO bool modelAlreadyUploaded = false; { std::lock_guard lock(uploadedM2IdsMutex_); modelAlreadyUploaded = uploadedM2Ids_.count(doodadModelId) > 0; } + bool modelAlreadyPreparedInWmo = !wmoPreparedModelIds.insert(doodadModelId).second; pipeline::M2Model m2Model; - if (!modelAlreadyUploaded) { + if (!modelAlreadyUploaded && !modelAlreadyPreparedInWmo) { std::vector m2Data = assetManager->readFile(m2Path); if (m2Data.empty()) continue; @@ -896,6 +910,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) { continue; } + if (!m2Renderer->hasModel(p.modelId)) { + continue; + } uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale); if (instId) { ft.m2InstanceIds.push_back(instId); @@ -961,6 +978,9 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { continue; } + if (!wmoRenderer->isModelLoaded(wmoReady.modelId)) { + continue; + } uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation); if (wmoInstId) { ft.wmoInstanceIds.push_back(wmoInstId); @@ -1377,6 +1397,10 @@ void TerrainManager::unloadTile(int x, int y) { // Water may have already been loaded in TERRAIN phase, so clean it up. for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) { if (fit->pending && fit->pending->coord == coord) { + // If terrain chunks were already uploaded, free their descriptor sets + if (fit->terrainMeshDone && terrainRenderer) { + terrainRenderer->removeTile(x, y); + } // If past TERRAIN phase, water was already loaded — remove it if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) { waterRenderer->removeTile(x, y); @@ -1392,7 +1416,11 @@ void TerrainManager::unloadTile(int x, int y) { wmoRenderer->removeInstances(fit->wmoInstanceIds); } for (uint32_t uid : fit->tileUniqueIds) placedDoodadIds.erase(uid); - for (uint32_t uid : fit->tileWmoUniqueIds) placedWmoIds.erase(uid); + for (uint32_t uid : fit->tileWmoUniqueIds) { + placedWmoIds.erase(uid); + std::lock_guard lock(preparedWmoUniqueIdsMutex_); + preparedWmoUniqueIds_.erase(uid); + } finalizingTiles_.erase(fit); return; } @@ -1413,6 +1441,8 @@ void TerrainManager::unloadTile(int x, int y) { } for (uint32_t uid : tile->wmoUniqueIds) { placedWmoIds.erase(uid); + std::lock_guard lock(preparedWmoUniqueIdsMutex_); + preparedWmoUniqueIds_.erase(uid); } // Remove M2 doodad instances @@ -1497,6 +1527,10 @@ void TerrainManager::unloadAll() { std::lock_guard lock(uploadedM2IdsMutex_); uploadedM2Ids_.clear(); } + { + std::lock_guard lock(preparedWmoUniqueIdsMutex_); + preparedWmoUniqueIds_.clear(); + } LOG_INFO("Unloading all terrain tiles"); loadedTiles.clear(); @@ -1549,6 +1583,10 @@ void TerrainManager::softReset() { std::lock_guard lock(uploadedM2IdsMutex_); uploadedM2Ids_.clear(); } + { + std::lock_guard lock(preparedWmoUniqueIdsMutex_); + preparedWmoUniqueIds_.clear(); + } // Clear tile cache — keys are (x,y) without map name, so stale entries from // a different map with overlapping coordinates would produce wrong geometry. diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 4e8593f5..775881d3 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -20,17 +20,6 @@ namespace wowee { namespace rendering { -namespace { -size_t envSizeMBOrDefault(const char* name, size_t defMb) { - const char* raw = std::getenv(name); - if (!raw || !*raw) return defMb; - char* end = nullptr; - unsigned long long mb = std::strtoull(raw, &end, 10); - if (end == raw || mb == 0) return defMb; - return static_cast(mb); -} -} // namespace - // Matches set 1 binding 7 in terrain.frag.glsl struct TerrainParamsUBO { int32_t layerCount; @@ -89,6 +78,7 @@ bool TerrainRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameL VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_MATERIAL_SETS; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; @@ -406,9 +396,13 @@ bool TerrainRenderer::loadTerrain(const pipeline::TerrainMesh& mesh, // Allocate and write material descriptor set gpuChunk.materialSet = allocateMaterialSet(); - if (gpuChunk.materialSet) { - writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); + if (!gpuChunk.materialSet) { + // Pool exhaustion can happen transiently while tile churn is high. + // Drop this chunk instead of retaining non-renderable GPU resources. + destroyChunkGPU(gpuChunk); + continue; } + writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); chunks.push_back(std::move(gpuChunk)); } @@ -497,9 +491,12 @@ bool TerrainRenderer::loadTerrainIncremental(const pipeline::TerrainMesh& mesh, } gpuChunk.materialSet = allocateMaterialSet(); - if (gpuChunk.materialSet) { - writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); + if (!gpuChunk.materialSet) { + // Keep memory/work bounded under descriptor pool pressure. + destroyChunkGPU(gpuChunk); + continue; } + writeMaterialDescriptors(gpuChunk.materialSet, gpuChunk); chunks.push_back(std::move(gpuChunk)); uploaded++; @@ -663,7 +660,11 @@ VkDescriptorSet TerrainRenderer::allocateMaterialSet() { VkDescriptorSet set = VK_NULL_HANDLE; if (vkAllocateDescriptorSets(vkCtx->getDevice(), &allocInfo, &set) != VK_SUCCESS) { - LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set"); + static uint64_t failCount = 0; + ++failCount; + if (failCount <= 8 || (failCount % 256) == 0) { + LOG_WARNING("TerrainRenderer: failed to allocate material descriptor set (count=", failCount, ")"); + } return VK_NULL_HANDLE; } return set; @@ -798,15 +799,6 @@ bool TerrainRenderer::initializeShadow(VkRenderPass shadowRenderPass) { VmaAllocator allocator = vkCtx->getAllocator(); // ShadowParams UBO — terrain uses no bones, no texture, no alpha test - struct ShadowParamsUBO { - int32_t useBones = 0; - int32_t useTexture = 0; - int32_t alphaTest = 0; - int32_t foliageSway = 0; - float windTime = 0.0f; - float foliageMotionDamp = 1.0f; - }; - VkBufferCreateInfo bufCI{}; bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; bufCI.size = sizeof(ShadowParamsUBO); @@ -964,7 +956,6 @@ void TerrainRenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSp // Identity model matrix — terrain vertices are already in world space static const glm::mat4 identity(1.0f); - struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; ShadowPush push{ lightSpaceMatrix, identity }; vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, 128, &push); @@ -1010,38 +1001,64 @@ void TerrainRenderer::clear() { } chunks.clear(); renderedChunks = 0; - - if (materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { + if (!vkCtx) return; + + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (chunk.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.vertexBuffer; ab.allocation = chunk.vertexAlloc; - destroyBuffer(allocator, ab); - chunk.vertexBuffer = VK_NULL_HANDLE; - } - if (chunk.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = chunk.indexBuffer; ab.allocation = chunk.indexAlloc; - destroyBuffer(allocator, ab); - chunk.indexBuffer = VK_NULL_HANDLE; - } - if (chunk.paramsUBO) { - AllocatedBuffer ab{}; ab.buffer = chunk.paramsUBO; ab.allocation = chunk.paramsAlloc; - destroyBuffer(allocator, ab); - chunk.paramsUBO = VK_NULL_HANDLE; - } - chunk.materialSet = VK_NULL_HANDLE; + // These resources may still be referenced by in-flight command buffers from + // previous frames. Defer actual destruction until this frame slot is safe. + ::VkBuffer vertexBuffer = chunk.vertexBuffer; + VmaAllocation vertexAlloc = chunk.vertexAlloc; + ::VkBuffer indexBuffer = chunk.indexBuffer; + VmaAllocation indexAlloc = chunk.indexAlloc; + ::VkBuffer paramsUBO = chunk.paramsUBO; + VmaAllocation paramsAlloc = chunk.paramsAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = chunk.materialSet; - // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) - VkDevice device = vkCtx->getDevice(); + std::vector alphaTextures; + alphaTextures.reserve(chunk.ownedAlphaTextures.size()); for (auto& tex : chunk.ownedAlphaTextures) { - if (tex) tex->destroy(device, allocator); + alphaTextures.push_back(tex.release()); } + + chunk.vertexBuffer = VK_NULL_HANDLE; + chunk.vertexAlloc = VK_NULL_HANDLE; + chunk.indexBuffer = VK_NULL_HANDLE; + chunk.indexAlloc = VK_NULL_HANDLE; + chunk.paramsUBO = VK_NULL_HANDLE; + chunk.paramsAlloc = VK_NULL_HANDLE; + chunk.materialSet = VK_NULL_HANDLE; chunk.ownedAlphaTextures.clear(); + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + paramsUBO, paramsAlloc, pool, materialSet, alphaTextures]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (paramsUBO) { + AllocatedBuffer ab{}; ab.buffer = paramsUBO; ab.allocation = paramsAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + for (VkTexture* tex : alphaTextures) { + if (!tex) continue; + tex->destroy(device, allocator); + delete tex; + } + }); } int TerrainRenderer::getTriangleCount() const { diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index dc4144fa..51781a3c 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -55,6 +55,11 @@ void VkContext::shutdown() { vkDeviceWaitIdle(device); } + // With the device idle, it is safe to run any deferred per-frame cleanup. + for (uint32_t fi = 0; fi < MAX_FRAMES_IN_FLIGHT; fi++) { + runDeferredCleanup(fi); + } + LOG_WARNING("VkContext::shutdown - destroyImGuiResources..."); destroyImGuiResources(); @@ -103,6 +108,19 @@ void VkContext::shutdown() { LOG_WARNING("Vulkan context shutdown complete"); } +void VkContext::deferAfterFrameFence(std::function&& fn) { + deferredCleanup_[currentFrame].push_back(std::move(fn)); +} + +void VkContext::runDeferredCleanup(uint32_t frameIndex) { + auto& q = deferredCleanup_[frameIndex]; + if (q.empty()) return; + for (auto& fn : q) { + if (fn) fn(); + } + q.clear(); +} + bool VkContext::createInstance(SDL_Window* window) { // Get required SDL extensions unsigned int sdlExtCount = 0; @@ -1051,14 +1069,21 @@ bool VkContext::recreateSwapchain(int width, int height) { auto swapRet = builder.build(); - if (oldSwapchain) { - vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + if (!swapRet) { + // Destroy old swapchain now that we failed (it can't be used either) + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + swapchain = VK_NULL_HANDLE; + } + LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); + // Keep swapchainDirty=true so the next frame retries + swapchainDirty = true; + return false; } - if (!swapRet) { - LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); - swapchain = VK_NULL_HANDLE; - return false; + // Success — safe to retire the old swapchain + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); } auto vkbSwap = swapRet.value(); @@ -1322,6 +1347,7 @@ bool VkContext::recreateSwapchain(int width, int height) { VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { if (deviceLost_) return VK_NULL_HANDLE; + if (swapchain == VK_NULL_HANDLE) return VK_NULL_HANDLE; // Swapchain lost; recreate pending auto& frame = frames[currentFrame]; @@ -1341,6 +1367,9 @@ VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { return VK_NULL_HANDLE; } + // Any work queued for this frame slot is now guaranteed to be unused by the GPU. + runDeferredCleanup(currentFrame); + // Acquire next swapchain image VkResult result = vkAcquireNextImageKHR(device, swapchain, UINT64_MAX, frame.imageAvailableSemaphore, VK_NULL_HANDLE, &imageIndex); diff --git a/src/rendering/water_renderer.cpp b/src/rendering/water_renderer.cpp index d79e53f7..6dd0b26f 100644 --- a/src/rendering/water_renderer.cpp +++ b/src/rendering/water_renderer.cpp @@ -1029,10 +1029,6 @@ void WaterRenderer::clear() { destroyWaterMesh(surface); } surfaces.clear(); - - if (vkCtx && materialDescPool) { - vkResetDescriptorPool(vkCtx->getDevice(), materialDescPool, 0); - } } // ============================================================== @@ -1146,10 +1142,14 @@ void WaterRenderer::captureSceneHistory(VkCommandBuffer cmd, }; // Color source: final render pass layout is PRESENT_SRC. + // srcAccessMask must be COLOR_ATTACHMENT_WRITE (not 0) so that GPU cache flushes + // happen before the transfer read. Using srcAccessMask=0 with BOTTOM_OF_PIPE + // causes VK_ERROR_DEVICE_LOST on strict drivers (AMD/Mali) because color writes + // are not made visible to the transfer unit before the copy begins. barrier2(srcColorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_PRESENT_SRC_KHR, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, - 0, VK_ACCESS_TRANSFER_READ_BIT, - VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT); barrier2(sh.colorImage, VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT, @@ -1358,27 +1358,45 @@ void WaterRenderer::createWaterMesh(WaterSurface& surface) { void WaterRenderer::destroyWaterMesh(WaterSurface& surface) { if (!vkCtx) return; + VkDevice device = vkCtx->getDevice(); VmaAllocator allocator = vkCtx->getAllocator(); - if (surface.vertexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.vertexBuffer; ab.allocation = surface.vertexAlloc; - destroyBuffer(allocator, ab); - surface.vertexBuffer = VK_NULL_HANDLE; - } - if (surface.indexBuffer) { - AllocatedBuffer ab{}; ab.buffer = surface.indexBuffer; ab.allocation = surface.indexAlloc; - destroyBuffer(allocator, ab); - surface.indexBuffer = VK_NULL_HANDLE; - } - if (surface.materialUBO) { - AllocatedBuffer ab{}; ab.buffer = surface.materialUBO; ab.allocation = surface.materialAlloc; - destroyBuffer(allocator, ab); - surface.materialUBO = VK_NULL_HANDLE; - } - if (surface.materialSet && materialDescPool) { - vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &surface.materialSet); - } + ::VkBuffer vertexBuffer = surface.vertexBuffer; + VmaAllocation vertexAlloc = surface.vertexAlloc; + ::VkBuffer indexBuffer = surface.indexBuffer; + VmaAllocation indexAlloc = surface.indexAlloc; + ::VkBuffer materialUBO = surface.materialUBO; + VmaAllocation materialAlloc = surface.materialAlloc; + VkDescriptorPool pool = materialDescPool; + VkDescriptorSet materialSet = surface.materialSet; + + surface.vertexBuffer = VK_NULL_HANDLE; + surface.vertexAlloc = VK_NULL_HANDLE; + surface.indexBuffer = VK_NULL_HANDLE; + surface.indexAlloc = VK_NULL_HANDLE; + surface.materialUBO = VK_NULL_HANDLE; + surface.materialAlloc = VK_NULL_HANDLE; surface.materialSet = VK_NULL_HANDLE; + + vkCtx->deferAfterFrameFence([device, allocator, vertexBuffer, vertexAlloc, indexBuffer, indexAlloc, + materialUBO, materialAlloc, pool, materialSet]() { + if (vertexBuffer) { + AllocatedBuffer ab{}; ab.buffer = vertexBuffer; ab.allocation = vertexAlloc; + destroyBuffer(allocator, ab); + } + if (indexBuffer) { + AllocatedBuffer ab{}; ab.buffer = indexBuffer; ab.allocation = indexAlloc; + destroyBuffer(allocator, ab); + } + if (materialUBO) { + AllocatedBuffer ab{}; ab.buffer = materialUBO; ab.allocation = materialAlloc; + destroyBuffer(allocator, ab); + } + if (materialSet && pool) { + VkDescriptorSet set = materialSet; + vkFreeDescriptorSets(device, pool, 1, &set); + } + }); } // ============================================================== diff --git a/src/rendering/weather.cpp b/src/rendering/weather.cpp index fed604dc..5dc525da 100644 --- a/src/rendering/weather.cpp +++ b/src/rendering/weather.cpp @@ -198,6 +198,10 @@ void Weather::update(const Camera& camera, float deltaTime) { if (weatherType == Type::RAIN) { p.velocity = glm::vec3(0.0f, -50.0f, 0.0f); // Fast downward p.maxLifetime = 5.0f; + } else if (weatherType == Type::STORM) { + // Storm: faster, angled rain with wind + p.velocity = glm::vec3(15.0f, -70.0f, 8.0f); + p.maxLifetime = 3.5f; } else { // SNOW p.velocity = glm::vec3(0.0f, -5.0f, 0.0f); // Slow downward p.maxLifetime = 10.0f; @@ -245,6 +249,12 @@ void Weather::updateParticle(Particle& particle, const Camera& camera, float del particle.velocity.x = windX; particle.velocity.z = windZ; } + // Storm: gusty, turbulent wind with varying direction + if (weatherType == Type::STORM) { + float gust = std::sin(particle.lifetime * 1.5f + particle.position.x * 0.1f) * 5.0f; + particle.velocity.x = 15.0f + gust; + particle.velocity.z = 8.0f + std::cos(particle.lifetime * 2.0f) * 3.0f; + } // Update position particle.position += particle.velocity * deltaTime; @@ -275,6 +285,9 @@ void Weather::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (weatherType == Type::RAIN) { push.particleSize = 3.0f; push.particleColor = glm::vec4(0.7f, 0.8f, 0.9f, 0.6f); + } else if (weatherType == Type::STORM) { + push.particleSize = 3.5f; + push.particleColor = glm::vec4(0.6f, 0.65f, 0.75f, 0.7f); // Darker, more opaque } else { // SNOW push.particleSize = 8.0f; push.particleColor = glm::vec4(1.0f, 1.0f, 1.0f, 0.9f); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 4d52fd76..c15bad3f 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -29,23 +29,6 @@ namespace wowee { namespace rendering { namespace { -size_t envSizeMBOrDefault(const char* name, size_t defMb) { - const char* raw = std::getenv(name); - if (!raw || !*raw) return defMb; - char* end = nullptr; - unsigned long long mb = std::strtoull(raw, &end, 10); - if (end == raw || mb == 0) return defMb; - return static_cast(mb); -} - -size_t envSizeOrDefault(const char* name, size_t defValue) { - const char* raw = std::getenv(name); - if (!raw || !*raw) return defValue; - char* end = nullptr; - unsigned long long v = std::strtoull(raw, &end, 10); - if (end == raw || v == 0) return defValue; - return static_cast(v); -} } // namespace // Thread-local scratch buffers for collision queries (allows concurrent getFloorHeight/checkWallCollision calls) @@ -124,6 +107,7 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_MATERIAL_SETS; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; @@ -323,7 +307,9 @@ void WMORenderer::shutdown() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; // Free white texture and flat normal texture @@ -821,6 +807,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { return true; } +bool WMORenderer::isModelLoaded(uint32_t id) const { + return loadedModels.find(id) != loadedModels.end(); +} + void WMORenderer::unloadModel(uint32_t id) { auto it = loadedModels.find(id); if (it == loadedModels.end()) { @@ -851,7 +841,12 @@ void WMORenderer::cleanupUnusedModels() { } } - // Delete GPU resources and remove from map + // Delete GPU resources and remove from map. + // Ensure all in-flight frames are complete before freeing vertex/index buffers — + // the GPU may still be reading them from the previous frame's command buffer. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } for (uint32_t id : toRemove) { unloadModel(id); } @@ -1094,7 +1089,9 @@ void WMORenderer::clearAll() { textureCacheBytes_ = 0; textureCacheCounter_ = 0; failedTextureCache_.clear(); + failedTextureRetryAt_.clear(); loggedTextureLoadFails_.clear(); + textureLookupSerial_ = 0; textureBudgetRejectWarnings_ = 0; precomputedFloorGrid.clear(); @@ -1356,7 +1353,8 @@ void WMORenderer::prepareRender() { } } -void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { +void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera, + const glm::vec3* viewerPos) { if (!opaquePipeline_ || instances.empty()) { lastDrawCalls = 0; return; @@ -1380,6 +1378,11 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } glm::vec3 camPos = camera.getPosition(); + // For portal culling, use the character/player position when available. + // The 3rd-person camera can orbit outside a WMO while the character is inside, + // causing the portal traversal to start from outside and cull interior groups. + // Passing the actual character position as the viewer fixes this. + glm::vec3 portalViewerPos = viewerPos ? *viewerPos : camPos; bool doPortalCull = portalCulling; bool doDistanceCull = distanceCulling; @@ -1400,7 +1403,7 @@ void WMORenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const bool usePortalCulling = doPortalCull && !model.portals.empty() && !model.portalRefs.empty(); if (usePortalCulling) { std::unordered_set pvgSet; - glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(camPos, 1.0f); + glm::vec4 localCamPos = instance.invModelMatrix * glm::vec4(portalViewerPos, 1.0f); getVisibleGroupsViaPortals(model, glm::vec3(localCamPos), frustum, instance.modelMatrix, pvgSet); portalVisibleGroups.assign(pvgSet.begin(), pvgSet.end()); @@ -1538,16 +1541,6 @@ bool WMORenderer::initializeShadow(VkRenderPass shadowRenderPass) { if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; VkDevice device = vkCtx_->getDevice(); - // ShadowParams UBO: useBones, useTexture, alphaTest, foliageSway, windTime, foliageMotionDamp - struct ShadowParamsUBO { - int32_t useBones = 0; - int32_t useTexture = 0; - int32_t alphaTest = 0; - int32_t foliageSway = 0; - float windTime = 0.0f; - float foliageMotionDamp = 1.0f; - }; - // Create ShadowParams UBO VkBufferCreateInfo bufCI{}; bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; @@ -1708,8 +1701,6 @@ void WMORenderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceM vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, 0, 1, &shadowParamsSet_, 0, nullptr); - struct ShadowPush { glm::mat4 lightSpaceMatrix; glm::mat4 model; }; - // WMO shadow cull uses the ortho half-extent (shadow map coverage) rather than // the proximity radius so that distant buildings whose shadows reach the player // are still rendered into the shadow map. @@ -1940,8 +1931,13 @@ void WMORenderer::destroyGroupGPU(GroupResources& group) { group.indexAlloc = VK_NULL_HANDLE; } - // Destroy material UBOs (descriptor sets are freed when pool is reset/destroyed) + // Destroy material UBOs and free descriptor sets back to pool + VkDevice device = vkCtx_->getDevice(); for (auto& mb : group.mergedBatches) { + if (mb.materialSet) { + vkFreeDescriptorSets(device, materialDescPool_, 1, &mb.materialSet); + mb.materialSet = VK_NULL_HANDLE; + } if (mb.materialUBO) { vmaDestroyBuffer(allocator, mb.materialUBO, mb.materialUBOAlloc); mb.materialUBO = VK_NULL_HANDLE; @@ -1969,40 +1965,27 @@ VkDescriptorSet WMORenderer::allocateMaterialSet() { bool WMORenderer::isGroupVisible(const GroupResources& group, const glm::mat4& modelMatrix, const Camera& camera) const { - // Simple frustum culling using bounding box - // Transform bounding box corners to world space - glm::vec3 corners[8] = { - glm::vec3(group.boundingBoxMin.x, group.boundingBoxMin.y, group.boundingBoxMin.z), - glm::vec3(group.boundingBoxMax.x, group.boundingBoxMin.y, group.boundingBoxMin.z), - glm::vec3(group.boundingBoxMin.x, group.boundingBoxMax.y, group.boundingBoxMin.z), - glm::vec3(group.boundingBoxMax.x, group.boundingBoxMax.y, group.boundingBoxMin.z), - glm::vec3(group.boundingBoxMin.x, group.boundingBoxMin.y, group.boundingBoxMax.z), - glm::vec3(group.boundingBoxMax.x, group.boundingBoxMin.y, group.boundingBoxMax.z), - glm::vec3(group.boundingBoxMin.x, group.boundingBoxMax.y, group.boundingBoxMax.z), - glm::vec3(group.boundingBoxMax.x, group.boundingBoxMax.y, group.boundingBoxMax.z) - }; + // Proper frustum-AABB intersection test for accurate visibility culling + // Transform bounding box min/max to world space + glm::vec3 localMin = group.boundingBoxMin; + glm::vec3 localMax = group.boundingBoxMax; - // Transform corners to world space - for (int i = 0; i < 8; i++) { - glm::vec4 worldPos = modelMatrix * glm::vec4(corners[i], 1.0f); - corners[i] = glm::vec3(worldPos); - } + // Transform min and max to world space + glm::vec4 worldMinH = modelMatrix * glm::vec4(localMin, 1.0f); + glm::vec4 worldMaxH = modelMatrix * glm::vec4(localMax, 1.0f); + glm::vec3 worldMin = glm::vec3(worldMinH); + glm::vec3 worldMax = glm::vec3(worldMaxH); - // Simple check: if all corners are behind camera, cull - // (This is a very basic culling implementation - a full frustum test would be better) - glm::vec3 forward = camera.getForward(); - glm::vec3 camPos = camera.getPosition(); + // Ensure min/max are correct after transformation (handles non-uniform scaling) + glm::vec3 boundsMin = glm::min(worldMin, worldMax); + glm::vec3 boundsMax = glm::max(worldMin, worldMax); - int behindCount = 0; - for (int i = 0; i < 8; i++) { - glm::vec3 toCorner = corners[i] - camPos; - if (glm::dot(toCorner, forward) < 0.0f) { - behindCount++; - } - } + // Extract frustum planes from view-projection matrix + Frustum frustum; + frustum.extractFromMatrix(camera.getViewProjectionMatrix()); - // If all corners are behind camera, cull - return behindCount < 8; + // Test if AABB intersects view frustum + return frustum.intersectsAABB(boundsMin, boundsMax); } int WMORenderer::findContainingGroup(const ModelData& model, const glm::vec3& localPos) const { @@ -2049,12 +2032,25 @@ bool WMORenderer::isPortalVisible(const ModelData& model, uint16_t portalIndex, } center /= static_cast(portal.vertexCount); - // Transform bounds to world space for frustum test - glm::vec4 worldMin = modelMatrix * glm::vec4(pMin, 1.0f); - glm::vec4 worldMax = modelMatrix * glm::vec4(pMax, 1.0f); + // Transform all 8 corners to world space to build the correct world AABB. + // Direct transform of pMin/pMax is wrong for rotated WMOs — the matrix can + // swap or negate components, inverting min/max and causing frustum test failures. + const glm::vec3 corners[8] = { + {pMin.x, pMin.y, pMin.z}, {pMax.x, pMin.y, pMin.z}, + {pMin.x, pMax.y, pMin.z}, {pMax.x, pMax.y, pMin.z}, + {pMin.x, pMin.y, pMax.z}, {pMax.x, pMin.y, pMax.z}, + {pMin.x, pMax.y, pMax.z}, {pMax.x, pMax.y, pMax.z}, + }; + glm::vec3 worldMin( std::numeric_limits::max()); + glm::vec3 worldMax(-std::numeric_limits::max()); + for (const auto& c : corners) { + glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f)); + worldMin = glm::min(worldMin, wc); + worldMax = glm::max(worldMax, wc); + } // Check if portal AABB intersects frustum (more robust than point test) - return frustum.intersectsAABB(glm::vec3(worldMin), glm::vec3(worldMax)); + return frustum.intersectsAABB(worldMin, worldMax); } void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, @@ -2062,6 +2058,9 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, const Frustum& frustum, const glm::mat4& modelMatrix, std::unordered_set& outVisibleGroups) const { + constexpr uint32_t WMO_GROUP_FLAG_OUTDOOR = 0x8; + constexpr uint32_t WMO_GROUP_FLAG_INDOOR = 0x2000; + // Find camera's containing group int cameraGroup = findContainingGroup(model, cameraLocalPos); @@ -2075,6 +2074,33 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, return; } + // Outdoor city WMOs (e.g. Stormwind) often have portal graphs that are valid for + // indoor visibility but too aggressive outdoors, causing direction-dependent popout. + // Only trust portal traversal when the camera is in an interior-only group. + if (cameraGroup < static_cast(model.groups.size())) { + const uint32_t gFlags = model.groups[cameraGroup].groupFlags; + const bool isIndoor = (gFlags & WMO_GROUP_FLAG_INDOOR) != 0; + const bool isOutdoor = (gFlags & WMO_GROUP_FLAG_OUTDOOR) != 0; + if (!isIndoor || isOutdoor) { + for (size_t gi = 0; gi < model.groups.size(); gi++) { + outVisibleGroups.insert(static_cast(gi)); + } + return; + } + } + + // If the camera group has no portal refs, it's a dead-end group (utility/transition group). + // Fall back to showing all groups to avoid the rest of the WMO going invisible. + if (cameraGroup < static_cast(model.groupPortalRefs.size())) { + auto [portalStart, portalCount] = model.groupPortalRefs[cameraGroup]; + if (portalCount == 0) { + for (size_t gi = 0; gi < model.groups.size(); gi++) { + outVisibleGroups.insert(static_cast(gi)); + } + return; + } + } + // BFS through portals from camera's group std::vector visited(model.groups.size(), false); std::vector queue; @@ -2215,6 +2241,7 @@ std::unique_ptr WMORenderer::generateNormalHeightMap( } VkTexture* WMORenderer::loadTexture(const std::string& path) { + constexpr uint64_t kFailedTextureRetryLookups = 512; if (!assetManager || !vkCtx_) { return whiteTexture_.get(); } @@ -2290,7 +2317,19 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } - const auto& attemptedCandidates = uniqueCandidates; + const uint64_t lookupSerial = ++textureLookupSerial_; + std::vector attemptedCandidates; + attemptedCandidates.reserve(uniqueCandidates.size()); + for (const auto& c : uniqueCandidates) { + auto fit = failedTextureRetryAt_.find(c); + if (fit != failedTextureRetryAt_.end() && lookupSerial < fit->second) { + continue; + } + attemptedCandidates.push_back(c); + } + if (attemptedCandidates.empty()) { + return whiteTexture_.get(); + } // Try loading all candidates until one succeeds // Check pre-decoded BLP cache first (populated by background worker threads) @@ -2317,6 +2356,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { } } if (!blp.isValid()) { + for (const auto& c : attemptedCandidates) { + failedTextureCache_.insert(c); + failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups; + } if (loggedTextureLoadFails_.insert(key).second) { core::Logger::getInstance().warning("WMO: Failed to load texture: ", path); } @@ -2331,6 +2374,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; size_t approxBytes = base + (base / 3); if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + for (const auto& c : attemptedCandidates) { + failedTextureCache_.insert(c); + failedTextureRetryAt_[c] = lookupSerial + kFailedTextureRetryLookups; + } if (textureBudgetRejectWarnings_ < 3) { core::Logger::getInstance().warning( "WMO texture cache full (", textureCacheBytes_ / (1024 * 1024), @@ -2372,8 +2419,12 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) { textureCacheBytes_ += e.approxBytes; if (!resolvedKey.empty()) { textureCache[resolvedKey] = std::move(e); + failedTextureCache_.erase(resolvedKey); + failedTextureRetryAt_.erase(resolvedKey); } else { textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); } core::Logger::getInstance().debug("WMO: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); @@ -2496,22 +2547,6 @@ static float pointAABBDistanceSq(const glm::vec3& p, const glm::vec3& bmin, cons return glm::dot(d, d); } -struct QueryTimer { - double* totalMs = nullptr; - uint32_t* callCount = nullptr; - std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now(); - QueryTimer(double* total, uint32_t* calls) : totalMs(total), callCount(calls) {} - ~QueryTimer() { - if (callCount) { - (*callCount)++; - } - if (totalMs) { - auto end = std::chrono::steady_clock::now(); - *totalMs += std::chrono::duration(end - start).count(); - } - } -}; - // Möller–Trumbore ray-triangle intersection // Returns distance along ray if hit, or negative if miss static float rayTriangleIntersect(const glm::vec3& origin, const glm::vec3& dir, @@ -3603,12 +3638,13 @@ void WMORenderer::recreatePipelines() { } // --- Vertex input --- + // WMO vertex: pos3 + normal3 + texCoord2 + color4 + tangent4 = 64 bytes struct WMOVertexData { glm::vec3 position; glm::vec3 normal; glm::vec2 texCoord; glm::vec4 color; - glm::vec4 tangent; + glm::vec4 tangent; // xyz=tangent dir, w=handedness ±1 }; VkVertexInputBindingDescription vertexBinding{}; diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index a1debba9..6b1710c4 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -12,6 +12,7 @@ #include "core/logger.hpp" #include #include +#include #include #include @@ -233,6 +234,10 @@ void WorldMap::setMapName(const std::string& name) { void WorldMap::setServerExplorationMask(const std::vector& masks, bool hasData) { if (!hasData || masks.empty()) { + // New session or no data yet — reset both server mask and local accumulation + if (hasServerExplorationMask) { + locallyExploredZones_.clear(); + } hasServerExplorationMask = false; serverExplorationMask.clear(); return; @@ -366,6 +371,7 @@ void WorldMap::loadZonesFromDBC() { } } + currentMapId_ = mapID; LOG_INFO("WorldMap: loaded ", zones.size(), " zones for mapID=", mapID, ", continentIdx=", continentIdx); } @@ -748,7 +754,6 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { return (serverExplorationMask[word] & (1u << (bitIndex % 32))) != 0; }; - bool markedAny = false; if (hasServerExplorationMask) { exploredZones.clear(); for (int i = 0; i < static_cast(zones.size()); i++) { @@ -757,17 +762,24 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { for (uint32_t bit : z.exploreBits) { if (isBitSet(bit)) { exploredZones.insert(i); - markedAny = true; break; } } } + // Always trust the server mask when available — even if empty (unexplored character). + // Also reveal the zone the player is currently standing in so the map isn't pitch-black + // the moment they first enter a new zone (the server bit arrives on the next update). + int curZone = findZoneForPlayer(playerRenderPos); + if (curZone >= 0) exploredZones.insert(curZone); + return; } - if (markedAny) return; + // Server mask unavailable — fall back to locally-accumulated position tracking. + // Add the zone the player is currently in to the local set and display that. float wowX = playerRenderPos.y; float wowY = playerRenderPos.x; + bool foundPos = false; for (int i = 0; i < static_cast(zones.size()); i++) { const auto& z = zones[i]; if (z.areaID == 0) continue; @@ -775,15 +787,18 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { float minY = std::min(z.locTop, z.locBottom), maxY = std::max(z.locTop, z.locBottom); if (maxX - minX < 0.001f || maxY - minY < 0.001f) continue; if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) { - exploredZones.insert(i); - markedAny = true; + locallyExploredZones_.insert(i); + foundPos = true; } } - if (!markedAny) { + if (!foundPos) { int zoneIdx = findZoneForPlayer(playerRenderPos); - if (zoneIdx >= 0) exploredZones.insert(zoneIdx); + if (zoneIdx >= 0) locallyExploredZones_.insert(zoneIdx); } + + // Display the accumulated local set + exploredZones = locallyExploredZones_; } void WorldMap::zoomIn(const glm::vec3& playerRenderPos) { @@ -821,63 +836,66 @@ void WorldMap::zoomOut() { // Main render (input + ImGui overlay) // -------------------------------------------------------- -void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { +void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, + float playerYawDeg) { if (!initialized || !assetManager) return; auto& input = core::Input::getInstance(); if (!zones.empty()) updateExploration(playerRenderPos); - if (open) { - if (input.isKeyJustPressed(SDL_SCANCODE_M) || - input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { - open = false; - return; + // game_screen owns the open/close toggle (via showWorldMap_ + TOGGLE_WORLD_MAP keybinding). + // render() is only called when showWorldMap_ is true, so treat each call as "should be open". + if (!open) { + // First time shown: load zones and navigate to player's location. + open = true; + if (zones.empty()) loadZonesFromDBC(); + + int bestContinent = findBestContinentForPlayer(playerRenderPos); + if (bestContinent >= 0 && bestContinent != continentIdx) { + continentIdx = bestContinent; + compositedIdx = -1; } + int playerZone = findZoneForPlayer(playerRenderPos); + if (playerZone >= 0 && continentIdx >= 0 && + zoneBelongsToContinent(playerZone, continentIdx)) { + loadZoneTextures(playerZone); + requestComposite(playerZone); + currentIdx = playerZone; + viewLevel = ViewLevel::ZONE; + } else if (continentIdx >= 0) { + loadZoneTextures(continentIdx); + requestComposite(continentIdx); + currentIdx = continentIdx; + viewLevel = ViewLevel::CONTINENT; + } + } + + // ESC closes the map; game_screen will sync showWorldMap_ via wm->isOpen() next frame. + if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { + open = false; + return; + } + + { auto& io = ImGui::GetIO(); float wheelDelta = io.MouseWheel; if (std::abs(wheelDelta) < 0.001f) wheelDelta = input.getMouseWheelDelta(); if (wheelDelta > 0.0f) zoomIn(playerRenderPos); else if (wheelDelta < 0.0f) zoomOut(); - } else { - auto& io = ImGui::GetIO(); - if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) { - open = true; - if (zones.empty()) loadZonesFromDBC(); - - int bestContinent = findBestContinentForPlayer(playerRenderPos); - if (bestContinent >= 0 && bestContinent != continentIdx) { - continentIdx = bestContinent; - compositedIdx = -1; - } - - int playerZone = findZoneForPlayer(playerRenderPos); - if (playerZone >= 0 && continentIdx >= 0 && - zoneBelongsToContinent(playerZone, continentIdx)) { - loadZoneTextures(playerZone); - requestComposite(playerZone); - currentIdx = playerZone; - viewLevel = ViewLevel::ZONE; - } else if (continentIdx >= 0) { - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - } } if (!open) return; - renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight); + renderImGuiOverlay(playerRenderPos, screenWidth, screenHeight, playerYawDeg); } // -------------------------------------------------------- // ImGui overlay // -------------------------------------------------------- -void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight) { +void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWidth, int screenHeight, float playerYawDeg) { float sw = static_cast(screenWidth); float sh = static_cast(screenHeight); @@ -998,8 +1016,179 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi playerUV.y >= 0.0f && playerUV.y <= 1.0f) { float px = imgMin.x + playerUV.x * displayW; float py = imgMin.y + playerUV.y * displayH; - drawList->AddCircleFilled(ImVec2(px, py), 6.0f, IM_COL32(255, 40, 40, 255)); - drawList->AddCircle(ImVec2(px, py), 6.0f, IM_COL32(0, 0, 0, 200), 0, 2.0f); + // Directional arrow: render-space (cos,sin) maps to screen (-dx,-dy) + // because render+X=west=left and render+Y=north=up (screen Y is down). + float yawRad = glm::radians(playerYawDeg); + float adx = -std::cos(yawRad); // screen-space arrow X + float ady = -std::sin(yawRad); // screen-space arrow Y + float apx = -ady, apy = adx; // perpendicular (left/right of arrow) + constexpr float TIP = 9.0f; // tip distance from center + constexpr float TAIL = 4.0f; // tail distance from center + constexpr float HALF = 5.0f; // half base width + ImVec2 tip(px + adx * TIP, py + ady * TIP); + ImVec2 bl (px - adx * TAIL + apx * HALF, py - ady * TAIL + apy * HALF); + ImVec2 br (px - adx * TAIL - apx * HALF, py - ady * TAIL - apy * HALF); + drawList->AddTriangleFilled(tip, bl, br, IM_COL32(255, 40, 40, 255)); + drawList->AddTriangle(tip, bl, br, IM_COL32(0, 0, 0, 200), 1.5f); + } + } + + // Party member dots + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + ImFont* font = ImGui::GetFont(); + for (const auto& dot : partyDots_) { + glm::vec2 uv = renderPosToMapUV(dot.renderPos, currentIdx); + if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; + float px = imgMin.x + uv.x * displayW; + float py = imgMin.y + uv.y * displayH; + drawList->AddCircleFilled(ImVec2(px, py), 5.0f, dot.color); + drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(0, 0, 0, 200), 0, 1.5f); + // Name tooltip on hover + if (!dot.name.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + float dx = mp.x - px, dy = mp.y - py; + if (dx * dx + dy * dy <= 49.0f) { // radius 7 px hit area + ImGui::SetTooltip("%s", dot.name.c_str()); + } + // Draw name label above the dot + ImVec2 nameSz = font->CalcTextSizeA(ImGui::GetFontSize(), FLT_MAX, 0.0f, dot.name.c_str()); + float tx = px - nameSz.x * 0.5f; + float ty = py - nameSz.y - 7.0f; + drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), dot.name.c_str()); + drawList->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 220), dot.name.c_str()); + } + } + } + + // Taxi node markers — flight master icons on the map + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !taxiNodes_.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + for (const auto& node : taxiNodes_) { + if (!node.known) continue; + if (static_cast(node.mapId) != currentMapId_) continue; + + glm::vec3 rPos = core::coords::canonicalToRender( + glm::vec3(node.wowX, node.wowY, node.wowZ)); + glm::vec2 uv = renderPosToMapUV(rPos, currentIdx); + if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; + + float px = imgMin.x + uv.x * displayW; + float py = imgMin.y + uv.y * displayH; + + // Flight-master icon: yellow diamond with dark border + constexpr float H = 5.0f; // half-size of diamond + ImVec2 top2(px, py - H); + ImVec2 right2(px + H, py ); + ImVec2 bot2(px, py + H); + ImVec2 left2(px - H, py ); + drawList->AddQuadFilled(top2, right2, bot2, left2, + IM_COL32(255, 215, 0, 230)); + drawList->AddQuad(top2, right2, bot2, left2, + IM_COL32(80, 50, 0, 200), 1.2f); + + // Tooltip on hover + if (!node.name.empty()) { + float mdx = mp.x - px, mdy = mp.y - py; + if (mdx * mdx + mdy * mdy < 49.0f) { + ImGui::SetTooltip("%s\n(Flight Master)", node.name.c_str()); + } + } + } + } + + // Quest POI markers — golden exclamation marks / question marks + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD && !questPois_.empty()) { + ImVec2 mp = ImGui::GetMousePos(); + ImFont* qFont = ImGui::GetFont(); + for (const auto& qp : questPois_) { + glm::vec3 rPos = core::coords::canonicalToRender( + glm::vec3(qp.wowX, qp.wowY, 0.0f)); + glm::vec2 uv = renderPosToMapUV(rPos, currentIdx); + if (uv.x < 0.0f || uv.x > 1.0f || uv.y < 0.0f || uv.y > 1.0f) continue; + + float px = imgMin.x + uv.x * displayW; + float py = imgMin.y + uv.y * displayH; + + // Cyan circle with golden ring (matches minimap POI style) + drawList->AddCircleFilled(ImVec2(px, py), 5.0f, IM_COL32(0, 210, 255, 220)); + drawList->AddCircle(ImVec2(px, py), 5.0f, IM_COL32(255, 215, 0, 220), 0, 1.5f); + + // Quest name label + if (!qp.name.empty()) { + ImVec2 nameSz = qFont->CalcTextSizeA(ImGui::GetFontSize() * 0.85f, FLT_MAX, 0.0f, qp.name.c_str()); + float tx = px - nameSz.x * 0.5f; + float ty = py - nameSz.y - 7.0f; + drawList->AddText(qFont, ImGui::GetFontSize() * 0.85f, + ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), qp.name.c_str()); + drawList->AddText(qFont, ImGui::GetFontSize() * 0.85f, + ImVec2(tx, ty), IM_COL32(255, 230, 100, 230), qp.name.c_str()); + } + // Tooltip on hover + float mdx = mp.x - px, mdy = mp.y - py; + if (mdx * mdx + mdy * mdy < 49.0f && !qp.name.empty()) { + ImGui::SetTooltip("%s\n(Quest Objective)", qp.name.c_str()); + } + } + } + + // Corpse marker — skull X shown when player is a ghost with unclaimed corpse + if (hasCorpse_ && currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + glm::vec2 uv = renderPosToMapUV(corpseRenderPos_, currentIdx); + if (uv.x >= 0.0f && uv.x <= 1.0f && uv.y >= 0.0f && uv.y <= 1.0f) { + float cx = imgMin.x + uv.x * displayW; + float cy = imgMin.y + uv.y * displayH; + constexpr float R = 5.0f; // cross arm half-length + constexpr float T = 1.8f; // line thickness + // Dark outline + drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), + IM_COL32(0, 0, 0, 220), T + 1.5f); + drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), + IM_COL32(0, 0, 0, 220), T + 1.5f); + // Bone-white X + drawList->AddLine(ImVec2(cx - R, cy - R), ImVec2(cx + R, cy + R), + IM_COL32(230, 220, 200, 240), T); + drawList->AddLine(ImVec2(cx + R, cy - R), ImVec2(cx - R, cy + R), + IM_COL32(230, 220, 200, 240), T); + // Tooltip on hover + ImVec2 mp = ImGui::GetMousePos(); + float dx = mp.x - cx, dy = mp.y - cy; + if (dx * dx + dy * dy < 64.0f) { + ImGui::SetTooltip("Your corpse"); + } + } + } + + // Hover coordinate display — show WoW coordinates under cursor + if (currentIdx >= 0 && viewLevel != ViewLevel::WORLD) { + auto& io = ImGui::GetIO(); + ImVec2 mp = io.MousePos; + if (mp.x >= imgMin.x && mp.x <= imgMin.x + displayW && + mp.y >= imgMin.y && mp.y <= imgMin.y + displayH) { + float mu = (mp.x - imgMin.x) / displayW; + float mv = (mp.y - imgMin.y) / displayH; + + const auto& zone = zones[currentIdx]; + float left = zone.locLeft, right = zone.locRight; + float top = zone.locTop, bottom = zone.locBottom; + if (zone.areaID == 0) { + float l, r, t, b; + getContinentProjectionBounds(currentIdx, l, r, t, b); + left = l; right = r; top = t; bottom = b; + // Undo the kVOffset applied during renderPosToMapUV for continent + constexpr float kVOffset = -0.15f; + mv -= kVOffset; + } + + float hWowX = left - mu * (left - right); + float hWowY = top - mv * (top - bottom); + + char coordBuf[32]; + snprintf(coordBuf, sizeof(coordBuf), "%.0f, %.0f", hWowX, hWowY); + ImVec2 coordSz = ImGui::CalcTextSize(coordBuf); + float cx = imgMin.x + displayW - coordSz.x - 8.0f; + float cy = imgMin.y + displayH - coordSz.y - 8.0f; + drawList->AddText(ImVec2(cx + 1.0f, cy + 1.0f), IM_COL32(0, 0, 0, 180), coordBuf); + drawList->AddText(ImVec2(cx, cy), IM_COL32(220, 210, 150, 230), coordBuf); } } @@ -1067,6 +1256,23 @@ void WorldMap::renderImGuiOverlay(const glm::vec3& playerRenderPos, int screenWi drawList->AddRect(ImVec2(sx0, sy0), ImVec2(sx1, sy1), IM_COL32(255, 255, 255, 30), 0.0f, 0, 1.0f); } + + // Zone name label — only if the rect is large enough to fit it + if (!z.areaName.empty()) { + ImVec2 textSz = ImGui::CalcTextSize(z.areaName.c_str()); + float rectW = sx1 - sx0; + float rectH = sy1 - sy0; + if (rectW > textSz.x + 4.0f && rectH > textSz.y + 2.0f) { + float tx = (sx0 + sx1) * 0.5f - textSz.x * 0.5f; + float ty = (sy0 + sy1) * 0.5f - textSz.y * 0.5f; + ImU32 labelCol = explored + ? IM_COL32(255, 230, 150, 210) + : IM_COL32(160, 160, 160, 80); + drawList->AddText(ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 130), z.areaName.c_str()); + drawList->AddText(ImVec2(tx, ty), labelCol, z.areaName.c_str()); + } + } } } diff --git a/src/ui/auth_screen.cpp b/src/ui/auth_screen.cpp index 2f4b83cc..777285cf 100644 --- a/src/ui/auth_screen.cpp +++ b/src/ui/auth_screen.cpp @@ -206,8 +206,8 @@ void AuthScreen::render(auth::AuthHandler& authHandler) { } } } - // Login screen music disabled - if (false && renderer) { + // Login screen music + if (renderer) { auto* music = renderer->getMusicManager(); if (music) { if (!loginMusicVolumeAdjusted_) { diff --git a/src/ui/character_screen.cpp b/src/ui/character_screen.cpp index 406164ac..96b53dd0 100644 --- a/src/ui/character_screen.cpp +++ b/src/ui/character_screen.cpp @@ -37,6 +37,22 @@ static uint64_t hashEquipment(const std::vector& eq) { return h; } +static ImVec4 classColor(uint8_t classId) { + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A + default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + } +} + void CharacterScreen::render(game::GameHandler& gameHandler) { ImGuiViewport* vp = ImGui::GetMainViewport(); const ImVec2 pad(24.0f, 24.0f); @@ -184,7 +200,7 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 45.0f); ImGui::TableSetupColumn("Race", ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 1.2f); - ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthFixed, 55.0f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 1.5f); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); @@ -224,10 +240,16 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("%s", game::getRaceName(character.race)); ImGui::TableSetColumnIndex(3); - ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::TextColored(classColor(static_cast(character.characterClass)), "%s", game::getClassName(character.characterClass)); ImGui::TableSetColumnIndex(4); - ImGui::Text("%d", character.zoneId); + { + std::string zoneName = gameHandler.getWhoAreaName(character.zoneId); + if (!zoneName.empty()) + ImGui::TextUnformatted(zoneName.c_str()); + else + ImGui::Text("%u", character.zoneId); + } } ImGui::EndTable(); @@ -325,10 +347,21 @@ void CharacterScreen::render(game::GameHandler& gameHandler) { ImGui::Text("Level %d", character.level); ImGui::Text("%s", game::getRaceName(character.race)); - ImGui::Text("%s", game::getClassName(character.characterClass)); + ImGui::TextColored(classColor(static_cast(character.characterClass)), "%s", game::getClassName(character.characterClass)); ImGui::Text("%s", game::getGenderName(character.gender)); ImGui::Spacing(); - ImGui::Text("Map %d, Zone %d", character.mapId, character.zoneId); + { + std::string mapName = gameHandler.getMapName(character.mapId); + std::string zoneName = gameHandler.getWhoAreaName(character.zoneId); + if (!mapName.empty() && !zoneName.empty()) + ImGui::Text("%s — %s", mapName.c_str(), zoneName.c_str()); + else if (!mapName.empty()) + ImGui::Text("%s (Zone %u)", mapName.c_str(), character.zoneId); + else if (!zoneName.empty()) + ImGui::Text("Map %u — %s", character.mapId, zoneName.c_str()); + else + ImGui::Text("Map %u, Zone %u", character.mapId, character.zoneId); + } if (character.hasGuild()) { ImGui::Text("Guild ID: %d", character.guildId); diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index f5a1bc1d..b74a78b5 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -2,6 +2,7 @@ #include "rendering/character_preview.hpp" #include "rendering/vk_context.hpp" #include "core/application.hpp" +#include "addons/addon_manager.hpp" #include "core/coordinates.hpp" #include "core/spawn_presets.hpp" #include "core/input.hpp" @@ -31,12 +32,14 @@ #include "pipeline/dbc_layout.hpp" #include "game/expansion_profile.hpp" +#include "game/character.hpp" #include "core/logger.hpp" #include #include #include #include #include +#include #include #include #include @@ -46,6 +49,17 @@ #include namespace { + // Build a WoW-format item link string for chat insertion. + // Format: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r + std::string buildItemChatLink(uint32_t itemId, uint8_t quality, const std::string& name) { + static const char* kQualHex[] = {"9d9d9d","ffffff","1eff00","0070dd","a335ee","ff8000","e6cc80","e6cc80"}; + uint8_t qi = quality < 8 ? quality : 1; + char buf[512]; + snprintf(buf, sizeof(buf), "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + kQualHex[qi], itemId, name.c_str()); + return buf; + } + std::string trim(const std::string& s) { size_t first = s.find_first_not_of(" \t\r\n"); if (first == std::string::npos) return ""; @@ -60,6 +74,67 @@ namespace { return s; } + // Render gold/silver/copper amounts in WoW-canonical colors on the current ImGui line. + // Skips zero-value denominations (except copper, which is always shown when gold=silver=0). + void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); + } + + // Return the canonical Blizzard class color as ImVec4. + // classId is byte 1 of UNIT_FIELD_BYTES_0 (or CharacterData::classId). + // Returns a neutral light-gray for unknown / class 0. + ImVec4 classColorVec4(uint8_t classId) { + switch (classId) { + case 1: return ImVec4(0.78f, 0.61f, 0.43f, 1.0f); // Warrior #C79C6E + case 2: return ImVec4(0.96f, 0.55f, 0.73f, 1.0f); // Paladin #F58CBA + case 3: return ImVec4(0.67f, 0.83f, 0.45f, 1.0f); // Hunter #ABD473 + case 4: return ImVec4(1.00f, 0.96f, 0.41f, 1.0f); // Rogue #FFF569 + case 5: return ImVec4(1.00f, 1.00f, 1.00f, 1.0f); // Priest #FFFFFF + case 6: return ImVec4(0.77f, 0.12f, 0.23f, 1.0f); // DeathKnight #C41F3B + case 7: return ImVec4(0.00f, 0.44f, 0.87f, 1.0f); // Shaman #0070DE + case 8: return ImVec4(0.41f, 0.80f, 0.94f, 1.0f); // Mage #69CCF0 + case 9: return ImVec4(0.58f, 0.51f, 0.79f, 1.0f); // Warlock #9482C9 + case 11: return ImVec4(1.00f, 0.49f, 0.04f, 1.0f); // Druid #FF7D0A + default: return ImVec4(0.85f, 0.85f, 0.85f, 1.0f); // unknown + } + } + + // ImU32 variant with alpha in [0,255]. + ImU32 classColorU32(uint8_t classId, int alpha = 255) { + ImVec4 c = classColorVec4(classId); + return IM_COL32(static_cast(c.x * 255), static_cast(c.y * 255), + static_cast(c.z * 255), alpha); + } + + // Extract class id from a unit's UNIT_FIELD_BYTES_0 update field. + // Returns 0 if the entity pointer is null or field is unset. + uint8_t entityClassId(const wowee::game::Entity* entity) { + if (!entity) return 0; + using UF = wowee::game::UF; + uint32_t bytes0 = entity->getField(wowee::game::fieldIndex(UF::UNIT_FIELD_BYTES_0)); + return static_cast((bytes0 >> 8) & 0xFF); + } + + // Return the English class name for a class ID (1-11), or "Unknown". + const char* classNameStr(uint8_t classId) { + static const char* kNames[] = { + "Unknown","Warrior","Paladin","Hunter","Rogue","Priest", + "Death Knight","Shaman","Mage","Warlock","","Druid" + }; + return (classId < 12) ? kNames[classId] : "Unknown"; + } + bool isPortBotTarget(const std::string& target) { std::string t = toLower(trim(target)); return t == "portbot" || t == "gmbot" || t == "telebot"; @@ -132,26 +207,43 @@ GameScreen::GameScreen() { void GameScreen::initChatTabs() { chatTabs_.clear(); // General tab: shows everything - chatTabs_.push_back({"General", 0xFFFFFFFF}); - // Combat tab: system + loot messages - chatTabs_.push_back({"Combat", (1u << static_cast(game::ChatType::SYSTEM)) | - (1u << static_cast(game::ChatType::LOOT))}); + chatTabs_.push_back({"General", ~0ULL}); + // Combat tab: system, loot, skills, achievements, and NPC speech/emotes + chatTabs_.push_back({"Combat", (1ULL << static_cast(game::ChatType::SYSTEM)) | + (1ULL << static_cast(game::ChatType::LOOT)) | + (1ULL << static_cast(game::ChatType::SKILL)) | + (1ULL << static_cast(game::ChatType::ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT)) | + (1ULL << static_cast(game::ChatType::MONSTER_SAY)) | + (1ULL << static_cast(game::ChatType::MONSTER_YELL)) | + (1ULL << static_cast(game::ChatType::MONSTER_EMOTE)) | + (1ULL << static_cast(game::ChatType::MONSTER_WHISPER)) | + (1ULL << static_cast(game::ChatType::MONSTER_PARTY)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_WHISPER)) | + (1ULL << static_cast(game::ChatType::RAID_BOSS_EMOTE))}); // Whispers tab - chatTabs_.push_back({"Whispers", (1u << static_cast(game::ChatType::WHISPER)) | - (1u << static_cast(game::ChatType::WHISPER_INFORM))}); + chatTabs_.push_back({"Whispers", (1ULL << static_cast(game::ChatType::WHISPER)) | + (1ULL << static_cast(game::ChatType::WHISPER_INFORM))}); + // Guild tab: guild and officer chat + chatTabs_.push_back({"Guild", (1ULL << static_cast(game::ChatType::GUILD)) | + (1ULL << static_cast(game::ChatType::OFFICER)) | + (1ULL << static_cast(game::ChatType::GUILD_ACHIEVEMENT))}); // Trade/LFG tab: channel messages - chatTabs_.push_back({"Trade/LFG", (1u << static_cast(game::ChatType::CHANNEL))}); + chatTabs_.push_back({"Trade/LFG", (1ULL << static_cast(game::ChatType::CHANNEL))}); + // Reset unread counts to match new tab list + chatTabUnread_.assign(chatTabs_.size(), 0); + chatTabSeenCount_ = 0; } bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabIndex) const { if (tabIndex < 0 || tabIndex >= static_cast(chatTabs_.size())) return true; const auto& tab = chatTabs_[tabIndex]; - if (tab.typeMask == 0xFFFFFFFF) return true; // General tab shows all + if (tab.typeMask == ~0ULL) return true; // General tab shows all - uint32_t typeBit = 1u << static_cast(msg.type); + uint64_t typeBit = 1ULL << static_cast(msg.type); - // For Trade/LFG tab, also filter by channel name - if (tabIndex == 3 && msg.type == game::ChatType::CHANNEL) { + // For Trade/LFG tab (now index 4), also filter by channel name + if (tabIndex == 4 && msg.type == game::ChatType::CHANNEL) { const std::string& ch = msg.channelName; if (ch.find("Trade") == std::string::npos && ch.find("General") == std::string::npos && @@ -165,7 +257,18 @@ bool GameScreen::shouldShowMessage(const game::MessageChatData& msg, int tabInde return (tab.typeMask & typeBit) != 0; } +// Forward declaration — defined near sendChatMessage below +static std::string firstMacroCommand(const std::string& macroText); +static std::vector allMacroCommands(const std::string& macroText); +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride); +// Returns the spell/item name from #showtooltip [Name], or "__auto__" for bare +// #showtooltip (use first /cast target), or "" if no directive is present. +static std::string getMacroShowtooltipArg(const std::string& macroText); + void GameScreen::render(game::GameHandler& gameHandler) { + cachedGameHandler_ = &gameHandler; // Set up chat bubble callback (once) if (!chatBubbleCallbackSet_) { gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { @@ -192,6 +295,163 @@ void GameScreen::render(game::GameHandler& gameHandler) { chatBubbleCallbackSet_ = true; } + // Set up level-up callback (once) + if (!levelUpCallbackSet_) { + gameHandler.setLevelUpCallback([this, &gameHandler](uint32_t newLevel) { + levelUpFlashAlpha_ = 1.0f; + levelUpDisplayLevel_ = newLevel; + const auto& d = gameHandler.getLastLevelUpDeltas(); + triggerDing(newLevel, d.hp, d.mana, d.str, d.agi, d.sta, d.intel, d.spi); + }); + levelUpCallbackSet_ = true; + } + + // Set up achievement toast callback (once) + if (!achievementCallbackSet_) { + gameHandler.setAchievementEarnedCallback([this](uint32_t id, const std::string& name) { + triggerAchievementToast(id, name); + }); + achievementCallbackSet_ = true; + } + + // Set up area discovery toast callback (once) + if (!areaDiscoveryCallbackSet_) { + gameHandler.setAreaDiscoveryCallback([this](const std::string& areaName, uint32_t xpGained) { + discoveryToastName_ = areaName.empty() ? "New Area" : areaName; + discoveryToastXP_ = xpGained; + discoveryToastTimer_ = DISCOVERY_TOAST_DURATION; + }); + areaDiscoveryCallbackSet_ = true; + } + + // Set up quest objective progress toast callback (once) + if (!questProgressCallbackSet_) { + gameHandler.setQuestProgressCallback([this](const std::string& questTitle, + const std::string& objectiveName, + uint32_t current, uint32_t required) { + // Coalesce: if the same objective already has a toast, just update counts + for (auto& t : questToasts_) { + if (t.questTitle == questTitle && t.objectiveName == objectiveName) { + t.current = current; + t.required = required; + t.age = 0.0f; // restart lifetime + return; + } + } + if (questToasts_.size() >= 4) questToasts_.erase(questToasts_.begin()); + questToasts_.push_back({questTitle, objectiveName, current, required, 0.0f}); + }); + questProgressCallbackSet_ = true; + } + + // Set up other-player level-up toast callback (once) + if (!otherPlayerLevelUpCallbackSet_) { + gameHandler.setOtherPlayerLevelUpCallback([this](uint64_t guid, uint32_t newLevel) { + // Coalesce: update existing toast for same player + for (auto& t : playerLevelUpToasts_) { + if (t.guid == guid) { + t.newLevel = newLevel; + t.age = 0.0f; + return; + } + } + if (playerLevelUpToasts_.size() >= 3) + playerLevelUpToasts_.erase(playerLevelUpToasts_.begin()); + playerLevelUpToasts_.push_back({guid, "", newLevel, 0.0f}); + }); + otherPlayerLevelUpCallbackSet_ = true; + } + + // Set up PvP honor credit toast callback (once) + if (!pvpHonorCallbackSet_) { + gameHandler.setPvpHonorCallback([this](uint32_t honor, uint64_t /*victimGuid*/, uint32_t rank) { + if (honor == 0) return; + pvpHonorToasts_.push_back({honor, rank, 0.0f}); + if (pvpHonorToasts_.size() > 4) + pvpHonorToasts_.erase(pvpHonorToasts_.begin()); + }); + pvpHonorCallbackSet_ = true; + } + + // Set up item loot toast callback (once) + if (!itemLootCallbackSet_) { + gameHandler.setItemLootCallback([this](uint32_t itemId, uint32_t count, + uint32_t quality, const std::string& name) { + // Coalesce: if same item already in queue, bump count and reset age + for (auto& t : itemLootToasts_) { + if (t.itemId == itemId) { + t.count += count; + t.age = 0.0f; + return; + } + } + if (itemLootToasts_.size() >= 5) + itemLootToasts_.erase(itemLootToasts_.begin()); + itemLootToasts_.push_back({itemId, count, quality, name, 0.0f}); + }); + itemLootCallbackSet_ = true; + } + + // Set up ghost-state callback to flash "You have been resurrected!" on revival (once) + if (!ghostStateCallbackSet_) { + gameHandler.setGhostStateCallback([this](bool isGhost) { + if (!isGhost) { + // Transitioning ghost→alive: trigger the resurrection flash + resurrectFlashTimer_ = kResurrectFlashDuration; + } + }); + ghostStateCallbackSet_ = true; + } + + // Set up appearance-changed callback to refresh inventory preview (barber shop, etc.) + if (!appearanceCallbackSet_) { + gameHandler.setAppearanceChangedCallback([this]() { + inventoryScreenCharGuid_ = 0; // force preview re-sync on next frame + }); + appearanceCallbackSet_ = true; + } + + // Set up UI error frame callback (once) + if (!uiErrorCallbackSet_) { + gameHandler.setUIErrorCallback([this](const std::string& msg) { + uiErrors_.push_back({msg, 0.0f}); + if (uiErrors_.size() > 5) uiErrors_.erase(uiErrors_.begin()); + // Play error sound for each new error (rate-limited by deque cap of 5) + if (auto* r = core::Application::getInstance().getRenderer()) { + if (auto* sfx = r->getUiSoundManager()) sfx->playError(); + } + }); + uiErrorCallbackSet_ = true; + } + + // Flash the action bar button whose spell just failed (0.5 s red overlay). + if (!castFailedCallbackSet_) { + gameHandler.setSpellCastFailedCallback([this](uint32_t spellId) { + if (spellId == 0) return; + float now = static_cast(ImGui::GetTime()); + actionFlashEndTimes_[spellId] = now + kActionFlashDuration; + }); + castFailedCallbackSet_ = true; + } + + // Set up reputation change toast callback (once) + if (!repChangeCallbackSet_) { + gameHandler.setRepChangeCallback([this](const std::string& name, int32_t delta, int32_t standing) { + repToasts_.push_back({name, delta, standing, 0.0f}); + if (repToasts_.size() > 4) repToasts_.erase(repToasts_.begin()); + }); + repChangeCallbackSet_ = true; + } + + // Set up quest completion toast callback (once) + if (!questCompleteCallbackSet_) { + gameHandler.setQuestCompleteCallback([this](uint32_t id, const std::string& title) { + questCompleteToasts_.push_back({id, title, 0.0f}); + if (questCompleteToasts_.size() > 3) questCompleteToasts_.erase(questCompleteToasts_.begin()); + }); + questCompleteCallbackSet_ = true; + } + // Apply UI transparency setting float prevAlpha = ImGui::GetStyle().Alpha; ImGui::GetStyle().Alpha = uiOpacity_; @@ -288,6 +548,15 @@ void GameScreen::render(game::GameHandler& gameHandler) { msaaSettingsApplied_ = true; } + // Apply saved FXAA setting once when renderer is available + if (!fxaaSettingsApplied_) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (renderer) { + renderer->setFXAAEnabled(pendingFXAA); + fxaaSettingsApplied_ = true; + } + } + // Apply saved water refraction setting once when renderer is available if (!waterRefractionApplied_) { auto* renderer = core::Application::getInstance().getRenderer(); @@ -327,22 +596,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderer->setFSRSharpness(pendingFSRSharpness); renderer->setFSR2DebugTuning(pendingFSR2JitterSign, pendingFSR2MotionVecScaleX, pendingFSR2MotionVecScaleY); renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen); - // Safety fallback: persisted FSR2 can still hang on some systems during startup. - // Require explicit opt-in for startup FSR2; otherwise fall back to FSR1. - const bool allowStartupFsr2 = (std::getenv("WOWEE_ALLOW_STARTUP_FSR2") != nullptr); int effectiveMode = pendingUpscalingMode; - if (effectiveMode == 2 && !allowStartupFsr2) { - static bool warnedStartupFsr2Fallback = false; - if (!warnedStartupFsr2Fallback) { - LOG_WARNING("Startup FSR2 is disabled by default for stability; falling back to FSR1. Set WOWEE_ALLOW_STARTUP_FSR2=1 to override."); - warnedStartupFsr2Fallback = true; - } - effectiveMode = 1; - pendingUpscalingMode = 1; - pendingFSR = true; - } - // If explicitly enabled, still defer FSR2 until fully in-world. + // Defer FSR2/FSR3 activation until fully in-world to avoid + // init issues during login/character selection screens. if (effectiveMode == 2 && gameHandler.getState() != game::WorldState::IN_WORLD) { renderer->setFSREnabled(false); renderer->setFSR2Enabled(false); @@ -354,8 +611,24 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } - // Apply auto-loot setting to GameHandler every frame (cheap bool sync) + // Apply auto-loot / auto-sell settings to GameHandler every frame (cheap bool sync) gameHandler.setAutoLoot(pendingAutoLoot); + gameHandler.setAutoSellGrey(pendingAutoSellGrey); + gameHandler.setAutoRepair(pendingAutoRepair); + + // Zone entry detection — fire a toast when the renderer's zone name changes + if (auto* rend = core::Application::getInstance().getRenderer()) { + const std::string& curZone = rend->getCurrentZoneName(); + if (!curZone.empty() && curZone != lastKnownZone_) { + if (!lastKnownZone_.empty()) { + // Genuine zone change (not first entry) + zoneToasts_.push_back({curZone, 0.0f}); + if (zoneToasts_.size() > 3) + zoneToasts_.erase(zoneToasts_.begin()); + } + lastKnownZone_ = curZone; + } + } // Sync chat auto-join settings to GameHandler gameHandler.chatAutoJoin.general = chatAutoJoinGeneral_; @@ -375,11 +648,27 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderPetFrame(gameHandler); } + // Auto-open pet rename modal when server signals the pet is renameable (first tame) + if (gameHandler.consumePetRenameablePending()) { + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } + + // Totem frame (Shaman only, when any totem is active) + if (gameHandler.getPlayerClass() == 7) { + renderTotemFrame(gameHandler); + } + // Target frame (only when we have a target) if (gameHandler.hasTarget()) { renderTargetFrame(gameHandler); } + // Focus target frame (only when we have a focus) + if (gameHandler.hasFocus()) { + renderFocusFrame(gameHandler); + } + // Render windows if (showPlayerInfo) { renderPlayerInfo(gameHandler); @@ -395,26 +684,46 @@ void GameScreen::render(game::GameHandler& gameHandler) { // ---- New UI elements ---- renderActionBar(gameHandler); + renderStanceBar(gameHandler); renderBagBar(gameHandler); renderXpBar(gameHandler); + renderRepBar(gameHandler); renderCastBar(gameHandler); renderMirrorTimers(gameHandler); + renderCooldownTracker(gameHandler); renderQuestObjectiveTracker(gameHandler); renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); + renderRaidWarningOverlay(gameHandler); renderCombatText(gameHandler); - renderPartyFrames(gameHandler); + renderDPSMeter(gameHandler); + renderDurabilityWarning(gameHandler); + renderUIErrors(gameHandler, ImGui::GetIO().DeltaTime); + renderRepToasts(ImGui::GetIO().DeltaTime); + renderQuestCompleteToasts(ImGui::GetIO().DeltaTime); + renderZoneToasts(ImGui::GetIO().DeltaTime); + renderAreaTriggerToasts(ImGui::GetIO().DeltaTime, gameHandler); + if (showRaidFrames_) { + renderPartyFrames(gameHandler); + } renderBossFrames(gameHandler); renderGroupInvitePopup(gameHandler); renderDuelRequestPopup(gameHandler); + renderDuelCountdown(gameHandler); renderLootRollPopup(gameHandler); renderTradeRequestPopup(gameHandler); + renderTradeWindow(gameHandler); renderSummonRequestPopup(gameHandler); renderSharedQuestPopup(gameHandler); renderItemTextWindow(gameHandler); renderGuildInvitePopup(gameHandler); renderReadyCheckPopup(gameHandler); + renderBgInvitePopup(gameHandler); + renderBfMgrInvitePopup(gameHandler); + renderLfgProposalPopup(gameHandler); + renderLfgRoleCheckPopup(gameHandler); renderGuildRoster(gameHandler); + renderSocialFrame(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); renderGossipWindow(gameHandler); @@ -423,6 +732,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderQuestOfferRewardWindow(gameHandler); renderVendorWindow(gameHandler); renderTrainerWindow(gameHandler); + renderBarberShopWindow(gameHandler); + renderStableWindow(gameHandler); renderTaxiWindow(gameHandler); renderMailWindow(gameHandler); renderMailComposeWindow(gameHandler); @@ -431,27 +742,64 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderAuctionHouseWindow(gameHandler); renderDungeonFinderWindow(gameHandler); renderInstanceLockouts(gameHandler); + renderWhoWindow(gameHandler); + renderCombatLog(gameHandler); + renderAchievementWindow(gameHandler); + renderSkillsWindow(gameHandler); + renderTitlesWindow(gameHandler); + renderEquipSetWindow(gameHandler); + renderGmTicketWindow(gameHandler); + renderInspectWindow(gameHandler); + renderBookWindow(gameHandler); + renderThreatWindow(gameHandler); + renderBgScoreboard(gameHandler); // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now - renderMinimapMarkers(gameHandler); + if (showMinimap_) { + renderMinimapMarkers(gameHandler); + } + renderLogoutCountdown(gameHandler); renderDeathScreen(gameHandler); renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); + renderTalentWipeConfirmDialog(gameHandler); + renderPetUnlearnConfirmDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); renderSettingsWindow(); renderDingEffect(); renderAchievementToast(); - renderZoneText(); + renderDiscoveryToast(); + renderWhisperToasts(); + renderQuestProgressToasts(); + renderPlayerLevelUpToasts(gameHandler); + renderPvpHonorToasts(); + renderItemLootToasts(); + renderResurrectFlash(); + renderZoneText(gameHandler); + renderWeatherOverlay(gameHandler); // World map (M key toggle handled inside) renderWorldMap(gameHandler); // Quest Log (L key toggle handled inside) - questLogScreen.render(gameHandler); + questLogScreen.render(gameHandler, inventoryScreen); // Spellbook (P key toggle handled inside) spellbookScreen.render(gameHandler, core::Application::getInstance().getAssetManager()); + // Insert spell link into chat if player shift-clicked a spellbook entry + { + std::string pendingSpellLink = spellbookScreen.getAndClearPendingChatLink(); + if (!pendingSpellLink.empty()) { + size_t curLen = strlen(chatInputBuffer); + if (curLen + pendingSpellLink.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, pendingSpellLink.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + } + // Talents (N key toggle handled inside) talentScreen.render(gameHandler); @@ -501,6 +849,19 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Character screen (C key toggle handled inside render()) inventoryScreen.renderCharacterScreen(gameHandler); + // Insert item link into chat if player shift-clicked any inventory/equipment slot + { + std::string pendingLink = inventoryScreen.getAndClearPendingChatLink(); + if (!pendingLink.empty()) { + size_t curLen = strlen(chatInputBuffer); + if (curLen + pendingLink.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, pendingLink.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + } + if (inventoryScreen.consumeEquipmentDirty() || gameHandler.consumeOnlineEquipmentDirty()) { updateCharacterGeosets(gameHandler.getInventory()); updateCharacterTextures(gameHandler.getInventory()); @@ -517,7 +878,23 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Update renderer face-target position and selection circle auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { - renderer->setInCombat(gameHandler.isInCombat()); + renderer->setInCombat(gameHandler.isInCombat() && + !gameHandler.isPlayerDead() && + !gameHandler.isPlayerGhost()); + if (auto* cr = renderer->getCharacterRenderer()) { + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId != 0) { + const bool isGhost = gameHandler.isPlayerGhost(); + if (!ghostOpacityStateKnown_ || + ghostOpacityLastState_ != isGhost || + ghostOpacityLastInstanceId_ != charInstId) { + cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f); + ghostOpacityStateKnown_ = true; + ghostOpacityLastState_ = isGhost; + ghostOpacityLastInstanceId_ = charInstId; + } + } + } static glm::vec3 targetGLPos; if (gameHandler.hasTarget()) { auto target = gameHandler.getTarget(); @@ -598,6 +975,128 @@ void GameScreen::render(game::GameHandler& gameHandler) { } } + // Screen edge damage flash — red vignette that fires on HP decrease + { + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + uint32_t currentHp = 0; + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || + playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) + currentHp = unit->getHealth(); + } + + // Detect HP drop (ignore transitions from 0 — entity just spawned or uninitialized) + if (damageFlashEnabled_ && lastPlayerHp_ > 0 && currentHp < lastPlayerHp_ && currentHp > 0) + damageFlashAlpha_ = 1.0f; + lastPlayerHp_ = currentHp; + + // Fade out over ~0.5 seconds + if (damageFlashAlpha_ > 0.0f) { + damageFlashAlpha_ -= ImGui::GetIO().DeltaTime * 2.0f; + if (damageFlashAlpha_ < 0.0f) damageFlashAlpha_ = 0.0f; + + // Draw four red gradient rectangles along each screen edge (vignette style) + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const int alpha = static_cast(damageFlashAlpha_ * 100.0f); + const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); + const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); + const float thickness = std::min(W, H) * 0.12f; + + // Top + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + edgeCol, edgeCol, fadeCol, fadeCol); + // Bottom + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + fadeCol, fadeCol, edgeCol, edgeCol); + // Left + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + edgeCol, fadeCol, fadeCol, edgeCol); + // Right + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + fadeCol, edgeCol, edgeCol, fadeCol); + } + } + + // Persistent low-health vignette — pulsing red edges when HP < 20% + { + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + bool isDead = gameHandler.isPlayerDead(); + float hpPct = 1.0f; + if (!isDead && playerEntity && + (playerEntity->getType() == game::ObjectType::PLAYER || + playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) + hpPct = static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()); + } + + // Only show when alive and below 20% HP; intensity increases as HP drops + if (lowHealthVignetteEnabled_ && !isDead && hpPct < 0.20f && hpPct > 0.0f) { + // Base intensity from HP deficit (0 at 20%, 1 at 0%); pulse at ~1.5 Hz + float danger = (0.20f - hpPct) / 0.20f; + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 9.4f); + int alpha = static_cast(danger * pulse * 90.0f); // max ~90 alpha, subtle + if (alpha > 0) { + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const float thickness = std::min(W, H) * 0.15f; + const ImU32 edgeCol = IM_COL32(200, 0, 0, alpha); + const ImU32 fadeCol = IM_COL32(200, 0, 0, 0); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + edgeCol, edgeCol, fadeCol, fadeCol); + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + fadeCol, fadeCol, edgeCol, edgeCol); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + edgeCol, fadeCol, fadeCol, edgeCol); + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + fadeCol, edgeCol, edgeCol, fadeCol); + } + } + } + + // Level-up golden burst overlay + if (levelUpFlashAlpha_ > 0.0f) { + levelUpFlashAlpha_ -= ImGui::GetIO().DeltaTime * 1.0f; // fade over ~1 second + if (levelUpFlashAlpha_ < 0.0f) levelUpFlashAlpha_ = 0.0f; + + ImDrawList* fg = ImGui::GetForegroundDrawList(); + ImGuiIO& io = ImGui::GetIO(); + const float W = io.DisplaySize.x; + const float H = io.DisplaySize.y; + const int alpha = static_cast(levelUpFlashAlpha_ * 160.0f); + const ImU32 goldEdge = IM_COL32(255, 210, 50, alpha); + const ImU32 goldFade = IM_COL32(255, 210, 50, 0); + const float thickness = std::min(W, H) * 0.18f; + + // Four golden gradient edges + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(W, thickness), + goldEdge, goldEdge, goldFade, goldFade); + fg->AddRectFilledMultiColor(ImVec2(0, H - thickness), ImVec2(W, H), + goldFade, goldFade, goldEdge, goldEdge); + fg->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(thickness, H), + goldEdge, goldFade, goldFade, goldEdge); + fg->AddRectFilledMultiColor(ImVec2(W - thickness, 0), ImVec2(W, H), + goldFade, goldEdge, goldEdge, goldFade); + + // "Level X!" text in the center during the first half of the animation + if (levelUpFlashAlpha_ > 0.5f && levelUpDisplayLevel_ > 0) { + char lvlText[32]; + snprintf(lvlText, sizeof(lvlText), "Level %u!", levelUpDisplayLevel_); + ImVec2 ts = ImGui::CalcTextSize(lvlText); + float tx = (W - ts.x) * 0.5f; + float ty = H * 0.35f; + // Large shadow + bright gold text + fg->AddText(nullptr, 28.0f, ImVec2(tx + 2, ty + 2), IM_COL32(0, 0, 0, alpha), lvlText); + fg->AddText(nullptr, 28.0f, ImVec2(tx, ty), IM_COL32(255, 230, 80, alpha), lvlText); + } + } + // Restore previous alpha ImGui::GetStyle().Alpha = prevAlpha; } @@ -748,6 +1247,7 @@ void GameScreen::renderEntityList(game::GameHandler& gameHandler) { void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { auto* window = core::Application::getInstance().getWindow(); + auto* assetMgr = core::Application::getInstance().getAssetManager(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; float chatW = std::min(500.0f, screenW * 0.4f); @@ -777,13 +1277,51 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { chatWindowPos_ = ImGui::GetWindowPos(); } + // Update unread counts: scan any new messages since last frame + { + const auto& history = gameHandler.getChatHistory(); + // Ensure unread array is sized correctly (guards against late init) + if (chatTabUnread_.size() != chatTabs_.size()) + chatTabUnread_.assign(chatTabs_.size(), 0); + // If history shrank (e.g. cleared), reset + if (chatTabSeenCount_ > history.size()) chatTabSeenCount_ = 0; + for (size_t mi = chatTabSeenCount_; mi < history.size(); ++mi) { + const auto& msg = history[mi]; + // For each non-General (non-0) tab that isn't currently active, check visibility + for (int ti = 1; ti < static_cast(chatTabs_.size()); ++ti) { + if (ti == activeChatTab_) continue; + if (shouldShowMessage(msg, ti)) { + chatTabUnread_[ti]++; + } + } + } + chatTabSeenCount_ = history.size(); + } + // Chat tabs if (ImGui::BeginTabBar("ChatTabs")) { for (int i = 0; i < static_cast(chatTabs_.size()); ++i) { - if (ImGui::BeginTabItem(chatTabs_[i].name.c_str())) { - activeChatTab_ = i; + // Build label with unread count suffix for non-General tabs + std::string tabLabel = chatTabs_[i].name; + if (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0) { + tabLabel += " (" + std::to_string(chatTabUnread_[i]) + ")"; + } + // Flash tab text color when unread messages exist + bool hasUnread = (i > 0 && i < static_cast(chatTabUnread_.size()) && chatTabUnread_[i] > 0); + if (hasUnread) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f * pulse, 0.2f * pulse, 1.0f)); + } + if (ImGui::BeginTabItem(tabLabel.c_str())) { + if (activeChatTab_ != i) { + activeChatTab_ = i; + // Clear unread count when tab becomes active + if (i < static_cast(chatTabUnread_.size())) + chatTabUnread_[i] = 0; + } ImGui::EndTabItem(); } + if (hasUnread) ImGui::PopStyleColor(); } ImGui::EndTabBar(); } @@ -881,6 +1419,25 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Heroic indicator (green, matches WoW tooltip style) + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (info->itemFlags & kFlagHeroic) + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + + // Bind type (appears right under name in WoW) + switch (info->bindType) { + case 1: ImGui::TextDisabled("Binds when picked up"); break; + case 2: ImGui::TextDisabled("Binds when equipped"); break; + case 3: ImGui::TextDisabled("Binds when used"); break; + case 4: ImGui::TextDisabled("Quest Item"); break; + } + // Unique / Unique-Equipped + if (info->maxCount == 1) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + else if (info->itemFlags & kFlagUniqueEquipped) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + // Slot type if (info->inventoryType > 0) { const char* slotName = ""; @@ -933,10 +1490,23 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { }; const bool isWeapon = isWeaponInventoryType(info->inventoryType); + // Item level (after slot/subclass) + if (info->itemLevel > 0) + ImGui::TextDisabled("Item Level %u", info->itemLevel); + if (isWeapon && info->damageMax > 0.0f && info->delayMs > 0) { float speed = static_cast(info->delayMs) / 1000.0f; float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + // WoW-style: "22 - 41 Damage" with speed right-aligned on same row + char dmgBuf[64], spdBuf[32]; + std::snprintf(dmgBuf, sizeof(dmgBuf), "%d - %d Damage", + static_cast(info->damageMin), static_cast(info->damageMax)); + std::snprintf(spdBuf, sizeof(spdBuf), "Speed %.2f", speed); + float spdW = ImGui::CalcTextSize(spdBuf).x; + ImGui::Text("%s", dmgBuf); + ImGui::SameLine(ImGui::GetWindowWidth() - spdW - 16.0f); + ImGui::Text("%s", spdBuf); + ImGui::TextDisabled("(%.1f damage per second)", dps); } ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { @@ -957,11 +1527,337 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (info->armor > 0) { ImGui::Text("%d Armor", info->armor); } + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { + info->holyRes, info->fireRes, info->natureRes, + info->frostRes, info->shadowRes, info->arcaneRes + }; + static const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int ri = 0; ri < 6; ++ri) + if (resVals[ri] > 0) ImGui::Text("+%d %s", resVals[ri], resLabels[ri]); + } + // Extra stats (hit/crit/haste/sp/ap/expertise/resilience/etc.) + if (!info->extraStats.empty()) { + auto statName = [](uint32_t t) -> const char* { + switch (t) { + case 12: return "Defense Rating"; + case 13: return "Dodge Rating"; + case 14: return "Parry Rating"; + case 15: return "Block Rating"; + case 16: case 17: case 18: case 31: return "Hit Rating"; + case 19: case 20: case 21: case 32: return "Critical Strike Rating"; + case 28: case 29: case 30: case 35: return "Haste Rating"; + case 34: return "Resilience Rating"; + case 36: return "Expertise Rating"; + case 37: return "Attack Power"; + case 38: return "Ranged Attack Power"; + case 45: return "Spell Power"; + case 46: return "Healing Power"; + case 47: return "Spell Damage"; + case 49: return "Mana per 5 sec."; + case 43: return "Spell Penetration"; + case 44: return "Block Value"; + default: return nullptr; + } + }; + for (const auto& es : info->extraStats) { + const char* nm = statName(es.statType); + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } + } + // Gem sockets (WotLK only — socketColor != 0 means socket present) + // socketColor bitmask: 1=Meta, 2=Red, 4=Yellow, 8=Blue + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + bool hasSocket = false; + for (int s = 0; s < 3; ++s) { + if (info->socketColor[s] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info->socketColor[s] & st.mask) { + ImGui::TextColored(st.col, "%s", st.label); + break; + } + } + } + if (hasSocket && info->socketBonus != 0) { + // Socket bonus ID maps to SpellItemEnchantment.dbc — lazy-load names + static std::unordered_map s_enchantNames; + static bool s_enchantNamesLoaded = false; + if (!s_enchantNamesLoaded && assetMgr) { + s_enchantNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nameField = lay ? lay->field("Name") : 8u; + if (nameField == 0xFFFFFFFF) nameField = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nameField >= fc) continue; + std::string ename = dbc->getString(r, nameField); + if (!ename.empty()) s_enchantNames[eid] = std::move(ename); + } + } + } + auto enchIt = s_enchantNames.find(info->socketBonus); + if (enchIt != s_enchantNames.end()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info->socketBonus); + } + } + // Item set membership + if (info->itemSetId != 0) { + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetMgr) { + s_setDataLoaded = true; + auto dbc = assetMgr->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = {"Item0","Item1","Item2","Item3","Item4","Item5","Item6","Item7","Item8","Item9"}; + static const char* spellKeys[10] = {"Spell0","Spell1","Spell2","Spell3","Spell4","Spell5","Spell6","Spell7","Spell8","Spell9"}; + static const char* thrKeys[10] = {"Threshold0","Threshold1","Threshold2","Threshold3","Threshold4","Threshold5","Threshold6","Threshold7","Threshold8","Threshold9"}; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : uint32_t(18 + i)); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : uint32_t(28 + i)); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : uint32_t(38 + i)); + } + s_setData[id] = std::move(e); + } + } + } + ImGui::Spacing(); + const auto& inv = gameHandler.getInventory(); + auto setIt = s_setData.find(info->itemSetId); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + for (int sl = 0; sl < game::Inventory::NUM_EQUIP_SLOTS; sl++) { + const auto& eq = inv.getEquipSlot(static_cast(sl)); + if (!eq.empty() && eq.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + if (total > 0) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + else if (!se.name.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler.getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info->itemSetId); + } + } + // Item spell effects (Use / Equip / Chance on Hit / Teaches) + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* triggerLabel = nullptr; + switch (sp.spellTrigger) { + case 0: triggerLabel = "Use"; break; + case 1: triggerLabel = "Equip"; break; + case 2: triggerLabel = "Chance on Hit"; break; + case 5: triggerLabel = "Teaches"; break; + } + if (!triggerLabel) continue; + // Use full spell description if available (matches inventory tooltip style) + const std::string& spDesc = gameHandler.getSpellDescription(sp.spellId); + const std::string& spText = !spDesc.empty() ? spDesc + : gameHandler.getSpellName(sp.spellId); + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: %s", triggerLabel, spText.c_str()); + ImGui::PopTextWrapPos(); + } + } + // Required level + if (info->requiredLevel > 1) + ImGui::TextDisabled("Requires Level %u", info->requiredLevel); + // Required skill (e.g. "Requires Blacksmithing (300)") + if (info->requiredSkill != 0 && info->requiredSkillRank > 0) { + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetMgr) { + s_skillNamesLoaded = true; + auto dbc = assetMgr->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 2u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler.getPlayerSkills(); + auto skPit = skills.find(info->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNames.find(info->requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info->requiredSkill, info->requiredSkillRank); + } + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info->requiredReputationFaction != 0 && info->requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetMgr) { + s_factionNamesLoaded = true; + auto dbc = assetMgr->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0u; + uint32_t nameF = layout ? (*layout)["Name"] : 20u; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info->requiredReputationRank < 8) + ? kRepRankNames[info->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info->allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClasses[] = { + { 1, "Warrior" }, + { 2, "Paladin" }, + { 4, "Hunter" }, + { 8, "Rogue" }, + { 16, "Priest" }, + { 32, "Death Knight" }, + { 64, "Shaman" }, + { 128, "Mage" }, + { 256, "Warlock" }, + { 1024, "Druid" }, + }; + int matchCount = 0; + for (const auto& kc : kClasses) + if (info->allowableClass & kc.mask) ++matchCount; + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info->allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + uint8_t pc = gameHandler.getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableClass & pmask)); + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(clColor, "%s", classBuf); + } + } + // Race restriction (e.g. "Races: Night Elf, Human") + if (info->allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRaces[] = { + { 1, "Human" }, + { 2, "Orc" }, + { 4, "Dwarf" }, + { 8, "Night Elf" }, + { 16, "Undead" }, + { 32, "Tauren" }, + { 64, "Gnome" }, + { 128, "Troll" }, + { 512, "Blood Elf" }, + { 1024, "Draenei" }, + }; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + if ((info->allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info->allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info->allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + uint8_t pr = gameHandler.getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0u; + bool playerAllowed = (pmask == 0 || (info->allowableRace & pmask)); + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + // Flavor / lore text (shown in gold italic in WoW, use a yellow-ish dim color here) + if (!info->description.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(300.0f); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 0.85f), "\"%s\"", info->description.c_str()); + ImGui::PopTextWrapPos(); + } if (info->sellPrice > 0) { uint32_t g = info->sellPrice / 10000; uint32_t s = (info->sellPrice / 100) % 100; uint32_t c = info->sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } if (ImGui::GetIO().KeyShift && info->inventoryType > 0) { @@ -978,7 +1874,15 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { float speed = static_cast(eq->item.delayMs) / 1000.0f; float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + char eqDmg[64], eqSpd[32]; + std::snprintf(eqDmg, sizeof(eqDmg), "%d - %d Damage", + static_cast(eq->item.damageMin), static_cast(eq->item.damageMax)); + std::snprintf(eqSpd, sizeof(eqSpd), "Speed %.2f", speed); + float eqSpdW = ImGui::CalcTextSize(eqSpd).x; + ImGui::Text("%s", eqDmg); + ImGui::SameLine(ImGui::GetWindowWidth() - eqSpdW - 16.0f); + ImGui::Text("%s", eqSpd); + ImGui::TextDisabled("(%.1f damage per second)", dps); } if (eq->item.armor > 0) { ImGui::Text("%d Armor", eq->item.armor); @@ -992,6 +1896,28 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (!eqBonusLine.empty()) { ImGui::TextColored(green, "%s", eqBonusLine.c_str()); } + // Extra stats for the equipped item + for (const auto& es : eq->item.extraStats) { + const char* nm = nullptr; + switch (es.statType) { + case 12: nm = "Defense Rating"; break; + case 13: nm = "Dodge Rating"; break; + case 14: nm = "Parry Rating"; break; + case 16: case 17: case 18: case 31: nm = "Hit Rating"; break; + case 19: case 20: case 21: case 32: nm = "Critical Strike Rating"; break; + case 28: case 29: case 30: case 35: nm = "Haste Rating"; break; + case 34: nm = "Resilience Rating"; break; + case 36: nm = "Expertise Rating"; break; + case 37: nm = "Attack Power"; break; + case 38: nm = "Ranged Attack Power"; break; + case 45: nm = "Spell Power"; break; + case 46: nm = "Healing Power"; break; + case 49: nm = "Mana per 5 sec."; break; + default: break; + } + if (nm && es.statValue > 0) + ImGui::TextColored(green, "+%d %s", es.statValue, nm); + } } } ImGui::EndTooltip(); @@ -1004,10 +1930,13 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Find next special element: URL or WoW link size_t urlStart = text.find("https://", pos); - // Find next WoW item link: |cXXXXXXXX|Hitem:ENTRY:...|h[Name]|h|r + // Find next WoW link (may be colored with |c prefix or bare |H) size_t linkStart = text.find("|c", pos); - // Also handle bare |Hitem: without color prefix - size_t bareLinkStart = text.find("|Hitem:", pos); + // Also handle bare |H links without color prefix + size_t bareItem = text.find("|Hitem:", pos); + size_t bareSpell = text.find("|Hspell:", pos); + size_t bareQuest = text.find("|Hquest:", pos); + size_t bareLinkStart = std::min({bareItem, bareSpell, bareQuest}); // Determine which comes first size_t nextSpecial = std::min({urlStart, linkStart, bareLinkStart}); @@ -1040,18 +1969,30 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { if (nextSpecial == linkStart && text.size() > linkStart + 10) { // Parse |cAARRGGBB color linkColor = parseWowColor(text, linkStart); - hStart = text.find("|Hitem:", linkStart + 10); + // Find the nearest |H link of any supported type + size_t hItem = text.find("|Hitem:", linkStart + 10); + size_t hSpell = text.find("|Hspell:", linkStart + 10); + size_t hQuest = text.find("|Hquest:", linkStart + 10); + size_t hAch = text.find("|Hachievement:", linkStart + 10); + hStart = std::min({hItem, hSpell, hQuest, hAch}); } else if (nextSpecial == bareLinkStart) { hStart = bareLinkStart; } if (hStart != std::string::npos) { - // Parse item entry: |Hitem:ENTRY:... - size_t entryStart = hStart + 7; // skip "|Hitem:" + // Determine link type + const bool isSpellLink = (text.compare(hStart, 8, "|Hspell:") == 0); + const bool isQuestLink = (text.compare(hStart, 8, "|Hquest:") == 0); + const bool isAchievLink = (text.compare(hStart, 14, "|Hachievement:") == 0); + // Default: item link + + // Parse the first numeric ID after |Htype: + size_t idOffset = isSpellLink ? 8 : (isQuestLink ? 8 : (isAchievLink ? 14 : 7)); + size_t entryStart = hStart + idOffset; size_t entryEnd = text.find(':', entryStart); - uint32_t itemEntry = 0; + uint32_t linkId = 0; if (entryEnd != std::string::npos) { - itemEntry = static_cast(strtoul( + linkId = static_cast(strtoul( text.substr(entryStart, entryEnd - entryStart).c_str(), nullptr, 10)); } @@ -1060,37 +2001,122 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { size_t nameTagEnd = (nameTagStart != std::string::npos) ? text.find("]|h", nameTagStart + 3) : std::string::npos; - std::string itemName = "Unknown Item"; + std::string linkName = isSpellLink ? "Unknown Spell" + : isQuestLink ? "Unknown Quest" + : isAchievLink ? "Unknown Achievement" + : "Unknown Item"; if (nameTagStart != std::string::npos && nameTagEnd != std::string::npos) { - itemName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); + linkName = text.substr(nameTagStart + 3, nameTagEnd - nameTagStart - 3); } // Find end of entire link sequence (|r or after ]|h) - size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + 7; + size_t linkEnd = (nameTagEnd != std::string::npos) ? nameTagEnd + 3 : hStart + idOffset; size_t resetPos = text.find("|r", linkEnd); if (resetPos != std::string::npos && resetPos <= linkEnd + 2) { linkEnd = resetPos + 2; } - // Ensure item info is cached (trigger query if needed) - if (itemEntry > 0) { - gameHandler.ensureItemInfo(itemEntry); - } - - // Render bracketed item name in quality color - std::string display = "[" + itemName + "]"; - ImGui::PushStyleColor(ImGuiCol_Text, linkColor); - ImGui::TextWrapped("%s", display.c_str()); - ImGui::PopStyleColor(); - - if (ImGui::IsItemHovered()) { - ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (!isSpellLink && !isQuestLink && !isAchievLink) { + // --- Item link --- + uint32_t itemEntry = linkId; if (itemEntry > 0) { - renderItemLinkTooltip(itemEntry); + gameHandler.ensureItemInfo(itemEntry); + } + + // Show small icon before item link if available + if (itemEntry > 0) { + const auto* chatInfo = gameHandler.getItemInfo(itemEntry); + if (chatInfo && chatInfo->valid && chatInfo->displayInfoId != 0) { + VkDescriptorSet chatIcon = inventoryScreen.getItemIcon(chatInfo->displayInfoId); + if (chatIcon) { + ImGui::Image((ImTextureID)(uintptr_t)chatIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + renderItemLinkTooltip(itemEntry); + } + ImGui::SameLine(0, 2); + } + } + } + + // Render bracketed item name in quality color + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (itemEntry > 0) { + renderItemLinkTooltip(itemEntry); + } + } + } else if (isSpellLink) { + // --- Spell link: |Hspell:SPELLID:RANK|h[Name]|h --- + // Small icon (use spell icon cache if available) + VkDescriptorSet spellIcon = (linkId > 0) ? getSpellIcon(linkId, assetMgr) : VK_NULL_HANDLE; + if (spellIcon) { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(12, 12)); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + ImGui::SameLine(0, 2); + } + + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, linkColor); + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (linkId > 0) { + spellbookScreen.renderSpellInfoTooltip(linkId, gameHandler, assetMgr); + } + } + } else if (isQuestLink) { + // --- Quest link: |Hquest:QUESTID:QUESTLEVEL|h[Name]|h --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.84f, 0.0f, 1.0f)); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%s", linkName.c_str()); + // Parse quest level (second field after questId) + if (entryEnd != std::string::npos) { + size_t lvlEnd = text.find(':', entryEnd + 1); + if (lvlEnd == std::string::npos) lvlEnd = text.find('|', entryEnd + 1); + if (lvlEnd != std::string::npos) { + uint32_t qLvl = static_cast(strtoul( + text.substr(entryEnd + 1, lvlEnd - entryEnd - 1).c_str(), nullptr, 10)); + if (qLvl > 0) ImGui::TextDisabled("Level %u Quest", qLvl); + } + } + ImGui::TextDisabled("Click quest log to view details"); + ImGui::EndTooltip(); + } + // Click: open quest log and select this quest if we have it + if (ImGui::IsItemClicked() && linkId > 0) { + questLogScreen.openAndSelectQuest(linkId); + } + } else { + // --- Achievement link --- + std::string display = "[" + linkName + "]"; + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); // gold + ImGui::TextWrapped("%s", display.c_str()); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + ImGui::SetTooltip("Achievement: %s", linkName.c_str()); } } - // Shift-click: insert item link into chat input + // Shift-click: insert entire link back into chat input if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { std::string linkText = text.substr(nextSpecial, linkEnd - nextSpecial); size_t curLen = strlen(chatInputBuffer); @@ -1176,10 +2202,80 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { } }; + // Determine local player name for mention detection (case-insensitive) + std::string selfNameLower; + { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && !ch->name.empty()) { + selfNameLower = ch->name; + for (auto& c : selfNameLower) c = static_cast(std::tolower(static_cast(c))); + } + } + + // Scan NEW messages (beyond chatMentionSeenCount_) for mentions and play notification sound + if (!selfNameLower.empty() && chatHistory.size() > chatMentionSeenCount_) { + for (size_t mi = chatMentionSeenCount_; mi < chatHistory.size(); ++mi) { + const auto& mMsg = chatHistory[mi]; + // Skip outgoing whispers, system, and monster messages + if (mMsg.type == game::ChatType::WHISPER_INFORM || + mMsg.type == game::ChatType::SYSTEM) continue; + // Case-insensitive search in message body + std::string bodyLower = mMsg.message; + for (auto& c : bodyLower) c = static_cast(std::tolower(static_cast(c))); + if (bodyLower.find(selfNameLower) != std::string::npos) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } + break; // play at most once per scan pass + } + } + chatMentionSeenCount_ = chatHistory.size(); + } else if (chatHistory.size() <= chatMentionSeenCount_) { + chatMentionSeenCount_ = chatHistory.size(); // reset if history was cleared + } + + // Scan NEW messages for incoming whispers and push a toast notification + { + size_t histSize = chatHistory.size(); + if (histSize < whisperSeenCount_) whisperSeenCount_ = histSize; // cleared + for (size_t wi = whisperSeenCount_; wi < histSize; ++wi) { + const auto& wMsg = chatHistory[wi]; + if (wMsg.type == game::ChatType::WHISPER || + wMsg.type == game::ChatType::RAID_BOSS_WHISPER) { + WhisperToastEntry toast; + toast.sender = wMsg.senderName; + if (toast.sender.empty() && wMsg.senderGuid != 0) + toast.sender = gameHandler.lookupName(wMsg.senderGuid); + if (toast.sender.empty()) toast.sender = "Unknown"; + // Truncate preview to 60 chars + toast.preview = wMsg.message.size() > 60 + ? wMsg.message.substr(0, 57) + "..." + : wMsg.message; + toast.age = 0.0f; + // Keep at most 3 stacked toasts + if (whisperToasts_.size() >= 3) whisperToasts_.erase(whisperToasts_.begin()); + whisperToasts_.push_back(std::move(toast)); + } + } + whisperSeenCount_ = histSize; + } + + int chatMsgIdx = 0; for (const auto& msg : chatHistory) { if (!shouldShowMessage(msg, activeChatTab_)) continue; std::string processedMessage = replaceGenderPlaceholders(msg.message, gameHandler); + // Resolve sender name at render time in case it wasn't available at parse time. + // This handles the race where SMSG_MESSAGECHAT arrives before the entity spawns. + const std::string& resolvedSenderName = [&]() -> const std::string& { + if (!msg.senderName.empty()) return msg.senderName; + if (msg.senderGuid == 0) return msg.senderName; + const std::string& cached = gameHandler.lookupName(msg.senderGuid); + if (!cached.empty()) return cached; + return msg.senderName; + }(); + ImVec4 color = getChatTypeColor(msg.type); // Optional timestamp prefix @@ -1197,43 +2293,131 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { tsPrefix = tsBuf; } - if (msg.type == game::ChatType::SYSTEM) { - renderTextWithLinks(tsPrefix + processedMessage, color); - } else if (msg.type == game::ChatType::TEXT_EMOTE) { - renderTextWithLinks(tsPrefix + processedMessage, color); - } else if (!msg.senderName.empty()) { - if (msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { - std::string fullMsg = tsPrefix + msg.senderName + " says: " + processedMessage; - renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_YELL) { - std::string fullMsg = tsPrefix + msg.senderName + " yells: " + processedMessage; - renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { - std::string fullMsg = tsPrefix + msg.senderName + " whispers: " + processedMessage; - renderTextWithLinks(fullMsg, color); - } else if (msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { - std::string fullMsg = tsPrefix + msg.senderName + " " + processedMessage; - renderTextWithLinks(fullMsg, color); + // Build chat tag prefix: , , from chatTag bitmask + std::string tagPrefix; + if (msg.chatTag & 0x04) tagPrefix = " "; + else if (msg.chatTag & 0x01) tagPrefix = " "; + else if (msg.chatTag & 0x02) tagPrefix = " "; + + // Build full message string for this entry + std::string fullMsg; + if (msg.type == game::ChatType::SYSTEM || msg.type == game::ChatType::TEXT_EMOTE) { + fullMsg = tsPrefix + processedMessage; + } else if (!resolvedSenderName.empty()) { + if (msg.type == game::ChatType::SAY || + msg.type == game::ChatType::MONSTER_SAY || msg.type == game::ChatType::MONSTER_PARTY) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " says: " + processedMessage; + } else if (msg.type == game::ChatType::YELL || msg.type == game::ChatType::MONSTER_YELL) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " yells: " + processedMessage; + } else if (msg.type == game::ChatType::WHISPER || + msg.type == game::ChatType::MONSTER_WHISPER || msg.type == game::ChatType::RAID_BOSS_WHISPER) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " whispers: " + processedMessage; + } else if (msg.type == game::ChatType::WHISPER_INFORM) { + const std::string& target = !msg.receiverName.empty() ? msg.receiverName : resolvedSenderName; + fullMsg = tsPrefix + "To " + target + ": " + processedMessage; + } else if (msg.type == game::ChatType::EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE || msg.type == game::ChatType::RAID_BOSS_EMOTE) { + fullMsg = tsPrefix + tagPrefix + resolvedSenderName + " " + processedMessage; } else if (msg.type == game::ChatType::CHANNEL && !msg.channelName.empty()) { int chIdx = gameHandler.getChannelIndex(msg.channelName); std::string chDisplay = chIdx > 0 ? "[" + std::to_string(chIdx) + ". " + msg.channelName + "]" : "[" + msg.channelName + "]"; - std::string fullMsg = tsPrefix + chDisplay + " [" + msg.senderName + "]: " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + chDisplay + " [" + tagPrefix + resolvedSenderName + "]: " + processedMessage; } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + msg.senderName + ": " + processedMessage; - renderTextWithLinks(fullMsg, color); + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + tagPrefix + resolvedSenderName + ": " + processedMessage; } } else { - std::string fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; - renderTextWithLinks(fullMsg, color); + bool isGroupType = + msg.type == game::ChatType::PARTY || + msg.type == game::ChatType::GUILD || + msg.type == game::ChatType::OFFICER || + msg.type == game::ChatType::RAID || + msg.type == game::ChatType::RAID_LEADER || + msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::BATTLEGROUND || + msg.type == game::ChatType::BATTLEGROUND_LEADER; + if (isGroupType) { + fullMsg = tsPrefix + "[" + std::string(getChatTypeName(msg.type)) + "] " + processedMessage; + } else { + fullMsg = tsPrefix + processedMessage; + } } + + // Detect mention: does this message contain the local player's name? + bool isMention = false; + if (!selfNameLower.empty() && + msg.type != game::ChatType::WHISPER_INFORM && + msg.type != game::ChatType::SYSTEM) { + std::string msgLower = fullMsg; + for (auto& c : msgLower) c = static_cast(std::tolower(static_cast(c))); + isMention = (msgLower.find(selfNameLower) != std::string::npos); + } + + // Render message in a group so we can attach a right-click context menu + ImGui::PushID(chatMsgIdx++); + if (isMention) { + // Golden highlight strip behind the text + ImVec2 groupMin = ImGui::GetCursorScreenPos(); + float availW = ImGui::GetContentRegionAvail().x; + float lineH = ImGui::GetTextLineHeightWithSpacing(); + ImGui::GetWindowDrawList()->AddRectFilled( + groupMin, + ImVec2(groupMin.x + availW, groupMin.y + lineH), + IM_COL32(255, 200, 50, 45)); // soft golden tint + } + ImGui::BeginGroup(); + renderTextWithLinks(fullMsg, isMention ? ImVec4(1.0f, 0.9f, 0.35f, 1.0f) : color); + ImGui::EndGroup(); + + // Right-click context menu (only for player messages with a sender) + bool isPlayerMsg = !resolvedSenderName.empty() && + msg.type != game::ChatType::SYSTEM && + msg.type != game::ChatType::TEXT_EMOTE && + msg.type != game::ChatType::MONSTER_SAY && + msg.type != game::ChatType::MONSTER_YELL && + msg.type != game::ChatType::MONSTER_WHISPER && + msg.type != game::ChatType::MONSTER_EMOTE && + msg.type != game::ChatType::MONSTER_PARTY && + msg.type != game::ChatType::RAID_BOSS_WHISPER && + msg.type != game::ChatType::RAID_BOSS_EMOTE; + + if (isPlayerMsg && ImGui::BeginPopupContextItem("ChatMsgCtx")) { + ImGui::TextDisabled("%s", resolvedSenderName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; // WHISPER + strncpy(whisperTargetBuffer, resolvedSenderName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(resolvedSenderName); + } + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(resolvedSenderName); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(resolvedSenderName); + } + ImGui::EndPopup(); + } + + ImGui::PopID(); } - // Auto-scroll to bottom - if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) { - ImGui::SetScrollHereY(1.0f); + // Auto-scroll to bottom; track whether user has scrolled up + { + float scrollY = ImGui::GetScrollY(); + float scrollMaxY = ImGui::GetScrollMaxY(); + bool atBottom = (scrollMaxY <= 0.0f) || (scrollY >= scrollMaxY - 2.0f); + if (atBottom || chatForceScrollToBottom_) { + ImGui::SetScrollHereY(1.0f); + chatScrolledUp_ = false; + chatForceScrollToBottom_ = false; + } else { + chatScrolledUp_ = true; + } } ImGui::EndChild(); @@ -1241,6 +2425,17 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { // Reset font scale after chat history ImGui::SetWindowFontScale(1.0f); + // "Jump to bottom" indicator when scrolled up + if (chatScrolledUp_) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.35f, 0.7f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); + if (ImGui::SmallButton(" v New messages ")) { + chatForceScrollToBottom_ = true; + } + ImGui::PopStyleColor(2); + ImGui::SameLine(); + } + ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); @@ -1253,8 +2448,8 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::Text("Type:"); ImGui::SameLine(); ImGui::SetNextItemWidth(100); - const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE" }; - ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 10); + const char* chatTypes[] = { "SAY", "YELL", "PARTY", "GUILD", "WHISPER", "RAID", "OFFICER", "BATTLEGROUND", "RAID WARNING", "INSTANCE", "CHANNEL" }; + ImGui::Combo("##ChatType", &selectedChatType, chatTypes, 11); // Auto-fill whisper target when switching to WHISPER mode if (selectedChatType == 4 && lastChatType != 4) { @@ -1281,6 +2476,27 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { ImGui::InputText("##WhisperTarget", whisperTargetBuffer, sizeof(whisperTargetBuffer)); } + // Show channel picker if CHANNEL is selected + if (selectedChatType == 10) { + const auto& channels = gameHandler.getJoinedChannels(); + if (channels.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(no channels joined)"); + } else { + ImGui::SameLine(); + if (selectedChannelIdx >= static_cast(channels.size())) selectedChannelIdx = 0; + ImGui::SetNextItemWidth(140); + if (ImGui::BeginCombo("##ChannelPicker", channels[selectedChannelIdx].c_str())) { + for (int ci = 0; ci < static_cast(channels.size()); ++ci) { + bool selected = (ci == selectedChannelIdx); + if (ImGui::Selectable(channels[ci].c_str(), selected)) selectedChannelIdx = ci; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } + } + ImGui::SameLine(); ImGui::Text("Message:"); ImGui::SameLine(); @@ -1301,22 +2517,41 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { std::string cmd = buf.substr(1, sp - 1); for (char& c : cmd) c = std::tolower(c); int detected = -1; + bool isReply = false; if (cmd == "s" || cmd == "say") detected = 0; else if (cmd == "y" || cmd == "yell" || cmd == "shout") detected = 1; else if (cmd == "p" || cmd == "party") detected = 2; else if (cmd == "g" || cmd == "guild") detected = 3; else if (cmd == "w" || cmd == "whisper" || cmd == "tell" || cmd == "t") detected = 4; + else if (cmd == "r" || cmd == "reply") { detected = 4; isReply = true; } else if (cmd == "raid" || cmd == "rsay" || cmd == "ra") detected = 5; else if (cmd == "o" || cmd == "officer" || cmd == "osay") detected = 6; else if (cmd == "bg" || cmd == "battleground") detected = 7; else if (cmd == "rw" || cmd == "raidwarning") detected = 8; else if (cmd == "i" || cmd == "instance") detected = 9; - if (detected >= 0 && selectedChatType != detected) { + else if (cmd.size() == 1 && cmd[0] >= '1' && cmd[0] <= '9') detected = 10; // /1, /2 etc. + if (detected >= 0 && (selectedChatType != detected || detected == 10 || isReply)) { + // For channel shortcuts, also update selectedChannelIdx + if (detected == 10) { + int chanIdx = cmd[0] - '1'; // /1 -> index 0, /2 -> index 1, etc. + const auto& chans = gameHandler.getJoinedChannels(); + if (chanIdx >= 0 && chanIdx < static_cast(chans.size())) { + selectedChannelIdx = chanIdx; + } + } selectedChatType = detected; // Strip the prefix, keep only the message part std::string remaining = buf.substr(sp + 1); - // For whisper, first word after /w is the target - if (detected == 4) { + // /r reply: pre-fill whisper target from last whisper sender + if (detected == 4 && isReply) { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (!lastSender.empty()) { + strncpy(whisperTargetBuffer, lastSender.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + } + // remaining is the message — don't extract a target from it + } else if (detected == 4) { + // For whisper, first word after /w is the target size_t msgStart = remaining.find(' '); if (msgStart != std::string::npos) { std::string wTarget = remaining.substr(0, msgStart); @@ -1349,24 +2584,240 @@ void GameScreen::renderChatWindow(game::GameHandler& gameHandler) { case 6: inputColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); break; // OFFICER - green case 7: inputColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // BG - orange case 8: inputColor = ImVec4(1.0f, 0.3f, 0.0f, 1.0f); break; // RAID WARNING - red-orange - case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue + case 9: inputColor = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); break; // INSTANCE - blue + case 10: inputColor = ImVec4(0.3f, 0.9f, 0.9f, 1.0f); break; // CHANNEL - cyan default: inputColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // SAY - white } ImGui::PushStyleColor(ImGuiCol_Text, inputColor); auto inputCallback = [](ImGuiInputTextCallbackData* data) -> int { auto* self = static_cast(data->UserData); - if (self && self->chatInputMoveCursorToEnd) { + if (!self) return 0; + + // Cursor-to-end after channel switch + if (self->chatInputMoveCursorToEnd) { int len = static_cast(std::strlen(data->Buf)); data->CursorPos = len; data->SelectionStart = len; data->SelectionEnd = len; self->chatInputMoveCursorToEnd = false; } + + // Tab: slash-command autocomplete + if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) { + if (data->BufTextLen > 0 && data->Buf[0] == '/') { + // Split buffer into command word and trailing args + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + std::string word = (spacePos != std::string::npos) ? fullBuf.substr(0, spacePos) : fullBuf; + std::string rest = (spacePos != std::string::npos) ? fullBuf.substr(spacePos) : ""; + + // Normalize to lowercase for matching + std::string lowerWord = word; + for (auto& ch : lowerWord) ch = static_cast(std::tolower(static_cast(ch))); + + static const std::vector kCmds = { + "/afk", "/assist", "/away", + "/cancelaura", "/cancelform", "/cancellogout", "/cancelshapeshift", + "/cast", "/castsequence", "/chathelp", "/clear", "/clearfocus", + "/clearmainassist", "/clearmaintank", "/cleartarget", "/cloak", + "/combatlog", "/dance", "/dismount", "/dnd", "/do", "/duel", "/dump", + "/e", "/emote", "/equip", "/equipset", + "/focus", "/follow", "/forfeit", "/friend", + "/g", "/gdemote", "/ginvite", "/gkick", "/gleader", "/gmotd", + "/gmticket", "/gpromote", "/gquit", "/grouploot", "/groster", + "/guild", "/guildinfo", + "/helm", "/help", + "/i", "/ignore", "/inspect", "/instance", "/invite", + "/j", "/join", "/kick", "/kneel", + "/l", "/leave", "/leaveparty", "/loc", "/local", "/logout", + "/macrohelp", "/mainassist", "/maintank", "/mark", "/me", + "/notready", + "/p", "/party", "/petaggressive", "/petattack", "/petdefensive", + "/petdismiss", "/petfollow", "/pethalt", "/petpassive", "/petstay", + "/played", "/pvp", + "/r", "/raid", "/raidinfo", "/raidwarning", "/random", "/ready", + "/readycheck", "/reload", "/reloadui", "/removefriend", + "/reply", "/rl", "/roll", "/run", + "/s", "/say", "/score", "/screenshot", "/script", "/setloot", + "/shout", "/sit", "/stand", + "/startattack", "/stopattack", "/stopcasting", "/stopfollow", "/stopmacro", + "/t", "/target", "/targetenemy", "/targetfriend", "/targetlast", + "/threat", "/ticket", "/time", "/trade", + "/unignore", "/uninvite", "/unstuck", "/use", + "/w", "/whisper", "/who", "/wts", "/wtb", + "/y", "/yell", "/zone" + }; + + // New session if prefix changed + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerWord) { + self->chatTabPrefix_ = lowerWord; + self->chatTabMatches_.clear(); + for (const auto& cmd : kCmds) { + if (cmd.size() >= lowerWord.size() && + cmd.compare(0, lowerWord.size(), lowerWord) == 0) + self->chatTabMatches_.push_back(cmd); + } + self->chatTabMatchIdx_ = 0; + } else { + // Cycle forward through matches + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + // Append trailing space when match is unambiguous + if (self->chatTabMatches_.size() == 1 && rest.empty()) + match += ' '; + std::string newBuf = match + rest; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } else if (data->BufTextLen > 0) { + // Player name tab-completion for commands like /w, /whisper, /invite, /trade, /duel + // Also works for plain text (completes nearby player names) + std::string fullBuf(data->Buf, data->BufTextLen); + size_t spacePos = fullBuf.find(' '); + bool isNameCommand = false; + std::string namePrefix; + size_t replaceStart = 0; + + if (fullBuf[0] == '/' && spacePos != std::string::npos) { + std::string cmd = fullBuf.substr(0, spacePos); + for (char& c : cmd) c = static_cast(std::tolower(static_cast(c))); + // Commands that take a player name as the first argument after the command + if (cmd == "/w" || cmd == "/whisper" || cmd == "/invite" || + cmd == "/trade" || cmd == "/duel" || cmd == "/follow" || + cmd == "/inspect" || cmd == "/friend" || cmd == "/removefriend" || + cmd == "/ignore" || cmd == "/unignore" || cmd == "/who" || + cmd == "/t" || cmd == "/target" || cmd == "/kick" || + cmd == "/uninvite" || cmd == "/ginvite" || cmd == "/gkick") { + // Extract the partial name after the space + namePrefix = fullBuf.substr(spacePos + 1); + // Only complete the first word after the command + size_t nameSpace = namePrefix.find(' '); + if (nameSpace == std::string::npos) { + isNameCommand = true; + replaceStart = spacePos + 1; + } + } + } + + if (isNameCommand && !namePrefix.empty()) { + std::string lowerPrefix = namePrefix; + for (char& c : lowerPrefix) c = static_cast(std::tolower(static_cast(c))); + + if (self->chatTabMatchIdx_ < 0 || self->chatTabPrefix_ != lowerPrefix) { + self->chatTabPrefix_ = lowerPrefix; + self->chatTabMatches_.clear(); + // Search player name cache and nearby entities + auto* gh = self->cachedGameHandler_; + // Party/raid members + for (const auto& m : gh->getPartyData().members) { + if (m.name.empty()) continue; + std::string lname = m.name; + for (char& c : lname) c = static_cast(std::tolower(static_cast(c))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) + self->chatTabMatches_.push_back(m.name); + } + // Friends + for (const auto& c : gh->getContacts()) { + if (!c.isFriend() || c.name.empty()) continue; + std::string lname = c.name; + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + // Avoid duplicates from party + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == c.name) { dup = true; break; } + if (!dup) self->chatTabMatches_.push_back(c.name); + } + } + // Nearby visible players + for (const auto& [guid, entity] : gh->getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) continue; + std::string lname = player->getName(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == player->getName()) { dup = true; break; } + if (!dup) self->chatTabMatches_.push_back(player->getName()); + } + } + // Last whisper sender + if (!gh->getLastWhisperSender().empty()) { + std::string lname = gh->getLastWhisperSender(); + for (char& cc : lname) cc = static_cast(std::tolower(static_cast(cc))); + if (lname.compare(0, lowerPrefix.size(), lowerPrefix) == 0) { + bool dup = false; + for (const auto& em : self->chatTabMatches_) + if (em == gh->getLastWhisperSender()) { dup = true; break; } + if (!dup) self->chatTabMatches_.insert(self->chatTabMatches_.begin(), gh->getLastWhisperSender()); + } + } + self->chatTabMatchIdx_ = 0; + } else { + ++self->chatTabMatchIdx_; + if (self->chatTabMatchIdx_ >= static_cast(self->chatTabMatches_.size())) + self->chatTabMatchIdx_ = 0; + } + + if (!self->chatTabMatches_.empty()) { + std::string match = self->chatTabMatches_[self->chatTabMatchIdx_]; + std::string prefix = fullBuf.substr(0, replaceStart); + std::string newBuf = prefix + match; + if (self->chatTabMatches_.size() == 1) newBuf += ' '; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, newBuf.c_str()); + } + } + } + return 0; + } + + // Up/Down arrow: cycle through sent message history + if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) { + // Any history navigation resets autocomplete + self->chatTabMatchIdx_ = -1; + self->chatTabMatches_.clear(); + + const int histSize = static_cast(self->chatSentHistory_.size()); + if (histSize == 0) return 0; + + if (data->EventKey == ImGuiKey_UpArrow) { + // Go back in history + if (self->chatHistoryIdx_ == -1) + self->chatHistoryIdx_ = histSize - 1; + else if (self->chatHistoryIdx_ > 0) + --self->chatHistoryIdx_; + } else if (data->EventKey == ImGuiKey_DownArrow) { + if (self->chatHistoryIdx_ == -1) return 0; + ++self->chatHistoryIdx_; + if (self->chatHistoryIdx_ >= histSize) { + self->chatHistoryIdx_ = -1; + data->DeleteChars(0, data->BufTextLen); + return 0; + } + } + + if (self->chatHistoryIdx_ >= 0 && self->chatHistoryIdx_ < histSize) { + const std::string& entry = self->chatSentHistory_[self->chatHistoryIdx_]; + data->DeleteChars(0, data->BufTextLen); + data->InsertChars(0, entry.c_str()); + } + } return 0; }; - ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackAlways; + ImGuiInputTextFlags inputFlags = ImGuiInputTextFlags_EnterReturnsTrue | + ImGuiInputTextFlags_CallbackAlways | + ImGuiInputTextFlags_CallbackHistory | + ImGuiInputTextFlags_CallbackCompletion; if (ImGui::InputText("##ChatInput", chatInputBuffer, sizeof(chatInputBuffer), inputFlags, inputCallback, this)) { sendChatMessage(gameHandler); // Close chat input on send so movement keys work immediately. @@ -1395,16 +2846,31 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto& io = ImGui::GetIO(); auto& input = core::Input::getInstance(); + // If the user is typing (or about to focus chat this frame), do not allow + // A-Z or 1-0 shortcuts to fire. + if (!io.WantTextInput && !chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { + refocusChatInput = true; + chatInputBuffer[0] = '/'; + chatInputBuffer[1] = '\0'; + chatInputMoveCursorToEnd = true; + } + if (!io.WantTextInput && !chatInputActive && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHAT, true)) { + refocusChatInput = true; + } + + const bool textFocus = chatInputActive || refocusChatInput || io.WantTextInput; + // Tab targeting (when keyboard not captured by UI) if (!io.WantCaptureKeyboard) { - if (input.isKeyJustPressed(SDL_SCANCODE_TAB)) { + // When typing in chat (or any text input), never treat keys as gameplay/UI shortcuts. + if (!textFocus && input.isKeyJustPressed(SDL_SCANCODE_TAB)) { const auto& movement = gameHandler.getMovementInfo(); gameHandler.tabTarget(movement.x, movement.y, movement.z); } - if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SETTINGS, true)) { if (showSettingsWindow) { - // Close settings window if open showSettingsWindow = false; } else if (showEscapeMenu) { showEscapeMenu = false; @@ -1415,52 +2881,159 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { gameHandler.closeLoot(); } else if (gameHandler.isGossipWindowOpen()) { gameHandler.closeGossip(); + } else if (gameHandler.isVendorWindowOpen()) { + gameHandler.closeVendor(); + } else if (gameHandler.isBarberShopOpen()) { + gameHandler.closeBarberShop(); + } else if (gameHandler.isBankOpen()) { + gameHandler.closeBank(); + } else if (gameHandler.isTrainerWindowOpen()) { + gameHandler.closeTrainer(); + } else if (gameHandler.isMailboxOpen()) { + gameHandler.closeMailbox(); + } else if (gameHandler.isAuctionHouseOpen()) { + gameHandler.closeAuctionHouse(); + } else if (gameHandler.isQuestDetailsOpen()) { + gameHandler.declineQuest(); + } else if (gameHandler.isQuestOfferRewardOpen()) { + gameHandler.closeQuestOfferReward(); + } else if (gameHandler.isQuestRequestItemsOpen()) { + gameHandler.closeQuestRequestItems(); + } else if (gameHandler.isTradeOpen()) { + gameHandler.cancelTrade(); + } else if (showWhoWindow_) { + showWhoWindow_ = false; + } else if (showCombatLog_) { + showCombatLog_ = false; + } else if (showSocialFrame_) { + showSocialFrame_ = false; + } else if (talentScreen.isOpen()) { + talentScreen.setOpen(false); + } else if (spellbookScreen.isOpen()) { + spellbookScreen.setOpen(false); + } else if (questLogScreen.isOpen()) { + questLogScreen.setOpen(false); + } else if (inventoryScreen.isCharacterOpen()) { + inventoryScreen.toggleCharacter(); + } else if (inventoryScreen.isOpen()) { + inventoryScreen.setOpen(false); + } else if (showWorldMap_) { + showWorldMap_ = false; } else { showEscapeMenu = true; } } - // V — toggle nameplates (WoW default keybinding) - if (input.isKeyJustPressed(SDL_SCANCODE_V)) { - showNameplates_ = !showNameplates_; - } + if (!textFocus) { + // Toggle character screen (C) and inventory/bags (I) + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_CHARACTER_SCREEN)) { + const bool wasOpen = inventoryScreen.isCharacterOpen(); + inventoryScreen.toggleCharacter(); + if (!wasOpen && gameHandler.isConnected()) { + gameHandler.requestPlayedTime(); + } + } - // Action bar keys (1-9, 0, -, =) - static const SDL_Scancode actionBarKeys[] = { - SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, - SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, - SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS - }; - const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); - const auto& bar = gameHandler.getActionBar(); - for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { - if (input.isKeyJustPressed(actionBarKeys[i])) { - int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; - if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(bar[slotIdx].id, target); - } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { - gameHandler.useItemById(bar[slotIdx].id); + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_INVENTORY)) { + inventoryScreen.toggle(); + } + + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_NAMEPLATES)) { + if (ImGui::GetIO().KeyShift) + showFriendlyNameplates_ = !showFriendlyNameplates_; + else + showNameplates_ = !showNameplates_; + } + + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_WORLD_MAP)) { + showWorldMap_ = !showWorldMap_; + } + + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_MINIMAP)) { + showMinimap_ = !showMinimap_; + } + + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_RAID_FRAMES)) { + showRaidFrames_ = !showRaidFrames_; + } + + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_ACHIEVEMENTS)) { + showAchievementWindow_ = !showAchievementWindow_; + } + if (KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_SKILLS)) { + showSkillsWindow_ = !showSkillsWindow_; + } + + // Toggle Titles window with H (hero/title screen — no conflicting keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + showTitlesWindow_ = !showTitlesWindow_; + } + + // Screenshot (PrintScreen key) + if (input.isKeyJustPressed(SDL_SCANCODE_PRINTSCREEN)) { + takeScreenshot(gameHandler); + } + + // Action bar keys (1-9, 0, -, =) + static const SDL_Scancode actionBarKeys[] = { + SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, + SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, + SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS + }; + const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlDown = input.isKeyPressed(SDL_SCANCODE_LCTRL) || input.isKeyPressed(SDL_SCANCODE_RCTRL); + const auto& bar = gameHandler.getActionBar(); + + // Ctrl+1..Ctrl+8 → switch stance/form/presence (WoW default bindings). + // Only fires for classes that use a stance bar; same slot ordering as + // renderStanceBar: Warrior, DK, Druid, Rogue, Priest. + if (ctrlDown) { + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + static const uint32_t rogueForms[] = { 1784 }; + static const uint32_t priestForms[] = { 15473 }; + const uint32_t* stArr = nullptr; int stCnt = 0; + switch (gameHandler.getPlayerClass()) { + case 1: stArr = warriorStances; stCnt = 3; break; + case 6: stArr = dkPresences; stCnt = 3; break; + case 11: stArr = druidForms; stCnt = 9; break; + case 4: stArr = rogueForms; stCnt = 1; break; + case 5: stArr = priestForms; stCnt = 1; break; + } + if (stArr) { + const auto& known = gameHandler.getKnownSpells(); + // Build available list (same order as UI) + std::vector avail; + avail.reserve(stCnt); + for (int i = 0; i < stCnt; ++i) + if (known.count(stArr[i])) avail.push_back(stArr[i]); + // Ctrl+1 = first stance, Ctrl+2 = second, … + for (int i = 0; i < static_cast(avail.size()) && i < 8; ++i) { + if (input.isKeyJustPressed(actionBarKeys[i])) + gameHandler.castSpell(avail[i]); + } + } + } + + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (!ctrlDown && input.isKeyJustPressed(actionBarKeys[i])) { + int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; + if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(bar[slotIdx].id, target); + } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { + gameHandler.useItemById(bar[slotIdx].id); + } else if (bar[slotIdx].type == game::ActionBarSlot::MACRO) { + executeMacroText(gameHandler, gameHandler.getMacroText(bar[slotIdx].id)); + } } } } } - // Slash key: focus chat input — always works unless already typing in chat - if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_SLASH)) { - refocusChatInput = true; - chatInputBuffer[0] = '/'; - chatInputBuffer[1] = '\0'; - chatInputMoveCursorToEnd = true; - } - - // Enter key: focus chat input (empty) — always works unless already typing - if (!chatInputActive && input.isKeyJustPressed(SDL_SCANCODE_RETURN)) { - refocusChatInput = true; - } - - // Cursor affordance: show hand cursor over interactable game objects. + // Cursor affordance: show hand cursor over interactable entities. if (!io.WantCaptureMouse) { auto* renderer = core::Application::getInstance().getRenderer(); auto* camera = renderer ? renderer->getCamera() : nullptr; @@ -1471,17 +3044,21 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { float screenH = static_cast(window->getHeight()); rendering::Ray ray = camera->screenToWorldRay(mousePos.x, mousePos.y, screenW, screenH); float closestT = 1e30f; - bool hoverInteractableGo = false; + bool hoverInteractable = false; for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (entity->getType() != game::ObjectType::GAMEOBJECT) continue; + bool isGo = (entity->getType() == game::ObjectType::GAMEOBJECT); + bool isUnit = (entity->getType() == game::ObjectType::UNIT); + bool isPlayer = (entity->getType() == game::ObjectType::PLAYER); + if (!isGo && !isUnit && !isPlayer) continue; + if (guid == gameHandler.getPlayerGuid()) continue; // skip self glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); if (!hasBounds) { - hitRadius = 2.5f; + hitRadius = isGo ? 2.5f : 1.8f; hitCenter = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - hitCenter.z += 1.2f; + hitCenter.z += isGo ? 1.2f : 1.0f; } else { hitRadius = std::max(hitRadius * 1.1f, 0.8f); } @@ -1489,10 +3066,10 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT) && hitT < closestT) { closestT = hitT; - hoverInteractableGo = true; + hoverInteractable = true; } } - if (hoverInteractableGo) { + if (hoverInteractable) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } } @@ -1612,6 +3189,25 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { auto* camera = renderer ? renderer->getCamera() : nullptr; auto* window = core::Application::getInstance().getWindow(); if (camera && window) { + // If a quest objective gameobject is under the cursor, prefer it over + // hostile units so quest pickups (e.g. "Bundle of Wood") are reliable. + std::unordered_set questObjectiveGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + questObjectiveGoEntries.reserve(32); + for (const auto& q : ql) { + if (q.complete) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId >= 0 || obj.required == 0) continue; + uint32_t entry = static_cast(-obj.npcOrGoId); + uint32_t cur = 0; + auto it = q.killCounts.find(entry); + if (it != q.killCounts.end()) cur = it->second.first; + if (cur < obj.required) questObjectiveGoEntries.insert(entry); + } + } + } + glm::vec2 mousePos = input.getMousePosition(); float screenW = static_cast(window->getWidth()); float screenH = static_cast(window->getHeight()); @@ -1621,13 +3217,19 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { game::ObjectType closestType = game::ObjectType::OBJECT; float closestHostileUnitT = 1e30f; uint64_t closestHostileUnitGuid = 0; + float closestQuestGoT = 1e30f; + uint64_t closestQuestGoGuid = 0; + float closestGoT = 1e30f; + uint64_t closestGoGuid = 0; const uint64_t myGuid = gameHandler.getPlayerGuid(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != game::ObjectType::UNIT && t != game::ObjectType::PLAYER && - t != game::ObjectType::GAMEOBJECT) continue; + t != game::ObjectType::GAMEOBJECT) + continue; if (guid == myGuid) continue; + glm::vec3 hitCenter; float hitRadius = 0.0f; bool hasBounds = core::Application::getInstance().getRenderBoundsForGuid(guid, hitCenter, hitRadius); @@ -1641,9 +3243,6 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { heightOffset = 0.3f; } } else if (t == game::ObjectType::GAMEOBJECT) { - // Do not hard-filter by GO type here. Some realms/content - // classify usable objects (including some chests) with types - // that look decorative in cache data. hitRadius = 2.5f; heightOffset = 1.2f; } @@ -1653,6 +3252,7 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } else { hitRadius = std::max(hitRadius * 1.1f, 0.6f); } + float hitT; if (raySphereIntersect(ray, hitCenter, hitRadius, hitT)) { if (t == game::ObjectType::UNIT) { @@ -1663,6 +3263,21 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { closestHostileUnitGuid = guid; } } + if (t == game::ObjectType::GAMEOBJECT) { + if (hitT < closestGoT) { + closestGoT = hitT; + closestGoGuid = guid; + } + if (!questObjectiveGoEntries.empty()) { + auto go = std::static_pointer_cast(entity); + if (questObjectiveGoEntries.count(go->getEntry())) { + if (hitT < closestQuestGoT) { + closestQuestGoT = hitT; + closestQuestGoGuid = guid; + } + } + } + } if (hitT < closestT) { closestT = hitT; closestGuid = guid; @@ -1670,11 +3285,28 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } } - // Prefer hostile monsters over nearby gameobjects/others when right-click picking. - if (closestHostileUnitGuid != 0) { + + // Priority: quest GO > closer of (GO, hostile unit) > closest anything. + if (closestQuestGoGuid != 0) { + closestGuid = closestQuestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else if (closestGoGuid != 0 && closestHostileUnitGuid != 0) { + // Both a GO and hostile unit were hit — prefer whichever is closer. + if (closestGoT <= closestHostileUnitT) { + closestGuid = closestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else { + closestGuid = closestHostileUnitGuid; + closestType = game::ObjectType::UNIT; + } + } else if (closestGoGuid != 0) { + closestGuid = closestGoGuid; + closestType = game::ObjectType::GAMEOBJECT; + } else if (closestHostileUnitGuid != 0) { closestGuid = closestHostileUnitGuid; closestType = game::ObjectType::UNIT; } + if (closestGuid != 0) { if (closestType == game::ObjectType::GAMEOBJECT) { gameHandler.setTarget(closestGuid); @@ -1773,11 +3405,42 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { playerHp = playerMaxHp; } - // Name in green (friendly player color) — clickable for self-target - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 1.0f, 0.3f, 1.0f)); + // Derive class color via shared helper + ImVec4 classColor = activeChar + ? classColorVec4(static_cast(activeChar->characterClass)) + : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + + // Name in class color — clickable for self-target, right-click for menu + ImGui::PushStyleColor(ImGuiCol_Text, classColor); if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { gameHandler.setTarget(gameHandler.getPlayerGuid()); } + if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) { + ImGui::TextDisabled("%s", playerName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open Character")) { + inventoryScreen.setCharacterOpen(true); + } + if (ImGui::MenuItem("Toggle PvP")) { + gameHandler.togglePvp(); + } + ImGui::Separator(); + bool afk = gameHandler.isAfk(); + bool dnd = gameHandler.isDnd(); + if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) { + gameHandler.toggleAfk(); + } + if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) { + gameHandler.toggleDnd(); + } + if (gameHandler.isInGroup()) { + ImGui::Separator(); + if (ImGui::MenuItem("Leave Group")) { + gameHandler.leaveGroup(); + } + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); ImGui::SameLine(); ImGui::TextDisabled("Lv %u", playerLevel); @@ -1785,6 +3448,49 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.2f, 0.2f, 1.0f), "DEAD"); } + // Group leader crown on self frame when you lead the party/raid + if (gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader"); + } + if (gameHandler.isAfk()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel"); + } else if (gameHandler.isDnd()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); + } + if (auto* ren = core::Application::getInstance().getRenderer()) { + if (auto* cam = ren->getCameraController()) { + if (cam->isAutoRunning()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[Auto-Run]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Auto-running — press ` or NumLock to stop"); + } + } + } + if (inCombatConfirmed && !isDead) { + float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * combatPulse, 0.2f * combatPulse, 1.0f), "[Combat]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); + } + + // Active title — shown in gold below the name/level line + { + int32_t titleBit = gameHandler.getChosenTitleBit(); + if (titleBit >= 0) { + const std::string titleText = gameHandler.getFormattedTitle( + static_cast(titleBit)); + if (!titleText.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str()); + } + } + } // Try to get real HP/mana from the player entity auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); @@ -1796,9 +3502,21 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } - // Health bar + // Health bar — color transitions green→yellow→red as HP drops float pct = static_cast(playerHp) / static_cast(playerMaxHp); - ImVec4 hpColor = isDead ? ImVec4(0.5f, 0.5f, 0.5f, 1.0f) : ImVec4(0.2f, 0.8f, 0.2f, 1.0f); + ImVec4 hpColor; + if (isDead) { + hpColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (pct > 0.5f) { + hpColor = ImVec4(0.2f, 0.8f, 0.2f, 1.0f); // green + } else if (pct > 0.2f) { + float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50% + hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow + } else { + // Critical — pulse red when < 20% + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.5f); + hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red + } ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor); char overlay[64]; snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); @@ -1818,7 +3536,16 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { float mpPct = static_cast(power) / static_cast(maxPower); ImVec4 powerColor; switch (powerType) { - case 0: powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; // Mana (blue) + case 0: { + // Mana: pulse desaturated blue when critically low (< 20%) + if (mpPct < 0.2f) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f); + } else { + powerColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); + } + break; + } case 1: powerColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage (red) case 2: powerColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus (orange) case 3: powerColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; // Energy (yellow) @@ -1915,7 +3642,127 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } } } + + // Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air + if (gameHandler.getPlayerClass() == 7) { + static const ImVec4 kTotemColors[] = { + ImVec4(0.80f, 0.55f, 0.25f, 1.0f), // Earth — brown + ImVec4(1.00f, 0.35f, 0.10f, 1.0f), // Fire — orange-red + ImVec4(0.20f, 0.55f, 0.90f, 1.0f), // Water — blue + ImVec4(0.70f, 0.90f, 1.00f, 1.0f), // Air — pale sky + }; + static const char* kTotemNames[] = { "Earth", "Fire", "Water", "Air" }; + + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + float spacing = 3.0f; + float slotW = (totalW - spacing * 3.0f) / 4.0f; + float slotH = 14.0f; + ImDrawList* tdl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; i++) { + const auto& ts = gameHandler.getTotemSlot(i); + float x0 = cursor.x + i * (slotW + spacing); + float y0 = cursor.y; + float x1 = x0 + slotW; + float y1 = y0 + slotH; + + // Background + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), IM_COL32(20, 20, 20, 200), 2.0f); + + if (ts.active()) { + float rem = ts.remainingMs(); + float frac = rem / static_cast(ts.durationMs); + float fillX = x0 + (x1 - x0) * frac; + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), + ImGui::ColorConvertFloat4ToU32(kTotemColors[i]), 2.0f); + // Remaining seconds label + char secBuf[8]; + snprintf(secBuf, sizeof(secBuf), "%.0f", rem / 1000.0f); + ImVec2 tsz = ImGui::CalcTextSize(secBuf); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx + 1, ly + 1), IM_COL32(0, 0, 0, 180), secBuf); + tdl->AddText(ImVec2(lx, ly), IM_COL32(255, 255, 255, 230), secBuf); + } else { + // Inactive — show element letter + const char* letter = kTotemNames[i]; + char single[2] = { letter[0], '\0' }; + ImVec2 tsz = ImGui::CalcTextSize(single); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx, ly), IM_COL32(80, 80, 80, 200), single); + } + + // Border + ImU32 borderCol = ts.active() + ? ImGui::ColorConvertFloat4ToU32(kTotemColors[i]) + : IM_COL32(60, 60, 60, 160); + tdl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); + + // Tooltip on hover + ImGui::SetCursorScreenPos(ImVec2(x0, y0)); + ImGui::InvisibleButton(("##totem" + std::to_string(i)).c_str(), ImVec2(slotW, slotH)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + if (ts.active()) { + const std::string& spellNm = gameHandler.getSpellName(ts.spellId); + ImGui::TextColored(ImVec4(kTotemColors[i].x, kTotemColors[i].y, + kTotemColors[i].z, 1.0f), + "%s Totem", kTotemNames[i]); + if (!spellNm.empty()) ImGui::Text("%s", spellNm.c_str()); + ImGui::Text("%.1fs remaining", ts.remainingMs() / 1000.0f); + } else { + ImGui::TextDisabled("%s Totem (empty)", kTotemNames[i]); + } + ImGui::EndTooltip(); + } + } + ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); + } } + + // Melee swing timer — shown when player is auto-attacking + if (gameHandler.isAutoAttacking()) { + const uint64_t lastSwingMs = gameHandler.getLastMeleeSwingMs(); + if (lastSwingMs > 0) { + // Determine weapon speed from the equipped main-hand weapon + uint32_t weaponDelayMs = 2000; // Default: 2.0s unarmed + const auto& mainSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND); + if (!mainSlot.empty() && mainSlot.item.itemId != 0) { + const auto* info = gameHandler.getItemInfo(mainSlot.item.itemId); + if (info && info->delayMs > 0) { + weaponDelayMs = info->delayMs; + } + } + + // Compute elapsed since last swing + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + uint64_t elapsedMs = (nowMs >= lastSwingMs) ? (nowMs - lastSwingMs) : 0; + + // Clamp to weapon delay (cap at 1.0 so the bar fills but doesn't exceed) + float pct = std::min(static_cast(elapsedMs) / static_cast(weaponDelayMs), 1.0f); + + // Light silver-orange color indicating auto-attack readiness + ImVec4 swingColor = (pct >= 0.95f) + ? ImVec4(1.0f, 0.75f, 0.15f, 1.0f) // gold when ready to swing + : ImVec4(0.65f, 0.55f, 0.40f, 1.0f); // muted brown-orange while filling + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, swingColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.12f, 0.08f, 0.8f)); + char swingLabel[24]; + float remainSec = std::max(0.0f, (weaponDelayMs - static_cast(elapsedMs)) / 1000.0f); + if (pct >= 0.98f) + snprintf(swingLabel, sizeof(swingLabel), "Swing!"); + else + snprintf(swingLabel, sizeof(swingLabel), "%.1fs", remainSec); + ImGui::ProgressBar(pct, ImVec2(-1.0f, 8.0f), swingLabel); + ImGui::PopStyleColor(2); + } + } + ImGui::End(); ImGui::PopStyleColor(2); @@ -1962,6 +3809,48 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) { gameHandler.setTarget(petGuid); } + // Right-click context menu on pet name + if (ImGui::BeginPopupContextItem("PetNameCtx")) { + ImGui::TextDisabled("%s", petLabel); + ImGui::Separator(); + if (ImGui::MenuItem("Target Pet")) { + gameHandler.setTarget(petGuid); + } + if (ImGui::MenuItem("Rename Pet")) { + ImGui::CloseCurrentPopup(); + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } + if (ImGui::MenuItem("Dismiss Pet")) { + gameHandler.dismissPet(); + } + ImGui::EndPopup(); + } + // Pet rename modal (opened via context menu) + if (petRenameOpen_) { + ImGui::OpenPopup("Rename Pet###PetRename"); + petRenameOpen_ = false; + } + if (ImGui::BeginPopupModal("Rename Pet###PetRename", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Enter new pet name (max 12 characters):"); + ImGui::SetNextItemWidth(180.0f); + bool submitted = ImGui::InputText("##PetRenameInput", petRenameBuf_, sizeof(petRenameBuf_), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (ImGui::Button("OK") || submitted) { + std::string newName(petRenameBuf_); + if (!newName.empty() && newName.size() <= 12) { + gameHandler.renamePet(newName); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } ImGui::PopStyleColor(); if (petLevel > 0) { ImGui::SameLine(); @@ -1973,7 +3862,10 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { uint32_t maxHp = petUnit->getMaxHealth(); if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.2f, 0.8f, 0.2f, 1.0f)); + ImVec4 petHpColor = pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) + : pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor); char hpText[32]; snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); @@ -2002,10 +3894,234 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - // Dismiss button (compact, right-aligned) - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60.0f); - if (ImGui::SmallButton("Dismiss")) { - gameHandler.dismissPet(); + // Happiness bar — hunter pets store happiness as power type 4 + { + uint32_t happiness = petUnit->getPowerByType(4); + uint32_t maxHappiness = petUnit->getMaxPowerByType(4); + if (maxHappiness > 0 && happiness > 0) { + float hapPct = static_cast(happiness) / static_cast(maxHappiness); + // Tier: < 33% = Unhappy (red), < 67% = Content (yellow), >= 67% = Happy (green) + ImVec4 hapColor = hapPct >= 0.667f ? ImVec4(0.2f, 0.85f, 0.2f, 1.0f) + : hapPct >= 0.333f ? ImVec4(0.9f, 0.75f, 0.1f, 1.0f) + : ImVec4(0.85f, 0.2f, 0.2f, 1.0f); + const char* hapLabel = hapPct >= 0.667f ? "Happy" : hapPct >= 0.333f ? "Content" : "Unhappy"; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hapColor); + ImGui::ProgressBar(hapPct, ImVec2(-1, 8), hapLabel); + ImGui::PopStyleColor(); + } + } + + // Pet cast bar + if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { + float castPct = (pcs->timeTotal > 0.0f) + ? (pcs->timeTotal - pcs->timeRemaining) / pcs->timeTotal : 0.0f; + // Orange color to distinguish from health/power bars + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.85f, 0.55f, 0.1f, 1.0f)); + char petCastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(pcs->spellId); + if (!spellNm.empty()) + snprintf(petCastLabel, sizeof(petCastLabel), "%s (%.1fs)", spellNm.c_str(), pcs->timeRemaining); + else + snprintf(petCastLabel, sizeof(petCastLabel), "Casting... (%.1fs)", pcs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), petCastLabel); + ImGui::PopStyleColor(); + } + + // Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned + { + static const char* kReactLabels[] = { "Psv", "Def", "Agg" }; + static const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" }; + static const ImVec4 kReactColors[] = { + ImVec4(0.4f, 0.6f, 1.0f, 1.0f), // passive — blue + ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green + ImVec4(1.0f, 0.35f, 0.35f, 1.0f),// aggressive — red + }; + static const ImVec4 kReactDimColors[] = { + ImVec4(0.15f, 0.2f, 0.4f, 0.8f), + ImVec4(0.1f, 0.3f, 0.1f, 0.8f), + ImVec4(0.4f, 0.1f, 0.1f, 0.8f), + }; + uint8_t curReact = gameHandler.getPetReact(); // 0=passive,1=defensive,2=aggressive + + // Find each react-type slot in the action bar by known built-in IDs: + // 1=Passive, 4=Defensive, 6=Aggressive (WoW wire protocol) + static const uint32_t kReactActionIds[] = { 1u, 4u, 6u }; + uint32_t reactSlotVals[3] = { 0, 0, 0 }; + const int slotTotal = game::GameHandler::PET_ACTION_BAR_SLOTS; + for (int i = 0; i < slotTotal; ++i) { + uint32_t sv = gameHandler.getPetActionSlot(i); + uint32_t aid = sv & 0x00FFFFFFu; + for (int r = 0; r < 3; ++r) { + if (aid == kReactActionIds[r]) { reactSlotVals[r] = sv; break; } + } + } + + for (int r = 0; r < 3; ++r) { + if (r > 0) ImGui::SameLine(0.0f, 3.0f); + bool active = (curReact == static_cast(r)); + ImVec4 btnCol = active ? kReactColors[r] : kReactDimColors[r]; + ImGui::PushID(r + 1000); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kReactColors[r]); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kReactColors[r]); + if (ImGui::Button(kReactLabels[r], ImVec2(34.0f, 16.0f))) { + // Use server-provided slot value if available; fall back to raw ID + uint32_t action = (reactSlotVals[r] != 0) + ? reactSlotVals[r] + : kReactActionIds[r]; + gameHandler.sendPetAction(action, 0); + } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", kReactTooltips[r]); + ImGui::PopID(); + } + + // Dismiss button right-aligned on the same row + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 58.0f); + if (ImGui::SmallButton("Dismiss")) { + gameHandler.dismissPet(); + } + } + + // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS + { + const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS; + // Filter to non-zero slots; lay them out as small icon/text buttons. + // Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID, + // high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type). + // Built-in commands: id=2 follow, id=3 stay/move, id=5 attack. + auto* assetMgr = core::Application::getInstance().getAssetManager(); + const float iconSz = 20.0f; + const float spacing = 2.0f; + ImGui::Separator(); + + int rendered = 0; + for (int i = 0; i < slotCount; ++i) { + uint32_t slotVal = gameHandler.getPetActionSlot(i); + if (slotVal == 0) continue; + + uint32_t actionId = slotVal & 0x00FFFFFFu; + // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. + bool autocastOn = gameHandler.isPetSpellAutocast(actionId); + + // Cooldown tracking for pet spells (actionId > 6 are spell IDs) + float petCd = (actionId > 6) ? gameHandler.getSpellCooldown(actionId) : 0.0f; + bool petOnCd = (petCd > 0.0f); + + ImGui::PushID(i); + if (rendered > 0) ImGui::SameLine(0.0f, spacing); + + // Try to show spell icon; fall back to abbreviated text label. + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const char* builtinLabel = nullptr; + if (actionId == 1) builtinLabel = "Psv"; + else if (actionId == 2) builtinLabel = "Fol"; + else if (actionId == 3) builtinLabel = "Sty"; + else if (actionId == 4) builtinLabel = "Def"; + else if (actionId == 5) builtinLabel = "Atk"; + else if (actionId == 6) builtinLabel = "Agg"; + else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); + + // Dim when on cooldown; tint green when autocast is on + ImVec4 tint = petOnCd + ? ImVec4(0.35f, 0.35f, 0.35f, 0.7f) + : (autocastOn ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 1.0f, 1.0f, 1.0f)); + bool clicked = false; + if (iconTex) { + clicked = ImGui::ImageButton("##pa", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(0.1f,0.1f,0.1f,0.9f), tint); + } else { + char label[8]; + if (builtinLabel) { + snprintf(label, sizeof(label), "%s", builtinLabel); + } else { + // Show first 3 chars of spell name or spell ID. + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100); + else snprintf(label, sizeof(label), "%.3s", nm.c_str()); + } + ImVec4 btnCol = petOnCd ? ImVec4(0.1f,0.1f,0.15f,0.9f) + : (autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) + : ImVec4(0.2f,0.2f,0.3f,0.9f)); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); + clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); + ImGui::PopStyleColor(); + } + + // Cooldown overlay: dark fill + time text centered on the button + if (petOnCd && !builtinLabel) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* cdDL = ImGui::GetWindowDrawList(); + cdDL->AddRectFilled(bMin, bMax, IM_COL32(0, 0, 0, 140)); + char cdTxt[8]; + if (petCd >= 60.0f) + snprintf(cdTxt, sizeof(cdTxt), "%dm", static_cast(petCd / 60.0f)); + else if (petCd >= 1.0f) + snprintf(cdTxt, sizeof(cdTxt), "%d", static_cast(petCd)); + else + snprintf(cdTxt, sizeof(cdTxt), "%.1f", petCd); + ImVec2 tsz = ImGui::CalcTextSize(cdTxt); + float cx = (bMin.x + bMax.x) * 0.5f; + float cy = (bMin.y + bMax.y) * 0.5f; + cdDL->AddText(ImVec2(cx - tsz.x * 0.5f, cy - tsz.y * 0.5f), + IM_COL32(255, 255, 255, 230), cdTxt); + } + + if (clicked && !petOnCd) { + // Send pet action; use current target for spells. + uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; + gameHandler.sendPetAction(slotVal, targetGuid); + } + // Right-click toggles autocast for castable pet spells (actionId > 6) + if (actionId > 6 && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + gameHandler.togglePetSpellAutocast(actionId); + } + + // Tooltip: rich spell info for pet spells, simple label for built-in commands + if (ImGui::IsItemHovered()) { + if (builtinLabel) { + const char* tip = nullptr; + if (actionId == 1) tip = "Passive"; + else if (actionId == 2) tip = "Follow"; + else if (actionId == 3) tip = "Stay"; + else if (actionId == 4) tip = "Defensive"; + else if (actionId == 5) tip = "Attack"; + else if (actionId == 6) tip = "Aggressive"; + if (tip) ImGui::SetTooltip("%s", tip); + } else if (actionId > 6) { + auto* spellAsset = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset); + if (!richOk) { + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) nm = "Spell #" + std::to_string(actionId); + ImGui::Text("%s", nm.c_str()); + } + ImGui::TextColored(autocastOn + ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "Autocast: %s (right-click to toggle)", autocastOn ? "On" : "Off"); + if (petOnCd) { + if (petCd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", + static_cast(petCd) / 60, static_cast(petCd) % 60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %.1f sec", petCd); + } + ImGui::EndTooltip(); + } + } + + ImGui::PopID(); + ++rendered; + } } } ImGui::End(); @@ -2014,6 +4130,87 @@ void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Totem Frame (Shaman — below pet frame / player frame) +// ============================================================ + +void GameScreen::renderTotemFrame(game::GameHandler& gameHandler) { + // Only show if at least one totem is active + bool anyActive = false; + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + if (gameHandler.getTotemSlot(i).active()) { anyActive = true; break; } + } + if (!anyActive) return; + + static const struct { const char* name; ImU32 color; } kTotemInfo[4] = { + { "Earth", IM_COL32(139, 90, 43, 255) }, // brown + { "Fire", IM_COL32(220, 80, 30, 255) }, // red-orange + { "Water", IM_COL32( 30,120, 220, 255) }, // blue + { "Air", IM_COL32(180,220, 255, 255) }, // light blue + }; + + // Position: below pet frame / player frame, left side + // Pet frame is at ~y=200 if active, player frame is at y=20; totem frame near y=300 + // We anchor relative to screen left edge like pet frame + ImGui::SetNextWindowPos(ImVec2(8.0f, 300.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(130.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoTitleBar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.08f, 0.06f, 0.88f)); + + if (ImGui::Begin("##TotemFrame", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.3f, 1.0f), "Totems"); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + const auto& slot = gameHandler.getTotemSlot(i); + if (!slot.active()) continue; + + ImGui::PushID(i); + + // Colored element dot + ImVec2 dotPos = ImGui::GetCursorScreenPos(); + dotPos.x += 4.0f; dotPos.y += 6.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotPos.x + 4.0f, dotPos.y + 4.0f), 4.0f, kTotemInfo[i].color); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + // Totem name or spell name + const std::string& spellName = gameHandler.getSpellName(slot.spellId); + const char* displayName = spellName.empty() ? kTotemInfo[i].name : spellName.c_str(); + ImGui::Text("%s", displayName); + + // Duration countdown bar + float remMs = slot.remainingMs(); + float totMs = static_cast(slot.durationMs); + float frac = (totMs > 0.0f) ? std::min(remMs / totMs, 1.0f) : 0.0f; + float remSec = remMs / 1000.0f; + + // Color bar with totem element tint + ImVec4 barCol( + static_cast((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f, + 0.9f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barCol); + char timeBuf[16]; + snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remSec); + ImGui::ProgressBar(frac, ImVec2(-1, 8), timeBuf); + ImGui::PopStyleColor(); + + ImGui::PopID(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { auto target = gameHandler.getTarget(); if (!target) return; @@ -2040,21 +4237,33 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (u->getHealth() == 0 && u->getMaxHealth() > 0) { hostileColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); } else if (u->isHostile()) { + // Check tapped-by-other: grey name for mobs tagged by someone else + uint32_t tgtDynFlags = u->getDynamicFlags(); + bool tgtTapped = (tgtDynFlags & 0x0004) != 0 && (tgtDynFlags & 0x0008) == 0; + if (tgtTapped) { + hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey — tapped by other + } else { // WoW level-based color for hostile mobs uint32_t playerLv = gameHandler.getPlayerLevel(); uint32_t mobLv = u->getLevel(); - int32_t diff = static_cast(mobLv) - static_cast(playerLv); - if (game::GameHandler::killXp(playerLv, mobLv) == 0) { - hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey - no XP - } else if (diff >= 10) { - hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard - } else if (diff >= 5) { - hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard - } else if (diff >= -2) { - hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even + if (mobLv == 0) { + // Level 0 = unknown/?? (e.g. high-level raid bosses) — always skull red + hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); } else { - hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) { + hostileColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); // Grey - no XP + } else if (diff >= 10) { + hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard + } else if (diff >= 5) { + hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard + } else if (diff >= -2) { + hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even + } else { + hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Green - easy + } } + } // end tapped else } else { hostileColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); // Friendly } @@ -2098,13 +4307,173 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); } - // Entity name and type + // Entity name and type — Selectable so we can attach a right-click context menu std::string name = getEntityName(target); + // Player targets: use class color instead of the generic green ImVec4 nameColor = hostileColor; + if (target->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(target.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } ImGui::SameLine(0.0f, 0.0f); - ImGui::TextColored(nameColor, "%s", name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + // Right-click context menu on target frame + if (ImGui::BeginPopupContextItem("##TargetFrameCtx")) { + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(target->getGuid()); + if (target->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(name); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(target->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(target->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(name); + } + ImGui::EndPopup(); + } + + // Group leader crown — golden ♛ when the targeted player is the party/raid leader + if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + + // Quest giver indicator — "!" for available quests, "?" for completable quests + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete"); + } + } + + // Creature subtitle (e.g. "", "Captain of the Guard") + if (target->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(target); + const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str()); + } + } + + // Player guild name (e.g. "") — mirrors NPC subtitle styling + if (target->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(target->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + + // Right-click context menu on the target name + if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { + const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); + const uint64_t tGuid = target->getGuid(); + + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(tGuid); + } + if (ImGui::MenuItem("Clear Target")) { + gameHandler.clearTarget(); + } + if (isPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Follow")) { + gameHandler.followTarget(); + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(name); + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(tGuid); + } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(tGuid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(name); + } + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(tGuid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(tGuid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } // Level (for units/players) — colored by difficulty if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) { @@ -2115,7 +4484,61 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (target->getType() == game::ObjectType::PLAYER) { levelColor = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } - ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + if (unit->getLevel() == 0) + ImGui::TextColored(levelColor, "Lv ??"); + else + ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + // Classification badge: Elite / Rare Elite / Boss / Rare + if (target->getType() == game::ObjectType::UNIT) { + int rank = gameHandler.getCreatureRank(unit->getEntry()); + if (rank == 1) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group"); + } else if (rank == 2) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended"); + } else if (rank == 3) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "[Boss]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss"); + } else if (rank == 4) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); + } + } + // Creature type label (Beast, Humanoid, Demon, etc.) + if (target->getType() == game::ObjectType::UNIT) { + uint32_t ctype = gameHandler.getCreatureType(unit->getEntry()); + const char* ctypeName = nullptr; + switch (ctype) { + case 1: ctypeName = "Beast"; break; + case 2: ctypeName = "Dragonkin"; break; + case 3: ctypeName = "Demon"; break; + case 4: ctypeName = "Elemental"; break; + case 5: ctypeName = "Giant"; break; + case 6: ctypeName = "Undead"; break; + case 7: ctypeName = "Humanoid"; break; + case 8: ctypeName = "Critter"; break; + case 9: ctypeName = "Mechanical"; break; + case 11: ctypeName = "Totem"; break; + case 12: ctypeName = "Non-combat Pet"; break; + case 13: ctypeName = "Gas Cloud"; break; + default: break; + } + if (ctypeName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", ctypeName); + } + } + if (confirmedCombatWithTarget) { + float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * cPulse, 0.2f * cPulse, 1.0f), "[Attacking]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Engaged in combat with this target"); + } // Health bar uint32_t hp = unit->getHealth(); @@ -2160,22 +4583,137 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // Combo points — shown when the player has combo points on this target + { + uint8_t cp = gameHandler.getComboPoints(); + if (cp > 0 && gameHandler.getComboTarget() == target->getGuid()) { + const float dotSize = 12.0f; + const float dotSpacing = 4.0f; + const int maxCP = 5; + float totalW = maxCP * dotSize + (maxCP - 1) * dotSpacing; + float startX = (frameW - totalW) * 0.5f; + ImGui::SetCursorPosX(startX); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (int ci = 0; ci < maxCP; ++ci) { + float cx = cursor.x + ci * (dotSize + dotSpacing) + dotSize * 0.5f; + float cy = cursor.y + dotSize * 0.5f; + if (ci < static_cast(cp)) { + // Lit: yellow for 1-4, red glow for 5 + ImU32 col = (cp >= 5) + ? IM_COL32(255, 50, 30, 255) + : IM_COL32(255, 210, 30, 255); + dl->AddCircleFilled(ImVec2(cx, cy), dotSize * 0.45f, col); + // Subtle glow + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.5f, IM_COL32(255, 255, 200, 80), 0, 1.5f); + } else { + // Unlit: dark outline + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.4f, IM_COL32(80, 80, 80, 180), 0, 1.5f); + } + } + ImGui::Dummy(ImVec2(totalW, dotSize + 2.0f)); + } + } + // Target cast bar — shown when the target is casting if (gameHandler.isTargetCasting()) { float castPct = gameHandler.getTargetCastProgress(); float castLeft = gameHandler.getTargetCastTimeRemaining(); uint32_t tspell = gameHandler.getTargetCastSpellId(); + bool interruptible = gameHandler.isTargetCastInterruptible(); const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80% + ImVec4 castBarColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + if (interruptible) + castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse + else + castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse + } else { + castBarColor = interruptible ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) // green = can interrupt + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); char castLabel[72]; if (!castName.empty()) snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); else snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); - ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + { + auto* tcastAsset = core::Application::getInstance().getAssetManager(); + VkDescriptorSet tIcon = (tspell != 0 && tcastAsset) + ? getSpellIcon(tspell, tcastAsset) : VK_NULL_HANDLE; + if (tIcon) { + ImGui::Image((ImTextureID)(uintptr_t)tIcon, ImVec2(14, 14)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } + } ImGui::PopStyleColor(); } + // Target-of-Target (ToT): show who the current target is targeting + { + uint64_t totGuid = 0; + const auto& tFields = target->getFields(); + auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (itLo != tFields.end()) { + totGuid = itLo->second; + auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (itHi != tFields.end()) + totGuid |= (static_cast(itHi->second) << 32); + } + if (totGuid != 0) { + auto totEnt = gameHandler.getEntityManager().getEntity(totGuid); + std::string totName; + ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f); + if (totGuid == gameHandler.getPlayerGuid()) { + auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid); + totName = playerEnt ? getEntityName(playerEnt) : "You"; + totColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (totEnt) { + totName = getEntityName(totEnt); + uint8_t cid = entityClassId(totEnt.get()); + if (cid != 0) totColor = classColorVec4(cid); + } + if (!totName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(totColor, "%s", totName.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str()); + } + if (ImGui::IsItemClicked()) { + gameHandler.setTarget(totGuid); + } + + // Compact health bar for the ToT — essential for healers tracking boss target + if (totEnt) { + auto totUnit = std::dynamic_pointer_cast(totEnt); + if (totUnit && totUnit->getMaxHealth() > 0) { + uint32_t totHp = totUnit->getHealth(); + uint32_t totMaxHp = totUnit->getMaxHealth(); + float totPct = static_cast(totHp) / static_cast(totMaxHp); + ImVec4 totBarColor = + totPct > 0.5f ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) : + totPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, totBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char totOverlay[32]; + snprintf(totOverlay, sizeof(totOverlay), "%u%%", + static_cast(totPct * 100.0f + 0.5f)); + ImGui::ProgressBar(totPct, ImVec2(-1, 10), totOverlay); + ImGui::PopStyleColor(2); + } + } + } + } + } + // Distance const auto& movement = gameHandler.getMovementInfo(); float dx = target->getX() - movement.x; @@ -2184,6 +4722,15 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { float distance = std::sqrt(dx*dx + dy*dy + dz*dz); ImGui::TextDisabled("%.1f yd", distance); + // Threat button (shown when in combat and threat data is available) + if (gameHandler.getTargetThreatList()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f)); + if (ImGui::SmallButton("Threat")) showThreatWindow_ = !showThreatWindow_; + ImGui::PopStyleColor(2); + } + // Target auras (buffs/debuffs) const auto& targetAuras = gameHandler.getTargetAuras(); int activeAuras = 0; @@ -2197,8 +4744,31 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::Separator(); + // Build sorted index list: debuffs before buffs, shorter duration first + uint64_t tNowSort = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector sortedIdx; + sortedIdx.reserve(targetAuras.size()); + for (size_t i = 0; i < targetAuras.size(); ++i) + if (!targetAuras[i].isEmpty()) sortedIdx.push_back(i); + std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = targetAuras[a]; const auto& ab = targetAuras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; // debuffs first + int32_t ra = aa.getRemainingMs(tNowSort); + int32_t rb = ab.getRemainingMs(tNowSort); + // Permanent (-1) goes last; shorter remaining goes first + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + int shown = 0; - for (size_t i = 0; i < targetAuras.size() && shown < 16; ++i) { + for (size_t si = 0; si < sortedIdx.size() && shown < 16; ++si) { + size_t i = sortedIdx[si]; const auto& aura = targetAuras[i]; if (aura.isEmpty()) continue; @@ -2207,7 +4777,20 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushID(static_cast(10000 + i)); bool isBuff = (aura.flags & 0x80) == 0; - ImVec4 auraBorderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + ImVec4 auraBorderColor; + if (isBuff) { + auraBorderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + // Debuff: color by dispel type, matching player buff bar convention + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: auraBorderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: auraBorderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: auraBorderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: auraBorderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: auraBorderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } VkDescriptorSet iconTex = VK_NULL_HANDLE; if (assetMgr) { @@ -2236,6 +4819,32 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { std::chrono::steady_clock::now().time_since_epoch()).count()); int32_t tRemainMs = aura.getRemainingMs(tNowMs); + // Clock-sweep overlay (elapsed = dark area, WoW style) + if (tRemainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 tIconMin = ImGui::GetItemRectMin(); + ImVec2 tIconMax = ImGui::GetItemRectMax(); + float tcx = (tIconMin.x + tIconMax.x) * 0.5f; + float tcy = (tIconMin.y + tIconMax.y) * 0.5f; + float tR = (tIconMax.x - tIconMin.x) * 0.5f; + float tTot = static_cast(aura.maxDurationMs); + float tFrac = std::clamp( + 1.0f - static_cast(tRemainMs) / tTot, 0.0f, 1.0f); + if (tFrac > 0.005f) { + constexpr int TSEGS = 24; + float tSa = -IM_PI * 0.5f; + float tEa = tSa + tFrac * 2.0f * IM_PI; + ImVec2 tPts[TSEGS + 2]; + tPts[0] = ImVec2(tcx, tcy); + for (int s = 0; s <= TSEGS; ++s) { + float a = tSa + (tEa - tSa) * s / static_cast(TSEGS); + tPts[s + 1] = ImVec2(tcx + std::cos(a) * tR, + tcy + std::sin(a) * tR); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + tPts, TSEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + // Duration countdown overlay if (tRemainMs > 0) { ImVec2 iconMin = ImGui::GetItemRectMin(); @@ -2251,10 +4860,24 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(timeStr); float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; float cy = iconMax.y - textSize.y - 1.0f; + // Color by urgency (matches player buff bar) + ImU32 tTimerColor; + if (tRemainMs < 10000) { + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + tTimerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (tRemainMs < 30000) { + tTimerColor = IM_COL32(255, 165, 0, 255); + } else { + tTimerColor = IM_COL32(255, 255, 255, 255); + } ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 200), timeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 255, 255), timeStr); + tTimerColor, timeStr); } // Stack / charge count — upper-left corner @@ -2268,20 +4891,23 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { IM_COL32(255, 220, 50, 255), chargeStr); } - // Tooltip + // Tooltip: rich spell info + remaining duration if (ImGui::IsItemHovered()) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } if (tRemainMs > 0) { int seconds = tRemainMs / 1000; - if (seconds < 60) { - ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); - } else { - ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); - } - } else { - ImGui::SetTooltip("%s", name.c_str()); + char durBuf[32]; + if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds); + else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf); } + ImGui::EndTooltip(); } ImGui::PopID(); @@ -2325,11 +4951,41 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { std::string totName = getEntityName(totEntity); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.8f, 1.0f), "%s", totName.c_str()); + // Class color for players; gray for NPCs + ImVec4 totNameColor = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + if (totEntity->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(totEntity.get()); + if (cid != 0) totNameColor = classColorVec4(cid); + } + // Selectable so we can attach a right-click context menu + ImGui::PushStyleColor(ImGuiCol_Text, totNameColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + if (ImGui::Selectable(totName.c_str(), false, + ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) { + gameHandler.setTarget(totGuid); + } + ImGui::PopStyleColor(4); + + if (ImGui::BeginPopupContextItem("##ToTCtx")) { + ImGui::TextDisabled("%s", totName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(totGuid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(totGuid); + ImGui::EndPopup(); + } if (totEntity->getType() == game::ObjectType::UNIT || totEntity->getType() == game::ObjectType::PLAYER) { auto totUnit = std::static_pointer_cast(totEntity); + if (totUnit->getLevel() > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv%u", totUnit->getLevel()); + } uint32_t hp = totUnit->getHealth(); uint32_t maxHp = totUnit->getMaxHealth(); if (maxHp > 0) { @@ -2341,6 +4997,158 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::ProgressBar(pct, ImVec2(-1, 10), ""); ImGui::PopStyleColor(); } + + // ToT cast bar — green if interruptible, red if not; pulses near completion + if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { + float totCastPct = (totCs->timeTotal > 0.0f) + ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; + ImVec4 tcColor; + if (totCastPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + tcColor = totCs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } else { + tcColor = totCs->interruptible + ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); + char tcLabel[48]; + const std::string& tcName = gameHandler.getSpellName(totCs->spellId); + if (!tcName.empty()) + snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining); + else + snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining); + ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel); + ImGui::PopStyleColor(); + } + + // ToT aura row — compact icons, debuffs first + { + const std::vector* totAuras = nullptr; + if (totGuid == gameHandler.getPlayerGuid()) + totAuras = &gameHandler.getPlayerAuras(); + else if (totGuid == gameHandler.getTargetGuid()) + totAuras = &gameHandler.getTargetAuras(); + else + totAuras = gameHandler.getUnitAuras(totGuid); + + if (totAuras) { + int totActive = 0; + for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++; + if (totActive > 0) { + auto* totAsset = core::Application::getInstance().getAssetManager(); + constexpr float TA_ICON = 16.0f; + constexpr int TA_PER_ROW = 8; + + ImGui::Separator(); + + uint64_t taNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + std::vector taIdx; + taIdx.reserve(totAuras->size()); + for (size_t i = 0; i < totAuras->size(); ++i) + if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i); + std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*totAuras)[a].flags & 0x80) != 0; + bool bD = ((*totAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; + int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs); + int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int taShown = 0; + for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) { + const auto& aura = (*totAuras)[taIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(taIdx[si]) + 5000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet taIcon = (totAsset) + ? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE; + if (taIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##taura", + (ImTextureID)(uintptr_t)taIcon, + ImVec2(TA_ICON - 2, TA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t taRemain = aura.getRemainingMs(taNowMs); + if (taRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (taRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, totAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (taRemain > 0) { + int s = taRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + taShown++; + } + ImGui::PopStyleVar(); + } + } + } } } ImGui::End(); @@ -2351,9 +5159,961 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } +void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { + auto focus = gameHandler.getFocus(); + if (!focus) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Position: right side of screen, mirroring the target frame on the opposite side + float frameW = 200.0f; + float frameX = screenW - frameW - 10.0f; + + ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + // Determine color based on relation (same logic as target frame) + ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); + if (focus->getType() == game::ObjectType::PLAYER) { + // Use class color for player focus targets + uint8_t cid = entityClassId(focus.get()); + focusColor = (cid != 0) ? classColorVec4(cid) : ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (focus->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(focus); + if (u->getHealth() == 0 && u->getMaxHealth() > 0) { + focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (u->isHostile()) { + // Tapped-by-other: grey focus frame name + uint32_t focDynFlags = u->getDynamicFlags(); + bool focTapped = (focDynFlags & 0x0004) != 0 && (focDynFlags & 0x0008) == 0; + if (focTapped) { + focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); + } else { + uint32_t playerLv = gameHandler.getPlayerLevel(); + uint32_t mobLv = u->getLevel(); + if (mobLv == 0) { + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // ?? level = skull red + } else { + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) + focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); + else if (diff >= 10) + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); + else if (diff >= 5) + focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); + else if (diff >= -2) + focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); + else + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } + } // end tapped else + } else { + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus + + if (ImGui::Begin("##FocusFrame", nullptr, flags)) { + // "Focus" label + ImGui::TextDisabled("[Focus]"); + ImGui::SameLine(); + + // Raid mark icon (star, circle, diamond, …) preceding the name + { + static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow) + { "\xe2\x97\x8f", IM_COL32(255, 103, 0, 255) }, // 1 Circle (orange) + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple) + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green) + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue) + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal) + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red) + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white) + }; + uint8_t fmark = gameHandler.getEntityRaidMark(focus->getGuid()); + if (fmark < game::GameHandler::kRaidMarkCount) { + ImGui::GetWindowDrawList()->AddText( + ImGui::GetCursorScreenPos(), + kFocusMarks[fmark].col, kFocusMarks[fmark].sym); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); + } + } + + std::string focusName = getEntityName(focus); + ImGui::PushStyleColor(ImGuiCol_Text, focusColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + // Right-click context menu on focus frame + if (ImGui::BeginPopupContextItem("##FocusFrameCtx")) { + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(focus->getGuid()); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focus->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(focus->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(focus->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(focus->getGuid()); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } + + // Group leader crown — golden ♛ when the focused player is the party/raid leader + if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + + // Quest giver indicator and classification badge for NPC focus targets + if (focus->getType() == game::ObjectType::UNIT) { + auto focusUnit = std::static_pointer_cast(focus); + + // Quest indicator: ! / ? + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "!"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "!"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "?"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "?"); + } + } + + // Classification badge + int fRank = gameHandler.getCreatureRank(focusUnit->getEntry()); + if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); } + else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); } + else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "[Boss]"); } + else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } + + // Creature type + { + uint32_t fctype = gameHandler.getCreatureType(focusUnit->getEntry()); + const char* fctName = nullptr; + switch (fctype) { + case 1: fctName="Beast"; break; case 2: fctName="Dragonkin"; break; + case 3: fctName="Demon"; break; case 4: fctName="Elemental"; break; + case 5: fctName="Giant"; break; case 6: fctName="Undead"; break; + case 7: fctName="Humanoid"; break; case 8: fctName="Critter"; break; + case 9: fctName="Mechanical"; break; case 11: fctName="Totem"; break; + case 12: fctName="Non-combat Pet"; break; case 13: fctName="Gas Cloud"; break; + default: break; + } + if (fctName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", fctName); + } + } + + // Creature subtitle + const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); + if (!fSub.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); + } + + // Player guild name on focus frame + if (focus->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(focus->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { + const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); + const uint64_t fGuid = focus->getGuid(); + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(fGuid); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focusIsPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, focusName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(fGuid); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(fGuid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(fGuid); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } + + if (focus->getType() == game::ObjectType::UNIT || + focus->getType() == game::ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(focus); + + // Level + health on same row + ImGui::SameLine(); + if (unit->getLevel() == 0) + ImGui::TextDisabled("Lv ??"); + else + ImGui::TextDisabled("Lv %u", unit->getLevel()); + + uint32_t hp = unit->getHealth(); + uint32_t maxHp = unit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : + ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay); + ImGui::PopStyleColor(); + + // Power bar + uint8_t pType = unit->getPowerType(); + uint32_t pwr = unit->getPower(); + uint32_t maxPwr = unit->getMaxPower(); + if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100; + if (maxPwr > 0) { + float mpPct = static_cast(pwr) / static_cast(maxPwr); + ImVec4 pwrColor; + switch (pType) { + case 0: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + case 1: pwrColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; + case 3: pwrColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; + case 6: pwrColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; + default: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor); + ImGui::ProgressBar(mpPct, ImVec2(-1, 10), ""); + ImGui::PopStyleColor(); + } + } + + // Focus cast bar + const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid()); + if (focusCast) { + float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f; + float rem = focusCast->timeRemaining; + float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); + const std::string& spName = gameHandler.getSpellName(focusCast->spellId); + // Pulse orange when > 80% complete — interrupt window closing + ImVec4 focusCastColor; + if (prog > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + focusCastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + focusCastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, focusCastColor); + char castBuf[64]; + if (!spName.empty()) + snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); + else + snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); + { + auto* fcAsset = core::Application::getInstance().getAssetManager(); + VkDescriptorSet fcIcon = (focusCast->spellId != 0 && fcAsset) + ? getSpellIcon(focusCast->spellId, fcAsset) : VK_NULL_HANDLE; + if (fcIcon) { + ImGui::Image((ImTextureID)(uintptr_t)fcIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } else { + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } + } + ImGui::PopStyleColor(); + } + } + + // Focus auras — buffs first, then debuffs, up to 8 icons wide + { + const std::vector* focusAuras = + (focus->getGuid() == gameHandler.getTargetGuid()) + ? &gameHandler.getTargetAuras() + : gameHandler.getUnitAuras(focus->getGuid()); + + if (focusAuras) { + int activeCount = 0; + for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++; + if (activeCount > 0) { + auto* focusAsset = core::Application::getInstance().getAssetManager(); + constexpr float FA_ICON = 20.0f; + constexpr int FA_PER_ROW = 10; + + ImGui::Separator(); + + uint64_t faNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: debuffs first (so hostile-caster info is prominent), then buffs + std::vector faIdx; + faIdx.reserve(focusAuras->size()); + for (size_t i = 0; i < focusAuras->size(); ++i) + if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i); + std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*focusAuras)[a].flags & 0x80) != 0; + bool bD = ((*focusAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; // debuffs first + int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs); + int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int faShown = 0; + for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) { + const auto& aura = (*focusAuras)[faIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(faIdx[si]) + 3000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet faIcon = (focusAsset) + ? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE; + if (faIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##faura", + (ImTextureID)(uintptr_t)faIcon, + ImVec2(FA_ICON - 2, FA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId); + ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t faRemain = aura.getRemainingMs(faNowMs); + if (faRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (faRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y - 1.0f; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Stack / charge count — upper-left corner (parity with target frame) + if (aura.charges > 1) { + ImVec2 faMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, focusAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (faRemain > 0) { + int s = faRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + faShown++; + } + ImGui::PopStyleVar(); + } + } + } + + // Target-of-Focus: who the focus target is currently targeting + { + uint64_t fofGuid = 0; + const auto& fFields = focus->getFields(); + auto fItLo = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (fItLo != fFields.end()) { + fofGuid = fItLo->second; + auto fItHi = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (fItHi != fFields.end()) + fofGuid |= (static_cast(fItHi->second) << 32); + } + if (fofGuid != 0) { + auto fofEnt = gameHandler.getEntityManager().getEntity(fofGuid); + std::string fofName; + ImVec4 fofColor(0.7f, 0.7f, 0.7f, 1.0f); + if (fofGuid == gameHandler.getPlayerGuid()) { + fofName = "You"; + fofColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (fofEnt) { + fofName = getEntityName(fofEnt); + uint8_t fcid = entityClassId(fofEnt.get()); + if (fcid != 0) fofColor = classColorVec4(fcid); + } + if (!fofName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(fofColor, "%s", fofName.c_str()); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Focus's target: %s\nClick to target", fofName.c_str()); + if (ImGui::IsItemClicked()) + gameHandler.setTarget(fofGuid); + + // Compact health bar for target-of-focus + if (fofEnt) { + auto fofUnit = std::dynamic_pointer_cast(fofEnt); + if (fofUnit && fofUnit->getMaxHealth() > 0) { + float fofPct = static_cast(fofUnit->getHealth()) / + static_cast(fofUnit->getMaxHealth()); + ImVec4 fofBarColor = + fofPct > 0.5f ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) : + fofPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, fofBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char fofOverlay[32]; + snprintf(fofOverlay, sizeof(fofOverlay), "%u%%", + static_cast(fofPct * 100.0f + 0.5f)); + ImGui::ProgressBar(fofPct, ImVec2(-1, 10), fofOverlay); + ImGui::PopStyleColor(2); + } + } + } + } + } + + // Distance to focus target + { + const auto& mv = gameHandler.getMovementInfo(); + float fdx = focus->getX() - mv.x; + float fdy = focus->getY() - mv.y; + float fdz = focus->getZ() - mv.z; + float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz); + ImGui::TextDisabled("%.1f yd", fdist); + } + + // Clicking the focus frame targets it + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { + gameHandler.setTarget(focus->getGuid()); + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +// Returns the first executable line of a macro text block, skipping blank lines +// and # directive lines (e.g. #showtooltip). Returns empty string if none found. +static std::string firstMacroCommand(const std::string& macroText) { + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + return line; + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + +// Collect all non-comment, non-empty lines from a macro body. +static std::vector allMacroCommands(const std::string& macroText) { + std::vector cmds; + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + cmds.push_back(std::move(line)); + if (nl == std::string::npos) break; + pos = nl + 1; + } + return cmds; +} + +// Returns the #showtooltip argument from a macro body: +// "#showtooltip Spell" → "Spell" +// "#showtooltip" → "__auto__" (derive from first /cast) +// (none) → "" +static std::string getMacroShowtooltipArg(const std::string& macroText) { + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t fs = line.find_first_not_of(" \t"); + if (fs != std::string::npos) line = line.substr(fs); + if (line.rfind("#showtooltip", 0) == 0 || line.rfind("#show", 0) == 0) { + size_t sp = line.find(' '); + if (sp != std::string::npos) { + std::string arg = line.substr(sp + 1); + size_t as = arg.find_first_not_of(" \t"); + if (as != std::string::npos) arg = arg.substr(as); + size_t ae = arg.find_last_not_of(" \t"); + if (ae != std::string::npos) arg.resize(ae + 1); + if (!arg.empty()) return arg; + } + return "__auto__"; + } + if (nl == std::string::npos) break; + pos = nl + 1; + } + return {}; +} + +// --------------------------------------------------------------------------- +// WoW macro conditional evaluator +// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell +// Returns the first matching alternative's argument, or "" if none matches. +// targetOverride is set to a specific GUID if [target=X] was in the conditions, +// or left as UINT64_MAX to mean "use the normal target". +// --------------------------------------------------------------------------- +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + targetOverride = static_cast(-1); + + auto& input = core::Input::getInstance(); + + const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || + input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || + input.isKeyPressed(SDL_SCANCODE_RCTRL); + const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || + input.isKeyPressed(SDL_SCANCODE_RALT); + const bool anyMod = shiftHeld || ctrlHeld || altHeld; + + // Split rawArg on ';' → alternatives + std::vector alts; + { + std::string cur; + for (char c : rawArg) { + if (c == ';') { alts.push_back(cur); cur.clear(); } + else cur += c; + } + alts.push_back(cur); + } + + // Evaluate a single comma-separated condition token. + // tgt is updated if a target= or @ specifier is found. + auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { + std::string c = raw; + // trim + size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; + size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); + if (c.empty()) return true; + + // @target specifiers: @player, @focus, @pet, @mouseover, @target + if (!c.empty() && c[0] == '@') { + std::string spec = c.substr(1); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + // target=X specifiers + if (c.rfind("target=", 0) == 0) { + std::string spec = c.substr(7); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + + // mod / nomod + if (c == "nomod" || c == "mod:none") return !anyMod; + if (c.rfind("mod:", 0) == 0) { + std::string mods = c.substr(4); + bool ok = true; + if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; + if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; + if (mods.find("alt") != std::string::npos && !altHeld) ok = false; + return ok; + } + + // combat / nocombat + if (c == "combat") return gameHandler.isInCombat(); + if (c == "nocombat") return !gameHandler.isInCombat(); + + // Helper to get the effective target entity + auto effTarget = [&]() -> std::shared_ptr { + if (tgt != static_cast(-1) && tgt != 0) + return gameHandler.getEntityManager().getEntity(tgt); + return gameHandler.getTarget(); + }; + + // exists / noexists + if (c == "exists") return effTarget() != nullptr; + if (c == "noexists") return effTarget() == nullptr; + + // dead / nodead + if (c == "dead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() == 0; + } + if (c == "nodead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() > 0; + } + + // help (friendly) / harm (hostile) and their no- variants + auto unitHostile = [&](const std::shared_ptr& t) -> bool { + if (!t) return false; + auto u = std::dynamic_pointer_cast(t); + return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); + }; + if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } + if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // swimming / noswimming + if (c == "swimming") return gameHandler.isSwimming(); + if (c == "noswimming") return !gameHandler.isSwimming(); + + // flying / noflying (CAN_FLY + FLYING flags active) + if (c == "flying") return gameHandler.isPlayerFlying(); + if (c == "noflying") return !gameHandler.isPlayerFlying(); + + // channeling / nochanneling + if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); + if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); + + // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) + auto isStealthedFn = [&]() -> bool { + auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (!pe) return false; + auto pu = std::dynamic_pointer_cast(pe); + return pu && (pu->getUnitFlags() & 0x02000000u) != 0; + }; + if (c == "stealthed") return isStealthedFn(); + if (c == "nostealthed") return !isStealthedFn(); + + // pet / nopet — player has an active pet (hunters, warlocks, DKs) + if (c == "pet") return gameHandler.hasPet(); + if (c == "nopet") return !gameHandler.hasPet(); + + // indoors / outdoors — WMO interior detection (affects mount type selection) + if (c == "indoors" || c == "nooutdoors") { + auto* r = core::Application::getInstance().getRenderer(); + return r && r->isPlayerIndoors(); + } + if (c == "outdoors" || c == "noindoors") { + auto* r = core::Application::getInstance().getRenderer(); + return !r || !r->isPlayerIndoors(); + } + + // group / nogroup — player is in a party or raid + if (c == "group" || c == "party") return gameHandler.isInGroup(); + if (c == "nogroup") return !gameHandler.isInGroup(); + + // raid / noraid — player is in a raid group (groupType == 1) + if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; + if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; + + // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) + if (c.rfind("spec:", 0) == 0) { + uint8_t wantSpec = 0; + try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} + return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); + } + + // noform / nostance — player is NOT in a shapeshift/stance + if (c == "noform" || c == "nostance") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + // form:0 same as noform + if (c == "form:0" || c == "stance:0") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + + // buff:SpellName / nobuff:SpellName — check if the effective target (or player + // if no target specified) has a buff with the given name. + // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). + auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, + bool negate) -> bool { + // Determine which aura list to check: effective target or player + const std::vector* auras = nullptr; + if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { + // Check target's auras + auras = &gameHandler.getTargetAuras(); + } else { + auras = &gameHandler.getPlayerAuras(); + } + std::string nameLow = spellName; + for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); + for (const auto& a : *auras) { + if (a.isEmpty() || a.spellId == 0) continue; + // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type + bool isDebuff = (a.flags & 0x80) != 0; + if (wantDebuff ? !isDebuff : isDebuff) continue; + std::string sn = gameHandler.getSpellName(a.spellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + if (sn == nameLow) return !negate; + } + return negate; + }; + if (c.rfind("buff:", 0) == 0 && c.size() > 5) + return checkAuraByName(c.substr(5), false, false); + if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), false, true); + if (c.rfind("debuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), true, false); + if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) + return checkAuraByName(c.substr(9), true, true); + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // group (any group) / nogroup / raid + if (c == "group") return !gameHandler.getPartyData().isEmpty(); + if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); + if (c == "raid") { + const auto& pd = gameHandler.getPartyData(); + return pd.groupType >= 1; // groupType 1 = raid, 0 = party + } + + // channeling:SpellName — player is currently channeling that spell + if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { + if (!gameHandler.isChanneling()) return false; + std::string want = c.substr(11); + for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); + uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); + std::string sn = gameHandler.getSpellName(castSpellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + return sn == want; + } + if (c == "channeling") return gameHandler.isChanneling(); + if (c == "nochanneling") return !gameHandler.isChanneling(); + + // casting (any active cast or channel) + if (c == "casting") return gameHandler.isCasting(); + if (c == "nocasting") return !gameHandler.isCasting(); + + // vehicle / novehicle (WotLK) + if (c == "vehicle") return gameHandler.getVehicleId() != 0; + if (c == "novehicle") return gameHandler.getVehicleId() == 0; + + // Unknown → permissive (don't block) + return true; + }; + + for (auto& alt : alts) { + // trim + size_t fs = alt.find_first_not_of(" \t"); + if (fs == std::string::npos) continue; + alt = alt.substr(fs); + size_t ls = alt.find_last_not_of(" \t"); + if (ls != std::string::npos) alt.resize(ls + 1); + + if (!alt.empty() && alt[0] == '[') { + size_t close = alt.find(']'); + if (close == std::string::npos) continue; + std::string condStr = alt.substr(1, close - 1); + std::string argPart = alt.substr(close + 1); + // Trim argPart + size_t as = argPart.find_first_not_of(" \t"); + argPart = (as != std::string::npos) ? argPart.substr(as) : ""; + + // Evaluate comma-separated conditions + uint64_t tgt = static_cast(-1); + bool pass = true; + size_t cp = 0; + while (pass) { + size_t comma = condStr.find(',', cp); + std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); + if (!evalCond(tok, tgt)) { pass = false; break; } + if (comma == std::string::npos) break; + cp = comma + 1; + } + if (pass) { + if (tgt != static_cast(-1)) targetOverride = tgt; + return argPart; + } + } else { + // No condition block — default fallback always matches + return alt; + } + } + return {}; +} + +// Execute all non-comment lines of a macro body in sequence. +// In WoW, every line executes per click; the server enforces spell-cast limits. +// /stopmacro (with optional conditionals) halts the remaining commands early. +void GameScreen::executeMacroText(game::GameHandler& gameHandler, const std::string& macroText) { + macroStopped_ = false; + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer, cmd.c_str(), sizeof(chatInputBuffer) - 1); + chatInputBuffer[sizeof(chatInputBuffer) - 1] = '\0'; + sendChatMessage(gameHandler); + if (macroStopped_) break; + } + macroStopped_ = false; +} + +// /castsequence persistent state — shared across all macros using the same spell list. +// Keyed by the normalized (lowercase, comma-joined) spell sequence string. +namespace { +struct CastSeqState { + size_t index = 0; + float lastPressSec = 0.0f; + uint64_t lastTargetGuid = 0; + bool lastInCombat = false; +}; +std::unordered_map s_castSeqStates; +} // namespace + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); + + // Save to sent-message history (skip pure whitespace, cap at 50 entries) + { + bool allSpace = true; + for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } + if (!allSpace) { + // Remove duplicate of last entry if identical + if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { + chatSentHistory_.push_back(input); + if (chatSentHistory_.size() > 50) + chatSentHistory_.erase(chatSentHistory_.begin()); + } + } + } + chatHistoryIdx_ = -1; // reset browsing position after send + game::ChatType type = game::ChatType::SAY; std::string message = input; std::string target; @@ -2371,6 +6131,57 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { std::string cmdLower = cmd; for (char& c : cmdLower) c = std::tolower(c); + // /run — execute Lua script via addon system + if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { + std::string luaCode = command.substr(spacePos + 1); + auto* am = core::Application::getInstance().getAddonManager(); + if (am) { + am->runScript(luaCode); + } else { + gameHandler.addUIError("Addon system not initialized."); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /dump — evaluate Lua expression and print result + if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { + std::string expr = command.substr(spacePos + 1); + auto* am = core::Application::getInstance().getAddonManager(); + if (am && am->isInitialized()) { + // Wrap expression in print(tostring(...)) to display the value + std::string wrapped = "local __v = " + expr + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + gameHandler.addLocalChatMessage(errMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // Check addon slash commands (SlashCmdList) before built-in commands + { + auto* am = core::Application::getInstance().getAddonManager(); + if (am && am->isInitialized()) { + std::string slashCmd = "/" + cmdLower; + std::string slashArgs; + if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); + if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { + chatInputBuffer[0] = '\0'; + return; + } + } + } + // Special commands if (cmdLower == "logout") { core::Application::getInstance().logoutToLogin(); @@ -2378,6 +6189,59 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "clear") { + gameHandler.clearChatHistory(); + chatInputBuffer[0] = '\0'; + return; + } + + // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) + if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { + auto* am = core::Application::getInstance().getAddonManager(); + if (am) { + am->reload(); + am->fireEvent("VARIABLES_LOADED"); + am->fireEvent("PLAYER_LOGIN"); + am->fireEvent("PLAYER_ENTERING_WORLD"); + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Interface reloaded."; + gameHandler.addLocalChatMessage(rlMsg); + } else { + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Addon system not available."; + gameHandler.addLocalChatMessage(rlMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /stopmacro [conditions] + // Halts execution of the current macro (remaining lines are skipped). + // With a condition block, only stops if the conditions evaluate to true. + // /stopmacro → always stops + // /stopmacro [combat] → stops only while in combat + // /stopmacro [nocombat] → stops only when not in combat + if (cmdLower == "stopmacro") { + bool shouldStop = true; + if (spacePos != std::string::npos) { + std::string condArg = command.substr(spacePos + 1); + while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); + if (!condArg.empty() && condArg.front() == '[') { + // Append a sentinel action so evaluateMacroConditionals can signal a match. + uint64_t tgtOver = static_cast(-1); + std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); + shouldStop = !hit.empty(); + } + } + if (shouldStop) macroStopped_ = true; + chatInputBuffer[0] = '\0'; + return; + } + // /invite command if (cmdLower == "invite" && spacePos != std::string::npos) { std::string targetName = command.substr(spacePos + 1); @@ -2389,6 +6253,22 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { // /inspect command if (cmdLower == "inspect") { gameHandler.inspectTarget(); + showInspectWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + + // /threat command + if (cmdLower == "threat") { + showThreatWindow_ = !showThreatWindow_; + chatInputBuffer[0] = '\0'; + return; + } + + // /score command — BG scoreboard + if (cmdLower == "score") { + gameHandler.requestPvpLog(); + showBgScoreboard_ = true; chatInputBuffer[0] = '\0'; return; } @@ -2400,6 +6280,47 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /loc command — print player coordinates and zone name + if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { + const auto& pmi = gameHandler.getMovementInfo(); + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + char buf[256]; + snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", + pmi.x, pmi.y, pmi.z, + zoneName.empty() ? "" : " — ", + zoneName.c_str()); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = buf; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + + // /screenshot command — capture current frame to PNG + if (cmdLower == "screenshot" || cmdLower == "ss") { + takeScreenshot(gameHandler); + chatInputBuffer[0] = '\0'; + return; + } + + // /zone command — print current zone name + if (cmdLower == "zone") { + std::string zoneName; + if (auto* rend = core::Application::getInstance().getRenderer()) + zoneName = rend->getCurrentZoneName(); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + // /played command if (cmdLower == "played") { gameHandler.requestPlayedTime(); @@ -2407,6 +6328,105 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /ticket command — open GM ticket window + if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { + showGmTicketWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + + // /chathelp command — list chat-channel slash commands + if (cmdLower == "chathelp") { + static const char* kChatHelp[] = { + "--- Chat Channel Commands ---", + "/s [msg] Say to nearby players", + "/y [msg] Yell to a wider area", + "/w [msg] Whisper to player", + "/r [msg] Reply to last whisper", + "/p [msg] Party chat", + "/g [msg] Guild chat", + "/o [msg] Guild officer chat", + "/raid [msg] Raid chat", + "/rw [msg] Raid warning", + "/bg [msg] Battleground chat", + "/1 [msg] General channel", + "/2 [msg] Trade channel (also /wts /wtb)", + "/ [msg] Channel by number", + "/join Join a channel", + "/leave Leave a channel", + "/afk [msg] Set AFK status", + "/dnd [msg] Set Do Not Disturb", + }; + for (const char* line : kChatHelp) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /macrohelp command — list available macro conditionals + if (cmdLower == "macrohelp") { + static const char* kMacroHelp[] = { + "--- Macro Conditionals ---", + "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", + "State: [combat] [mounted] [swimming] [flying] [stealthed]", + " [channeling] [pet] [group] [raid] [indoors] [outdoors]", + "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", + " (prefix no- to negate any condition)", + "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", + " [target=focus] [target=pet] [target=mouseover] [target=player]", + " (also: @focus, @pet, @mouseover, @player, @target)", + "Form: [noform] [nostance] [form:0]", + "Keys: [mod:shift] [mod:ctrl] [mod:alt]", + "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", + "Other: #showtooltip, /stopmacro [cond], /castsequence", + }; + for (const char* line : kMacroHelp) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = line; + gameHandler.addLocalChatMessage(m); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /help command — list available slash commands + if (cmdLower == "help" || cmdLower == "?") { + static const char* kHelpLines[] = { + "--- Wowee Slash Commands ---", + "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", + "Social: /who /friend add/remove /ignore /unignore", + "Party: /invite /uninvite /leave /readycheck /mark /roll", + " /maintank /mainassist /raidinfo", + "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", + " /gleader /groster /ginfo /gcreate /gdisband", + "Combat: /cast /castsequence /use /startattack /stopattack", + " /stopcasting /duel /forfeit /pvp /assist", + " /follow /stopfollow /threat /combatlog", + "Items: /use /equip /equipset [name]", + "Target: /target /cleartarget /focus /clearfocus /inspect", + "Movement: /sit /stand /kneel /dismount", + "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", + " /trade /score /unstuck /logout /ticket /screenshot", + " /macrohelp /chathelp /help", + }; + for (const char* line : kHelpLines) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // /who commands if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { std::string query; @@ -2443,6 +6463,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } gameHandler.queryWho(query); + showWhoWindow_ = true; + chatInputBuffer[0] = '\0'; + return; + } + + // /combatlog command + if (cmdLower == "combatlog" || cmdLower == "cl") { + showCombatLog_ = !showCombatLog_; chatInputBuffer[0] = '\0'; return; } @@ -2581,6 +6609,96 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // Pet control commands (common macro use) + // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive + if (cmdLower == "petattack") { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendPetAction(5, target); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petfollow") { + gameHandler.sendPetAction(2, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petstay" || cmdLower == "pethalt") { + gameHandler.sendPetAction(3, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petpassive") { + gameHandler.sendPetAction(1, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petdefensive") { + gameHandler.sendPetAction(4, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petaggressive") { + gameHandler.sendPetAction(6, 0); + chatInputBuffer[0] = '\0'; + return; + } + if (cmdLower == "petdismiss") { + gameHandler.dismissPet(); + chatInputBuffer[0] = '\0'; + return; + } + + // /cancelform / /cancelshapeshift — leave current shapeshift/stance + if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { + // Cancel the first permanent shapeshift aura the player has + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + // Permanent shapeshift auras have the permanent flag (0x20) set + if (aura.flags & 0x20) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer[0] = '\0'; + return; + } + + // /cancelaura — cancel a specific buff by name or ID + if (cmdLower == "cancelaura" && spacePos != std::string::npos) { + std::string auraArg = command.substr(spacePos + 1); + while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); + while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); + // Try numeric ID first + { + std::string numStr = auraArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNum = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNum) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId) gameHandler.cancelAura(spellId); + chatInputBuffer[0] = '\0'; + return; + } + } + // Name match against player auras + std::string argLow = auraArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + std::string sn = gameHandler.getSpellName(aura.spellId); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == argLow) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer[0] = '\0'; + return; + } + // /sit command if (cmdLower == "sit") { gameHandler.setStandState(1); // 1 = sit @@ -2637,9 +6755,88 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /stopfollow command + if (cmdLower == "stopfollow") { + gameHandler.cancelFollow(); + chatInputBuffer[0] = '\0'; + return; + } + // /assist command if (cmdLower == "assist") { - gameHandler.assistTarget(); + // /assist → assist current target (use their target) + // /assist PlayerName → find PlayerName, target their target + // /assist [target=X] → evaluate conditional, target that entity's target + auto assistEntityTarget = [&](uint64_t srcGuid) { + auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); + if (!srcEnt) { gameHandler.assistTarget(); return; } + uint64_t atkGuid = 0; + const auto& flds = srcEnt->getFields(); + auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (iLo != flds.end()) { + atkGuid = iLo->second; + auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); + } + if (atkGuid != 0) { + gameHandler.setTarget(atkGuid); + } else { + std::string sn = getEntityName(srcEnt); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = (sn.empty() ? "Target" : sn) + " has no target."; + gameHandler.addLocalChatMessage(msg); + } + }; + + if (spacePos != std::string::npos) { + std::string assistArg = command.substr(spacePos + 1); + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + + // Evaluate conditionals if present + uint64_t assistOver = static_cast(-1); + if (!assistArg.empty() && assistArg.front() == '[') { + assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); + if (assistArg.empty() && assistOver == static_cast(-1)) { + chatInputBuffer[0] = '\0'; return; // no condition matched + } + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); + } + + if (assistOver != static_cast(-1) && assistOver != 0) { + assistEntityTarget(assistOver); + } else if (!assistArg.empty()) { + // Name search + std::string argLow = assistArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { + if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; + std::string nm = getEntityName(ent); + std::string nml = nm; + for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); + if (nml.find(argLow) != 0) continue; + float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) + + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); + if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } + } + if (bestGuid) assistEntityTarget(bestGuid); + else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + assistArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } else { + gameHandler.assistTarget(); + } + } else { + gameHandler.assistTarget(); + } chatInputBuffer[0] = '\0'; return; } @@ -2943,6 +7140,57 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + // /mark [icon] — set or clear a raid target mark on the current target. + // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull + // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) + // /mark — no arg marks with skull (icon 7) + if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { + if (!gameHandler.hasTarget()) { + game::MessageChatData noTgt; + noTgt.type = game::ChatType::SYSTEM; + noTgt.language = game::ChatLanguage::UNIVERSAL; + noTgt.message = "No target selected."; + gameHandler.addLocalChatMessage(noTgt); + chatInputBuffer[0] = '\0'; + return; + } + static const char* kMarkWords[] = { + "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" + }; + uint8_t icon = 7; // default: skull + if (spacePos != std::string::npos) { + std::string arg = command.substr(spacePos + 1); + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + std::string argLow = arg; + for (auto& c : argLow) c = static_cast(std::tolower(c)); + if (argLow == "clear" || argLow == "0" || argLow == "none") { + gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); + chatInputBuffer[0] = '\0'; + return; + } + bool found = false; + for (int mi = 0; mi < 8; ++mi) { + if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } + } + if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { + icon = static_cast(argLow[0] - '1'); + found = true; + } + if (!found) { + game::MessageChatData badArg; + badArg.type = game::ChatType::SYSTEM; + badArg.language = game::ChatLanguage::UNIVERSAL; + badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; + gameHandler.addLocalChatMessage(badArg); + chatInputBuffer[0] = '\0'; + return; + } + } + gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); + chatInputBuffer[0] = '\0'; + return; + } + // Combat and Trade commands if (cmdLower == "duel") { if (gameHandler.hasTarget()) { @@ -2980,14 +7228,29 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "startattack") { - if (gameHandler.hasTarget()) { - gameHandler.startAutoAttack(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You have no target."; - gameHandler.addLocalChatMessage(msg); + // Support macro conditionals: /startattack [harm,nodead] + bool condPass = true; + uint64_t saOverride = static_cast(-1); + if (spacePos != std::string::npos) { + std::string saArg = command.substr(spacePos + 1); + while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); + if (!saArg.empty() && saArg.front() == '[') { + std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); + condPass = !(result.empty() && saOverride == static_cast(-1)); + } + } + if (condPass) { + uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) + ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + if (atkTarget != 0) { + gameHandler.startAutoAttack(atkTarget); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You have no target."; + gameHandler.addLocalChatMessage(msg); + } } chatInputBuffer[0] = '\0'; return; @@ -3005,9 +7268,541 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { return; } + if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { + gameHandler.cancelQueuedSpell(); + chatInputBuffer[0] = '\0'; + return; + } + + // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) + // /equipset — list available sets in chat + if (cmdLower == "equipset") { + const auto& sets = gameHandler.getEquipmentSets(); + auto sysSay = [&](const std::string& msg) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = msg; + gameHandler.addLocalChatMessage(m); + }; + if (spacePos == std::string::npos) { + // No argument: list available sets + if (sets.empty()) { + sysSay("[System] No equipment sets saved."); + } else { + sysSay("[System] Equipment sets:"); + for (const auto& es : sets) + sysSay(" " + es.name); + } + } else { + std::string setName = command.substr(spacePos + 1); + while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); + while (!setName.empty() && setName.back() == ' ') setName.pop_back(); + // Case-insensitive prefix match + std::string setLower = setName; + std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); + const game::GameHandler::EquipmentSetInfo* found = nullptr; + for (const auto& es : sets) { + std::string nameLow = es.name; + std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); + if (nameLow == setLower || nameLow.find(setLower) == 0) { + found = &es; + break; + } + } + if (found) { + gameHandler.useEquipmentSet(found->setId); + } else { + sysSay("[System] No equipment set matching '" + setName + "'."); + } + } + chatInputBuffer[0] = '\0'; + return; + } + + // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... + // Cycles through the spell list on successive presses; resets per the reset= spec. + if (cmdLower == "castsequence" && spacePos != std::string::npos) { + std::string seqArg = command.substr(spacePos + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + + // Macro conditionals + uint64_t seqTgtOver = static_cast(-1); + if (!seqArg.empty() && seqArg.front() == '[') { + seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); + if (seqArg.empty() && seqTgtOver == static_cast(-1)) { + chatInputBuffer[0] = '\0'; return; + } + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); + } + + // Optional reset= spec (may contain slash-separated conditions: reset=5/target) + std::string resetSpec; + if (seqArg.rfind("reset=", 0) == 0) { + size_t spAfter = seqArg.find(' '); + if (spAfter != std::string::npos) { + resetSpec = seqArg.substr(6, spAfter - 6); + seqArg = seqArg.substr(spAfter + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + } + } + + // Parse comma-separated spell list + std::vector seqSpells; + { + std::string cur; + for (char c : seqArg) { + if (c == ',') { + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + cur.clear(); + } else { cur += c; } + } + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + } + if (seqSpells.empty()) { chatInputBuffer[0] = '\0'; return; } + + // Build stable key from lowercase spell list + std::string seqKey; + for (size_t k = 0; k < seqSpells.size(); ++k) { + if (k) seqKey += ','; + std::string sl = seqSpells[k]; + for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); + seqKey += sl; + } + + auto& seqState = s_castSeqStates[seqKey]; + + // Check reset conditions (slash-separated: e.g. "5/target") + float nowSec = static_cast(ImGui::GetTime()); + bool shouldReset = false; + if (!resetSpec.empty()) { + size_t rpos = 0; + while (rpos <= resetSpec.size()) { + size_t slash = resetSpec.find('/', rpos); + std::string part = (slash != std::string::npos) + ? resetSpec.substr(rpos, slash - rpos) + : resetSpec.substr(rpos); + std::string plow = part; + for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); + bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), + [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (isNum) { + float rSec = 0.0f; + try { rSec = std::stof(plow); } catch (...) {} + if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; + } else if (plow == "target") { + if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; + } else if (plow == "combat") { + if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; + } + if (slash == std::string::npos) break; + rpos = slash + 1; + } + } + if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; + + const std::string& seqSpell = seqSpells[seqState.index]; + seqState.index = (seqState.index + 1) % seqSpells.size(); + seqState.lastPressSec = nowSec; + seqState.lastTargetGuid = gameHandler.getTargetGuid(); + seqState.lastInCombat = gameHandler.isInCombat(); + + // Cast the selected spell — mirrors /cast spell lookup + std::string ssLow = seqSpell; + for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); + if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); + + uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) + ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + + // Numeric ID + if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); + bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (ssNumeric) { + uint32_t ssId = 0; + try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} + if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); + } else { + uint32_t ssBest = 0; int ssBestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl != ssLow) continue; + int sRnk = 0; + const std::string& rk = gameHandler.getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } + } + if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } + } + if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); + } + chatInputBuffer[0] = '\0'; + return; + } + + if (cmdLower == "cast" && spacePos != std::string::npos) { + std::string spellArg = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + + // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal + uint64_t castTargetOverride = static_cast(-1); + if (!spellArg.empty() && spellArg.front() == '[') { + spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); + if (spellArg.empty()) { + chatInputBuffer[0] = '\0'; + return; // No conditional matched — skip cast + } + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + } + + // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) + if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); + + // Support numeric spell ID: /cast 133 or /cast #133 + { + std::string numStr = spellArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId != 0) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(spellId, targetGuid); + } + chatInputBuffer[0] = '\0'; + return; + } + } + + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" + int requestedRank = -1; // -1 = highest rank + std::string spellName = spellArg; + { + auto rankPos = spellArg.find('('); + if (rankPos != std::string::npos) { + std::string rankStr = spellArg.substr(rankPos + 1); + // Strip closing paren and whitespace + auto closePos = rankStr.find(')'); + if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); + for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); + // Expect "rank N" + if (rankStr.rfind("rank ", 0) == 0) { + try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} + } + spellName = spellArg.substr(0, rankPos); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + } + + std::string spellNameLower = spellName; + for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); + + // Search known spells for a name match; pick highest rank (or specific rank) + uint32_t bestSpellId = 0; + int bestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sName = gameHandler.getSpellName(sid); + if (sName.empty()) continue; + std::string sNameLower = sName; + for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); + if (sNameLower != spellNameLower) continue; + + // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) + int sRank = 0; + const std::string& rankStr = gameHandler.getSpellRank(sid); + if (!rankStr.empty()) { + std::string rLow = rankStr; + for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); + if (rLow.rfind("rank ", 0) == 0) { + try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} + } + } + + if (requestedRank >= 0) { + if (sRank == requestedRank) { bestSpellId = sid; break; } + } else { + if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } + } + } + + if (bestSpellId) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(bestSpellId, targetGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = requestedRank >= 0 + ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." + : "Unknown spell: '" + spellName + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /use + // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, + // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) + if (cmdLower == "use" && spacePos != std::string::npos) { + std::string useArg = command.substr(spacePos + 1); + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + + // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem + if (!useArg.empty() && useArg.front() == '[') { + uint64_t dummy = static_cast(-1); + useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); + if (useArg.empty()) { chatInputBuffer[0] = '\0'; return; } + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + } + + // Check for bag/slot notation: two numbers separated by whitespace + { + std::istringstream iss(useArg); + int bagNum = -1, slotNum = -1; + iss >> bagNum >> slotNum; + if (!iss.fail() && slotNum >= 1) { + if (bagNum == 0) { + // Backpack: bag=0, slot 1-based → 0-based + gameHandler.useItemBySlot(slotNum - 1); + chatInputBuffer[0] = '\0'; + return; + } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { + // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) + gameHandler.useItemInBag(bagNum - 1, slotNum - 1); + chatInputBuffer[0] = '\0'; + return; + } + } + } + + // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) + { + std::string numStr = useArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) + int slotNum = 0; + try { slotNum = std::stoi(numStr); } catch (...) {} + if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { + auto eslot = static_cast(slotNum - 1); + const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); + if (!esl.empty()) + gameHandler.useItemById(esl.item.itemId); + } + chatInputBuffer[0] = '\0'; + return; + } + } + + std::string useArgLower = useArg; + for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + useArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + + // /equip — auto-equip an item from backpack/bags by name + if (cmdLower == "equip" && spacePos != std::string::npos) { + std::string equipArg = command.substr(spacePos + 1); + while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); + while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); + std::string equipArgLower = equipArg; + for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + equipArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer[0] = '\0'; + return; + } + // Targeting commands if (cmdLower == "cleartarget") { - gameHandler.clearTarget(); + // Support macro conditionals: /cleartarget [dead] clears only if target is dead + bool ctCondPass = true; + if (spacePos != std::string::npos) { + std::string ctArg = command.substr(spacePos + 1); + while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); + if (!ctArg.empty() && ctArg.front() == '[') { + uint64_t ctOver = static_cast(-1); + std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); + ctCondPass = !(res.empty() && ctOver == static_cast(-1)); + } + } + if (ctCondPass) gameHandler.clearTarget(); + chatInputBuffer[0] = '\0'; + return; + } + + if (cmdLower == "target" && spacePos != std::string::npos) { + // Search visible entities for name match (case-insensitive prefix). + // Among all matches, pick the nearest living unit to the player. + // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss + std::string targetArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t targetCmdOverride = static_cast(-1); + if (!targetArg.empty() && targetArg.front() == '[') { + targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); + if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { + // No condition matched — silently skip (macro fallthrough) + chatInputBuffer[0] = '\0'; + return; + } + while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); + while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); + } + + // If conditionals resolved to a specific GUID, target it directly + if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { + gameHandler.setTarget(targetCmdOverride); + chatInputBuffer[0] = '\0'; + return; + } + + // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently + if (targetArg.empty()) { + chatInputBuffer[0] = '\0'; + return; + } + + std::string targetArgLower = targetArg; + for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + const float playerX = pmi.x; + const float playerY = pmi.y; + const float playerZ = pmi.z; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(targetArgLower) == 0) { + float dx = entity->getX() - playerX; + float dy = entity->getY() - playerY; + float dz = entity->getZ() - playerZ; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { + bestDist = dist; + bestGuid = guid; + } + } + } + if (bestGuid) { + gameHandler.setTarget(bestGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No target matching '" + targetArg + "' found."; + gameHandler.addLocalChatMessage(sysMsg); + } chatInputBuffer[0] = '\0'; return; } @@ -3043,7 +7838,64 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } if (cmdLower == "focus") { - if (gameHandler.hasTarget()) { + // /focus → set current target as focus + // /focus PlayerName → search for entity by name and set as focus + // /focus [target=X] Name → macro conditional: set focus to resolved target + if (spacePos != std::string::npos) { + std::string focusArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t focusCmdOverride = static_cast(-1); + if (!focusArg.empty() && focusArg.front() == '[') { + focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); + if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { + chatInputBuffer[0] = '\0'; + return; + } + while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); + while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); + } + + if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { + // Conditional resolved to a specific GUID (e.g. [target=mouseover]) + gameHandler.setFocus(focusCmdOverride); + } else if (!focusArg.empty()) { + // Name search — same logic as /target + std::string focusArgLower = focusArg; + for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(focusArgLower) == 0) { + float dx = entity->getX() - pmi.x; + float dy = entity->getY() - pmi.y; + float dz = entity->getZ() - pmi.z; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { bestDist = dist; bestGuid = guid; } + } + } + if (bestGuid) { + gameHandler.setFocus(bestGuid); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + focusArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } + } else if (gameHandler.hasTarget()) { gameHandler.setFocus(gameHandler.getTargetGuid()); } else { game::MessageChatData msg; @@ -3200,6 +8052,31 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { } chatInputBuffer[0] = '\0'; return; + } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { + // /wts and /wtb — send to Trade channel + // Prefix with [WTS] / [WTB] and route to the Trade channel + const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; + const std::string body = command.substr(spacePos + 1); + // Find the Trade channel among joined channels (case-insensitive prefix match) + std::string tradeChan; + for (const auto& ch : gameHandler.getJoinedChannels()) { + std::string chLow = ch; + for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); + if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } + } + if (tradeChan.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "You are not in the Trade channel."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer[0] = '\0'; + return; + } + message = tag + body; + type = game::ChatType::CHANNEL; + target = tradeChan; + isChannelCommand = true; } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { // /1 msg, /2 msg — channel shortcuts int channelIdx = cmdLower[0] - '0'; @@ -3246,6 +8123,28 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { message = ""; isChannelCommand = true; } + } else if (cmdLower == "r" || cmdLower == "reply") { + switchChatType = 4; + std::string lastSender = gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No one has whispered you yet."; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer[0] = '\0'; + return; + } + target = lastSender; + strncpy(whisperTargetBuffer, target.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + if (spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::WHISPER; + } else { + message = ""; + } + isChannelCommand = true; } // Check for emote commands @@ -3306,6 +8205,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { case 7: type = game::ChatType::BATTLEGROUND; break; case 8: type = game::ChatType::RAID_WARNING; break; case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx]; + } else { type = game::ChatType::SAY; } + break; + } default: type = game::ChatType::SAY; break; } } @@ -3322,6 +8229,14 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { case 7: type = game::ChatType::BATTLEGROUND; break; case 8: type = game::ChatType::RAID_WARNING; break; case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx]; + } else { type = game::ChatType::SAY; } + break; + } default: type = game::ChatType::SAY; break; } } @@ -3374,29 +8289,32 @@ void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { const char* GameScreen::getChatTypeName(game::ChatType type) const { switch (type) { - case game::ChatType::SAY: return "SAY"; - case game::ChatType::YELL: return "YELL"; - case game::ChatType::EMOTE: return "EMOTE"; - case game::ChatType::TEXT_EMOTE: return "EMOTE"; - case game::ChatType::PARTY: return "PARTY"; - case game::ChatType::GUILD: return "GUILD"; - case game::ChatType::OFFICER: return "OFFICER"; - case game::ChatType::RAID: return "RAID"; - case game::ChatType::RAID_LEADER: return "RAID LEADER"; - case game::ChatType::RAID_WARNING: return "RAID WARNING"; - case game::ChatType::BATTLEGROUND: return "BATTLEGROUND"; - case game::ChatType::BATTLEGROUND_LEADER: return "BG LEADER"; - case game::ChatType::WHISPER: return "WHISPER"; - case game::ChatType::WHISPER_INFORM: return "TO"; - case game::ChatType::SYSTEM: return "SYSTEM"; - case game::ChatType::MONSTER_SAY: return "SAY"; - case game::ChatType::MONSTER_YELL: return "YELL"; - case game::ChatType::MONSTER_EMOTE: return "EMOTE"; - case game::ChatType::CHANNEL: return "CHANNEL"; - case game::ChatType::ACHIEVEMENT: return "ACHIEVEMENT"; + case game::ChatType::SAY: return "Say"; + case game::ChatType::YELL: return "Yell"; + case game::ChatType::EMOTE: return "Emote"; + case game::ChatType::TEXT_EMOTE: return "Emote"; + case game::ChatType::PARTY: return "Party"; + case game::ChatType::GUILD: return "Guild"; + case game::ChatType::OFFICER: return "Officer"; + case game::ChatType::RAID: return "Raid"; + case game::ChatType::RAID_LEADER: return "Raid Leader"; + case game::ChatType::RAID_WARNING: return "Raid Warning"; + case game::ChatType::BATTLEGROUND: return "Battleground"; + case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; + case game::ChatType::WHISPER: return "Whisper"; + case game::ChatType::WHISPER_INFORM: return "To"; + case game::ChatType::SYSTEM: return "System"; + case game::ChatType::MONSTER_SAY: return "Say"; + case game::ChatType::MONSTER_YELL: return "Yell"; + case game::ChatType::MONSTER_EMOTE: return "Emote"; + case game::ChatType::CHANNEL: return "Channel"; + case game::ChatType::ACHIEVEMENT: return "Achievement"; case game::ChatType::DND: return "DND"; case game::ChatType::AFK: return "AFK"; - default: return "UNKNOWN"; + case game::ChatType::BG_SYSTEM_NEUTRAL: + case game::ChatType::BG_SYSTEM_ALLIANCE: + case game::ChatType::BG_SYSTEM_HORDE: return "System"; + default: return "Unknown"; } } @@ -3442,6 +8360,28 @@ ImVec4 GameScreen::getChatTypeColor(game::ChatType type) const { return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink case game::ChatType::ACHIEVEMENT: return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow + case game::ChatType::GUILD_ACHIEVEMENT: + return ImVec4(1.0f, 0.84f, 0.0f, 1.0f); // Gold + case game::ChatType::SKILL: + return ImVec4(0.0f, 0.8f, 1.0f, 1.0f); // Cyan + case game::ChatType::LOOT: + return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple + case game::ChatType::MONSTER_WHISPER: + case game::ChatType::RAID_BOSS_WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) + case game::ChatType::RAID_BOSS_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::MONSTER_PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) + case game::ChatType::BG_SYSTEM_NEUTRAL: + return ImVec4(1.0f, 0.84f, 0.0f, 1.0f); // Gold + case game::ChatType::BG_SYSTEM_ALLIANCE: + return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue + case game::ChatType::BG_SYSTEM_HORDE: + return ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // Red + case game::ChatType::AFK: + case game::ChatType::DND: + return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray default: return ImVec4(0.7f, 0.7f, 0.7f, 1.0f); // Gray } @@ -3728,6 +8668,8 @@ void GameScreen::updateCharacterTextures(game::Inventory& inventory) { // ============================================================ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { + if (!showWorldMap_) return; + auto& app = core::Application::getInstance(); auto* renderer = app.getRenderer(); if (!renderer) return; @@ -3744,11 +8686,83 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { gameHandler.getPlayerExploredZoneMasks(), gameHandler.hasPlayerExploredZoneMasks()); + // Party member dots on world map + { + std::vector dots; + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + for (const auto& member : partyData.members) { + if (!member.isOnline || !member.hasPartyStats) continue; + if (member.posX == 0 && member.posY == 0) continue; + // posY → canonical X (north), posX → canonical Y (west) + float wowX = static_cast(member.posY); + float wowY = static_cast(member.posX); + glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); + auto ent = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(ent.get()); + ImU32 col = (cid != 0) + ? classColorU32(cid, 230) + : (member.guid == partyData.leaderGuid + ? IM_COL32(255, 210, 0, 230) + : IM_COL32(100, 180, 255, 230)); + dots.push_back({ rpos, col, member.name }); + } + } + wm->setPartyDots(std::move(dots)); + } + + // Taxi node markers on world map + { + std::vector taxiNodes; + const auto& nodes = gameHandler.getTaxiNodes(); + taxiNodes.reserve(nodes.size()); + for (const auto& [id, node] : nodes) { + rendering::WorldMapTaxiNode wtn; + wtn.id = node.id; + wtn.mapId = node.mapId; + wtn.wowX = node.x; + wtn.wowY = node.y; + wtn.wowZ = node.z; + wtn.name = node.name; + wtn.known = gameHandler.isKnownTaxiNode(id); + taxiNodes.push_back(std::move(wtn)); + } + wm->setTaxiNodes(std::move(taxiNodes)); + } + + // Quest POI markers on world map (from SMSG_QUEST_POI_QUERY_RESPONSE / gossip POIs) + { + std::vector qpois; + for (const auto& poi : gameHandler.getGossipPois()) { + rendering::WorldMap::QuestPoi qp; + qp.wowX = poi.x; + qp.wowY = poi.y; + qp.name = poi.name; + qpois.push_back(std::move(qp)); + } + wm->setQuestPois(std::move(qpois)); + } + + // Corpse marker: show skull X on world map when ghost with unclaimed corpse + { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + bool ghostWithCorpse = gameHandler.isPlayerGhost() && + gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY); + glm::vec3 corpseRender = ghostWithCorpse + ? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)) + : glm::vec3{}; + wm->setCorpsePos(ghostWithCorpse, corpseRender); + } + glm::vec3 playerPos = renderer->getCharacterPosition(); + float playerYaw = renderer->getCharacterYaw(); auto* window = app.getWindow(); int screenW = window ? window->getWidth() : 1280; int screenH = window ? window->getHeight() : 720; - wm->render(playerPos, screenW, screenH); + wm->render(playerPos, screenW, screenH, playerYaw); + + // Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay). + if (!wm->isOpen()) showWorldMap_ = false; } // ============================================================ @@ -3784,7 +8798,7 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; if (spellDbc && spellDbc->isLoaded()) { uint32_t fieldCount = spellDbc->getFieldCount(); - // Try expansion layout first + // Helper to load icons for a given field layout auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) { spellIconIds_.clear(); if (iconField >= fieldCount) return; @@ -3796,21 +8810,35 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage } } }; - // If the DBC has WotLK-range field count (≥200 fields), it's the binary - // WotLK Spell.dbc (CSV fallback). Use WotLK layout regardless of expansion, - // since Turtle/Classic CSV files are garbled and fall back to WotLK binary. - if (fieldCount >= 200) { - tryLoadIcons(0, 133); // WotLK IconID field - } else if (spellL) { - tryLoadIcons((*spellL)["ID"], (*spellL)["IconID"]); - } - // Fallback to WotLK field 133 if expansion layout yielded nothing - if (spellIconIds_.empty() && fieldCount > 133) { - tryLoadIcons(0, 133); + + // Use expansion-aware layout if available AND the DBC field count + // matches the expansion's expected format. Classic=173, TBC=216, + // WotLK=234 fields. When Classic is active but the base WotLK DBC + // is loaded (234 fields), field 117 is NOT IconID — we must use + // the WotLK field 133 instead. + uint32_t iconField = 133; // WotLK default + uint32_t idField = 0; + if (spellL) { + uint32_t layoutIcon = (*spellL)["IconID"]; + // Only trust the expansion layout if the DBC has a compatible + // field count (within ~20 of the layout's icon field). + if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { + iconField = layoutIcon; + idField = (*spellL)["ID"]; + } } + tryLoadIcons(idField, iconField); } } + // Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached + // (e.g., first login, after loading screen, or many new auras appearing at once). + static int gsLoadsThisFrame = 0; + static int gsLastImGuiFrame = -1; + int gsCurFrame = ImGui::GetFrameCount(); + if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; } + if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + // Look up spellId -> SpellIconID -> icon path auto iit = spellIconIds_.find(spellId); if (iit == spellIconIds_.end()) { @@ -3846,18 +8874,96 @@ VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManage return VK_NULL_HANDLE; } + ++gsLoadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache_[spellId] = ds; return ds; } +uint32_t GameScreen::resolveMacroPrimarySpellId(uint32_t macroId, game::GameHandler& gameHandler) { + // Invalidate cache when spell list changes (learning/unlearning spells) + size_t curSpellCount = gameHandler.getKnownSpells().size(); + if (curSpellCount != macroCacheSpellCount_) { + macroPrimarySpellCache_.clear(); + macroCacheSpellCount_ = curSpellCount; + } + auto cacheIt = macroPrimarySpellCache_.find(macroId); + if (cacheIt != macroPrimarySpellCache_.end()) return cacheIt->second; + + const std::string& macroText = gameHandler.getMacroText(macroId); + uint32_t result = 0; + if (!macroText.empty()) { + for (const auto& cmdLine : allMacroCommands(macroText)) { + std::string cl = cmdLine; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + bool isCast = (cl.rfind("/cast ", 0) == 0); + bool isCastSeq = (cl.rfind("/castsequence ", 0) == 0); + bool isUse = (cl.rfind("/use ", 0) == 0); + if (!isCast && !isCastSeq && !isUse) continue; + size_t sp2 = cmdLine.find(' '); + if (sp2 == std::string::npos) continue; + std::string spellArg = cmdLine.substr(sp2 + 1); + // Strip conditionals [...] + if (!spellArg.empty() && spellArg.front() == '[') { + size_t ce = spellArg.find(']'); + if (ce != std::string::npos) spellArg = spellArg.substr(ce + 1); + } + // Strip reset= spec for castsequence + if (isCastSeq) { + std::string tmp = spellArg; + while (!tmp.empty() && tmp.front() == ' ') tmp.erase(tmp.begin()); + if (tmp.rfind("reset=", 0) == 0) { + size_t spAfter = tmp.find(' '); + if (spAfter != std::string::npos) spellArg = tmp.substr(spAfter + 1); + } + } + // Take first alternative before ';' (for /cast) or first spell before ',' (for /castsequence) + size_t semi = spellArg.find(isCastSeq ? ',' : ';'); + if (semi != std::string::npos) spellArg = spellArg.substr(0, semi); + size_t ss = spellArg.find_first_not_of(" \t!"); + if (ss != std::string::npos) spellArg = spellArg.substr(ss); + size_t se = spellArg.find_last_not_of(" \t"); + if (se != std::string::npos) spellArg.resize(se + 1); + if (spellArg.empty()) continue; + std::string spLow = spellArg; + for (char& c : spLow) c = static_cast(std::tolower(static_cast(c))); + if (isUse) { + // /use resolves an item name → find the item's on-use spell ID + for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { + if (!info.valid) continue; + std::string iName = info.name; + for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); + if (iName == spLow) { + for (const auto& sp : info.spells) { + if (sp.spellId != 0 && sp.spellTrigger == 0) { result = sp.spellId; break; } + } + break; + } + } + } else { + // /cast and /castsequence resolve a spell name + for (uint32_t sid : gameHandler.getKnownSpells()) { + std::string sn = gameHandler.getSpellName(sid); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == spLow) { result = sid; break; } + } + } + break; + } + } + macroPrimarySpellCache_[macroId] = result; + return result; +} + void GameScreen::renderActionBar(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + // Use ImGui's display size — always in sync with the current swap-chain/frame, + // whereas window->getWidth/Height() can lag by one frame on resize events. + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* assetMgr = core::Application::getInstance().getAssetManager(); - float slotSize = 48.0f; + float slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; @@ -3896,6 +9002,75 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { const auto& slot = bar[absSlot]; bool onCooldown = !slot.isReady(); + // Macro cooldown: check the cached primary spell's cooldown. + float macroCooldownRemaining = 0.0f; + float macroCooldownTotal = 0.0f; + if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !onCooldown) { + uint32_t macroSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + if (macroSpellId != 0) { + float cd = gameHandler.getSpellCooldown(macroSpellId); + if (cd > 0.0f) { + macroCooldownRemaining = cd; + macroCooldownTotal = cd; + onCooldown = true; + } + } + } + + const bool onGCD = gameHandler.isGCDActive() && !onCooldown && !slot.isEmpty(); + + // Out-of-range check: red tint when a targeted spell cannot reach the current target. + // Applies to SPELL and MACRO slots with a known max range (>5 yd) and an active target. + // Item range is checked below after barItemDef is populated. + bool outOfRange = false; + { + uint32_t rangeCheckSpellId = 0; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) + rangeCheckSpellId = slot.id; + else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) + rangeCheckSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + if (rangeCheckSpellId != 0 && !onCooldown && gameHandler.hasTarget()) { + uint32_t maxRange = spellbookScreen.getSpellMaxRange(rangeCheckSpellId, assetMgr); + if (maxRange > 5) { + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(maxRange)) + outOfRange = true; + } + } + } + } + + // Insufficient-power check: tint when player doesn't have enough power to cast. + // Applies to SPELL and MACRO slots with a known power cost. + bool insufficientPower = false; + { + uint32_t powerCheckSpellId = 0; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) + powerCheckSpellId = slot.id; + else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) + powerCheckSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + uint32_t spellCost = 0, spellPowerType = 0; + if (powerCheckSpellId != 0 && !onCooldown) + spellbookScreen.getSpellPowerInfo(powerCheckSpellId, assetMgr, spellCost, spellPowerType); + if (spellCost > 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt && (playerEnt->getType() == game::ObjectType::PLAYER || + playerEnt->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEnt); + if (unit->getPowerType() == static_cast(spellPowerType)) { + if (unit->getPower() < spellCost) + insufficientPower = true; + } + } + } + } + auto getSpellName = [&](uint32_t spellId) -> std::string { std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); if (!name.empty()) return name; @@ -3942,20 +9117,137 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); } + // Macro icon: #showtooltip [SpellName] → show that spell's icon on the button + bool macroIsUseCmd = false; // tracks if the macro's primary command is /use (for item icon fallback) + if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0 && !iconTex) { + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + std::string showArg = getMacroShowtooltipArg(macroText); + if (showArg.empty() || showArg == "__auto__") { + // No explicit #showtooltip arg — derive spell from first /cast, /castsequence, or /use line + for (const auto& cmdLine : allMacroCommands(macroText)) { + if (cmdLine.size() < 6) continue; + std::string cl = cmdLine; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + bool isCastCmd = (cl.rfind("/cast ", 0) == 0 || cl == "/cast"); + bool isCastSeqCmd = (cl.rfind("/castsequence ", 0) == 0); + bool isUseCmd = (cl.rfind("/use ", 0) == 0); + if (isUseCmd) macroIsUseCmd = true; + if (!isCastCmd && !isCastSeqCmd && !isUseCmd) continue; + size_t sp2 = cmdLine.find(' '); + if (sp2 == std::string::npos) continue; + showArg = cmdLine.substr(sp2 + 1); + // Strip conditionals [...] + if (!showArg.empty() && showArg.front() == '[') { + size_t ce = showArg.find(']'); + if (ce != std::string::npos) showArg = showArg.substr(ce + 1); + } + // Strip reset= spec for castsequence + if (isCastSeqCmd) { + std::string tmp = showArg; + while (!tmp.empty() && tmp.front() == ' ') tmp.erase(tmp.begin()); + if (tmp.rfind("reset=", 0) == 0) { + size_t spA = tmp.find(' '); + if (spA != std::string::npos) showArg = tmp.substr(spA + 1); + } + } + // First alternative: ';' for /cast, ',' for /castsequence + size_t sep = showArg.find(isCastSeqCmd ? ',' : ';'); + if (sep != std::string::npos) showArg = showArg.substr(0, sep); + // Trim and strip '!' + size_t ss = showArg.find_first_not_of(" \t!"); + if (ss != std::string::npos) showArg = showArg.substr(ss); + size_t se = showArg.find_last_not_of(" \t"); + if (se != std::string::npos) showArg.resize(se + 1); + break; + } + } + // Look up the spell icon by name + if (!showArg.empty() && showArg != "__auto__") { + std::string showLower = showArg; + for (char& c : showLower) c = static_cast(std::tolower(static_cast(c))); + // Also strip "(Rank N)" suffix for matching + size_t rankParen = showLower.find('('); + if (rankParen != std::string::npos) showLower.resize(rankParen); + while (!showLower.empty() && showLower.back() == ' ') showLower.pop_back(); + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl == showLower) { + iconTex = assetMgr ? getSpellIcon(sid, assetMgr) : VK_NULL_HANDLE; + if (iconTex) break; + } + } + // Fallback for /use macros: if no spell matched, search item cache for the item icon + if (!iconTex && macroIsUseCmd) { + for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { + if (!info.valid) continue; + std::string iName = info.name; + for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); + if (iName == showLower && info.displayInfoId != 0) { + iconTex = inventoryScreen.getItemIcon(info.displayInfoId); + break; + } + } + } + } + } + } + + // Item-missing check: grey out item slots whose item is not in the player's inventory. + const bool itemMissing = (slot.type == game::ActionBarSlot::ITEM && slot.id != 0 + && barItemDef == nullptr && !onCooldown); + + // Ranged item out-of-range check (runs after barItemDef is populated above). + // invType 15=Ranged (bow/gun/crossbow), 26=Thrown, 28=RangedRight (wand/crossbow). + if (!outOfRange && slot.type == game::ActionBarSlot::ITEM && barItemDef + && !onCooldown && gameHandler.hasTarget()) { + constexpr uint8_t INVTYPE_RANGED = 15; + constexpr uint8_t INVTYPE_THROWN = 26; + constexpr uint8_t INVTYPE_RANGEDRIGHT = 28; + uint32_t itemMaxRange = 0; + if (barItemDef->inventoryType == INVTYPE_RANGED || + barItemDef->inventoryType == INVTYPE_RANGEDRIGHT) + itemMaxRange = 40; + else if (barItemDef->inventoryType == INVTYPE_THROWN) + itemMaxRange = 30; + if (itemMaxRange > 0) { + auto& em = gameHandler.getEntityManager(); + auto playerEnt = em.getEntity(gameHandler.getPlayerGuid()); + auto targetEnt = em.getEntity(gameHandler.getTargetGuid()); + if (playerEnt && targetEnt) { + float dx = playerEnt->getX() - targetEnt->getX(); + float dy = playerEnt->getY() - targetEnt->getY(); + float dz = playerEnt->getZ() - targetEnt->getZ(); + if (std::sqrt(dx*dx + dy*dy + dz*dz) > static_cast(itemMaxRange)) + outOfRange = true; + } + } + } + bool clicked = false; if (iconTex) { ImVec4 tintColor(1, 1, 1, 1); ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); - if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + else if (onGCD) { tintColor = ImVec4(0.6f, 0.6f, 0.6f, 0.85f); } + else if (outOfRange) { tintColor = ImVec4(0.85f, 0.35f, 0.35f, 0.9f); } + else if (insufficientPower) { tintColor = ImVec4(0.6f, 0.5f, 0.9f, 0.85f); } + else if (itemMissing) { tintColor = ImVec4(0.35f, 0.35f, 0.35f, 0.7f); } clicked = ImGui::ImageButton("##icon", (ImTextureID)(uintptr_t)iconTex, ImVec2(slotSize, slotSize), ImVec2(0, 0), ImVec2(1, 1), bgColor, tintColor); } else { - if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); - else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (outOfRange) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.45f, 0.15f, 0.15f, 0.9f)); + else if (insufficientPower)ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.15f, 0.4f, 0.9f)); + else if (itemMissing) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.12f, 0.12f, 0.12f, 0.7f)); + else if (slot.isEmpty()) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); char label[32]; if (slot.type == game::ActionBarSlot::SPELL) { @@ -3977,7 +9269,31 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } - bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); + // Error-flash overlay: red fade on spell cast failure (~0.5 s). + // Check both spell slots directly and macro slots via their primary spell. + { + uint32_t flashSpellId = 0; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) + flashSpellId = slot.id; + else if (slot.type == game::ActionBarSlot::MACRO && slot.id != 0) + flashSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + auto flashIt = (flashSpellId != 0) ? actionFlashEndTimes_.find(flashSpellId) : actionFlashEndTimes_.end(); + if (flashIt != actionFlashEndTimes_.end()) { + float now = static_cast(ImGui::GetTime()); + float remaining = flashIt->second - now; + if (remaining > 0.0f) { + float alpha = remaining / kActionFlashDuration; // 1→0 + ImVec2 rMin = ImGui::GetItemRectMin(); + ImVec2 rMax = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddRectFilled( + rMin, rMax, + ImGui::ColorConvertFloat4ToU32(ImVec4(1.0f, 0.1f, 0.1f, 0.55f * alpha))); + } else { + actionFlashEndTimes_.erase(flashIt); + } + } + } + bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && ImGui::IsMouseReleased(ImGuiMouseButton_Left); @@ -4003,48 +9319,187 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { gameHandler.castSpell(slot.id, target); } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { gameHandler.useItemById(slot.id); + } else if (slot.type == game::ActionBarSlot::MACRO) { + executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); + } + } + + // Right-click context menu for non-empty slots + if (!slot.isEmpty()) { + // Use a unique popup ID per slot so multiple slots don't share state + char ctxId[32]; + snprintf(ctxId, sizeof(ctxId), "##ABCtx%d", absSlot); + if (ImGui::BeginPopupContextItem(ctxId)) { + if (slot.type == game::ActionBarSlot::SPELL) { + std::string spellName = getSpellName(slot.id); + ImGui::TextDisabled("%s", spellName.c_str()); + ImGui::Separator(); + if (onCooldown) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Cast")) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } + if (onCooldown) ImGui::EndDisabled(); + } else if (slot.type == game::ActionBarSlot::ITEM) { + const char* iName = (barItemDef && !barItemDef->name.empty()) + ? barItemDef->name.c_str() + : (!itemNameFromQuery.empty() ? itemNameFromQuery.c_str() : "Item"); + ImGui::TextDisabled("%s", iName); + ImGui::Separator(); + if (ImGui::MenuItem("Use")) { + gameHandler.useItemById(slot.id); + } + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::TextDisabled("Macro #%u", slot.id); + ImGui::Separator(); + if (ImGui::MenuItem("Execute")) { + executeMacroText(gameHandler, gameHandler.getMacroText(slot.id)); + } + if (ImGui::MenuItem("Edit")) { + const std::string& txt = gameHandler.getMacroText(slot.id); + strncpy(macroEditorBuf_, txt.c_str(), sizeof(macroEditorBuf_) - 1); + macroEditorBuf_[sizeof(macroEditorBuf_) - 1] = '\0'; + macroEditorId_ = slot.id; + macroEditorOpen_ = true; + } + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Slot")) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::EMPTY, 0); + } + ImGui::EndPopup(); } - } else if (rightClicked && !slot.isEmpty()) { - actionBarDragSlot_ = absSlot; - actionBarDragIcon_ = iconTex; } // Tooltip if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { - ImGui::BeginTooltip(); if (slot.type == game::ActionBarSlot::SPELL) { - ImGui::Text("%s", getSpellName(slot.id).c_str()); + // Use the spellbook's rich tooltip (school, cost, cast time, range, description). + // Falls back to the simple name if DBC data isn't loaded yet. + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(slot.id, gameHandler, assetMgr); + if (!richOk) { + ImGui::Text("%s", getSpellName(slot.id).c_str()); + } + // Hearthstone: add location note after the spell tooltip body if (slot.id == 8690) { uint32_t mapId = 0; glm::vec3 pos; if (gameHandler.getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; + std::string homeLocation; + // Zone name (from zoneId stored in bind point) + uint32_t zoneId = gameHandler.getHomeBindZoneId(); + if (zoneId != 0) { + homeLocation = gameHandler.getWhoAreaName(zoneId); } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + default: homeLocation = "Unknown"; break; + } + } + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), + "Home: %s", homeLocation.c_str()); } - ImGui::TextDisabled("Use: Teleport home"); } + if (outOfRange) { + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Out of range"); + } + if (insufficientPower) { + ImGui::TextColored(ImVec4(0.75f, 0.55f, 1.0f, 1.0f), "Not enough power"); + } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); + } else if (slot.type == game::ActionBarSlot::MACRO) { + ImGui::BeginTooltip(); + // Show the primary spell's rich tooltip (like WoW does for macro buttons) + uint32_t macroSpellId = resolveMacroPrimarySpellId(slot.id, gameHandler); + bool showedRich = false; + if (macroSpellId != 0) { + showedRich = spellbookScreen.renderSpellInfoTooltip(macroSpellId, gameHandler, assetMgr); + if (onCooldown && macroCooldownRemaining > 0.0f) { + float cd = macroCooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + } + if (!showedRich) { + // For /use macros: try showing the item tooltip instead + if (macroIsUseCmd) { + const std::string& macroText = gameHandler.getMacroText(slot.id); + // Extract item name from first /use command + for (const auto& cmd : allMacroCommands(macroText)) { + std::string cl = cmd; + for (char& c : cl) c = static_cast(std::tolower(static_cast(c))); + if (cl.rfind("/use ", 0) != 0) continue; + size_t sp = cmd.find(' '); + if (sp == std::string::npos) continue; + std::string itemArg = cmd.substr(sp + 1); + while (!itemArg.empty() && itemArg.front() == ' ') itemArg.erase(itemArg.begin()); + while (!itemArg.empty() && itemArg.back() == ' ') itemArg.pop_back(); + std::string itemLow = itemArg; + for (char& c : itemLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& [entry, info] : gameHandler.getItemInfoCache()) { + if (!info.valid) continue; + std::string iName = info.name; + for (char& c : iName) c = static_cast(std::tolower(static_cast(c))); + if (iName == itemLow) { + inventoryScreen.renderItemTooltip(info); + showedRich = true; + break; + } + } + break; + } + } + if (!showedRich) { + ImGui::Text("Macro #%u", slot.id); + const std::string& macroText = gameHandler.getMacroText(slot.id); + if (!macroText.empty()) { + ImGui::Separator(); + ImGui::TextUnformatted(macroText.c_str()); + } else { + ImGui::TextDisabled("(no text — right-click to Edit)"); + } + } + } + ImGui::EndTooltip(); } else if (slot.type == game::ActionBarSlot::ITEM) { - if (barItemDef && !barItemDef->name.empty()) + ImGui::BeginTooltip(); + // Prefer full rich tooltip from ItemQueryResponseData (has stats, quality, set info) + const auto* itemQueryInfo = gameHandler.getItemInfo(slot.id); + if (itemQueryInfo && itemQueryInfo->valid) { + inventoryScreen.renderItemTooltip(*itemQueryInfo); + } else if (barItemDef && !barItemDef->name.empty()) { ImGui::Text("%s", barItemDef->name.c_str()); - else if (!itemNameFromQuery.empty()) + } else if (!itemNameFromQuery.empty()) { ImGui::Text("%s", itemNameFromQuery.c_str()); - else + } else { ImGui::Text("Item #%u", slot.id); + } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); } - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); - else - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Cooldown: %.1f sec", cd); - } - ImGui::EndTooltip(); } // Cooldown overlay: WoW-style clock-sweep + time text @@ -4056,8 +9511,11 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { float r = (btnMax.x - btnMin.x) * 0.5f; auto* dl = ImGui::GetWindowDrawList(); - float total = (slot.cooldownTotal > 0.0f) ? slot.cooldownTotal : 1.0f; - float elapsed = total - slot.cooldownRemaining; + // For macros, use the resolved primary spell cooldown instead of the slot's own. + float effCdTotal = (macroCooldownTotal > 0.0f) ? macroCooldownTotal : slot.cooldownTotal; + float effCdRemaining = (macroCooldownRemaining > 0.0f) ? macroCooldownRemaining : slot.cooldownRemaining; + float total = (effCdTotal > 0.0f) ? effCdTotal : 1.0f; + float elapsed = total - effCdRemaining; float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); if (elapsedFrac > 0.005f) { constexpr int N_SEGS = 32; @@ -4074,9 +9532,11 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } char cdText[16]; - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm", (int)cd / 60); - else snprintf(cdText, sizeof(cdText), "%.0f", cd); + float cd = effCdRemaining; + if (cd >= 3600.0f) snprintf(cdText, sizeof(cdText), "%dh", (int)cd / 3600); + else if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm%ds", (int)cd / 60, (int)cd % 60); + else if (cd >= 5.0f) snprintf(cdText, sizeof(cdText), "%ds", (int)cd); + else snprintf(cdText, sizeof(cdText), "%.1f", cd); ImVec2 textSize = ImGui::CalcTextSize(cdText); float tx = cx - textSize.x * 0.5f; float ty = cy - textSize.y * 0.5f; @@ -4084,6 +9544,113 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); } + // GCD overlay — subtle dark fan sweep (thinner/lighter than regular cooldown) + if (onGCD) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + float gcdRem = gameHandler.getGCDRemaining(); + float gcdTotal = gameHandler.getGCDTotal(); + if (gcdTotal > 0.0f) { + float elapsed = gcdTotal - gcdRem; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / gcdTotal)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 24; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.4f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); + } + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 110)); + } + } + } + + // Auto-attack active glow — pulsing golden border when slot 6603 (Attack) is toggled on + if (slot.type == game::ActionBarSlot::SPELL && slot.id == 6603 + && gameHandler.isAutoAttacking()) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + float pulse = 0.55f + 0.45f * std::sin(static_cast(ImGui::GetTime()) * 5.0f); + ImU32 glowCol = IM_COL32( + static_cast(255), + static_cast(200 * pulse), + static_cast(0), + static_cast(200 * pulse)); + ImGui::GetWindowDrawList()->AddRect(bMin, bMax, glowCol, 2.0f, 0, 2.5f); + } + + // Item stack count overlay — bottom-right corner of icon + if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + // Count total of this item across all inventory slots + auto& inv = gameHandler.getInventory(); + int totalCount = 0; + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) totalCount += bs.item.stackCount; + } + } + if (totalCount > 0) { + char countStr[16]; + snprintf(countStr, sizeof(countStr), "%d", totalCount); + ImVec2 btnMax = ImGui::GetItemRectMax(); + ImVec2 tsz = ImGui::CalcTextSize(countStr); + float cx2 = btnMax.x - tsz.x - 2.0f; + float cy2 = btnMax.y - tsz.y - 1.0f; + auto* cdl = ImGui::GetWindowDrawList(); + cdl->AddText(ImVec2(cx2 + 1.0f, cy2 + 1.0f), IM_COL32(0, 0, 0, 200), countStr); + cdl->AddText(ImVec2(cx2, cy2), + totalCount <= 1 ? IM_COL32(220, 100, 100, 255) : IM_COL32(255, 255, 255, 255), + countStr); + } + } + + // Ready glow: animate a gold border for ~1.5s when a cooldown just expires + { + static std::unordered_map slotGlowTimers; // absSlot -> remaining glow seconds + static std::unordered_map slotWasOnCooldown; // absSlot -> last frame state + + float dt = ImGui::GetIO().DeltaTime; + bool wasOnCd = slotWasOnCooldown.count(absSlot) ? slotWasOnCooldown[absSlot] : false; + + // Trigger glow when transitioning from on-cooldown to ready (and slot isn't empty) + if (wasOnCd && !onCooldown && !slot.isEmpty()) { + slotGlowTimers[absSlot] = 1.5f; + } + slotWasOnCooldown[absSlot] = onCooldown; + + auto git = slotGlowTimers.find(absSlot); + if (git != slotGlowTimers.end() && git->second > 0.0f) { + git->second -= dt; + float t = git->second / 1.5f; // 1.0 → 0.0 over lifetime + // Pulse: bright when fresh, fading out + float pulse = std::sin(t * IM_PI * 4.0f) * 0.5f + 0.5f; // 4 pulses + uint8_t alpha = static_cast(200 * t * (0.5f + 0.5f * pulse)); + if (alpha > 0) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* gdl = ImGui::GetWindowDrawList(); + // Gold glow border (2px inset, 3px thick) + gdl->AddRect(ImVec2(bMin.x - 2, bMin.y - 2), + ImVec2(bMax.x + 2, bMax.y + 2), + IM_COL32(255, 215, 0, alpha), 3.0f, 0, 3.0f); + } + if (git->second <= 0.0f) slotGlowTimers.erase(git); + } + } + // Key label below ImGui::TextDisabled("%s", keyLabel); @@ -4092,13 +9659,14 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { }; // Bar 2 (slots 12-23) — only show if at least one slot is populated - { + if (pendingShowActionBar2) { bool bar2HasContent = false; for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } - float bar2Y = barY - barH - 2.0f; - ImGui::SetNextWindowPos(ImVec2(barX, bar2Y), ImGuiCond_Always); + float bar2X = barX + pendingActionBar2OffsetX; + float bar2Y = barY - barH - 2.0f + pendingActionBar2OffsetY; + ImGui::SetNextWindowPos(ImVec2(bar2X, bar2Y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); @@ -4123,12 +9691,125 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { if (i > 0) ImGui::SameLine(0, spacing); renderBarSlot(i, keyLabels1[i]); } + + // Macro editor modal — opened by "Edit" in action bar context menus + if (macroEditorOpen_) { + ImGui::OpenPopup("Edit Macro###MacroEdit"); + macroEditorOpen_ = false; + } + if (ImGui::BeginPopupModal("Edit Macro###MacroEdit", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Macro #%u (all lines execute; [cond] Spell; Default supported)", macroEditorId_); + ImGui::SetNextItemWidth(320.0f); + ImGui::InputTextMultiline("##MacroText", macroEditorBuf_, sizeof(macroEditorBuf_), + ImVec2(320.0f, 80.0f)); + if (ImGui::Button("Save")) { + gameHandler.setMacroText(macroEditorId_, std::string(macroEditorBuf_)); + macroPrimarySpellCache_.clear(); // invalidate resolved spell IDs + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } ImGui::End(); ImGui::PopStyleColor(); ImGui::PopStyleVar(4); + // Right side vertical bar (bar 3, slots 24-35) + if (pendingShowRightBar) { + bool bar3HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 2 + i].isEmpty()) { bar3HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = screenW - sideBarW - 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + pendingRightBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar3HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarRight", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 2 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Left side vertical bar (bar 4, slots 36-47) + if (pendingShowLeftBar) { + bool bar4HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR * 3 + i].isEmpty()) { bar4HasContent = true; break; } + + float sideBarW = slotSize + padding * 2; + float sideBarH = game::GameHandler::SLOTS_PER_BAR * slotSize + (game::GameHandler::SLOTS_PER_BAR - 1) * spacing + padding * 2; + float sideBarX = 4.0f; + float sideBarY = (screenH - sideBarH) / 2.0f + pendingLeftBarOffsetY; + + ImGui::SetNextWindowPos(ImVec2(sideBarX, sideBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(sideBarW, sideBarH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar4HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBarLeft", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + renderBarSlot(game::GameHandler::SLOTS_PER_BAR * 3 + i, ""); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } + + // Vehicle exit button (WotLK): floating button above action bar when player is in a vehicle + if (gameHandler.isInVehicle()) { + const float btnW = 120.0f; + const float btnH = 32.0f; + const float btnX = (screenW - btnW) / 2.0f; + const float btnY = barY - btnH - 6.0f; + + ImGui::SetNextWindowPos(ImVec2(btnX, btnY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW, btnH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.0f)); + ImGuiWindowFlags vFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##VehicleExit", nullptr, vFlags)) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.1f, 0.1f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.4f, 0.0f, 0.0f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f); + if (ImGui::Button("Leave Vehicle", ImVec2(btnW - 8.0f, btnH - 8.0f))) { + gameHandler.sendRequestVehicleExit(); + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); + } + // Handle action bar drag: render icon at cursor and detect drop outside if (actionBarDragSlot_ >= 0) { ImVec2 mousePos = ImGui::GetMousePos(); @@ -4160,14 +9841,150 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { } } +// ============================================================ +// Stance / Form / Presence Bar +// Shown for Warriors (stances), Death Knights (presences), +// Druids (shapeshift forms), Rogues (stealth), Priests (Shadowform). +// Buttons display the player's known stance/form spells. +// Active form is detected by checking permanent player auras. +// ============================================================ + +void GameScreen::renderStanceBar(game::GameHandler& gameHandler) { + uint8_t playerClass = gameHandler.getPlayerClass(); + + // Stance/form spell IDs per class (ordered by display priority) + // Class IDs: 1=Warrior, 4=Rogue, 5=Priest, 6=DeathKnight, 11=Druid + static const uint32_t warriorStances[] = { 2457, 71, 2458 }; // Battle, Defensive, Berserker + static const uint32_t dkPresences[] = { 48266, 48263, 48265 }; // Blood, Frost, Unholy + static const uint32_t druidForms[] = { 5487, 9634, 768, 783, 1066, 24858, 33891, 33943, 40120 }; + // Bear, DireBear, Cat, Travel, Aquatic, Moonkin, Tree, Flight, SwiftFlight + static const uint32_t rogueForms[] = { 1784 }; // Stealth + static const uint32_t priestForms[] = { 15473 }; // Shadowform + + const uint32_t* stanceArr = nullptr; + int stanceCount = 0; + switch (playerClass) { + case 1: stanceArr = warriorStances; stanceCount = 3; break; + case 6: stanceArr = dkPresences; stanceCount = 3; break; + case 11: stanceArr = druidForms; stanceCount = 9; break; + case 4: stanceArr = rogueForms; stanceCount = 1; break; + case 5: stanceArr = priestForms; stanceCount = 1; break; + default: return; + } + + // Filter to spells the player actually knows + const auto& known = gameHandler.getKnownSpells(); + std::vector available; + available.reserve(stanceCount); + for (int i = 0; i < stanceCount; ++i) + if (known.count(stanceArr[i])) available.push_back(stanceArr[i]); + + if (available.empty()) return; + + // Detect active stance from permanent player auras (maxDurationMs == -1) + uint32_t activeStance = 0; + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.isEmpty() || aura.maxDurationMs != -1) continue; + for (uint32_t sid : available) { + if (aura.spellId == sid) { activeStance = sid; break; } + } + if (activeStance) break; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + // Match the action bar slot size so they align neatly + float slotSize = 38.0f; + float spacing = 4.0f; + float padding = 6.0f; + int count = static_cast(available.size()); + + float barW = count * slotSize + (count - 1) * spacing + padding * 2.0f; + float barH = slotSize + padding * 2.0f; + + // Position the stance bar immediately to the left of the action bar + float actionSlot = 48.0f * pendingActionBarScale; + float actionBarW = 12.0f * actionSlot + 11.0f * 4.0f + 8.0f * 2.0f; + float actionBarX = (screenW - actionBarW) / 2.0f; + float actionBarH = actionSlot + 24.0f; + float actionBarY = screenH - actionBarH; + + float barX = actionBarX - barW - 8.0f; + float barY = actionBarY + (actionBarH - barH) / 2.0f; + + ImGui::SetNextWindowPos(ImVec2(barX, barY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + + if (ImGui::Begin("##StanceBar", nullptr, flags)) { + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < count; ++i) { + if (i > 0) ImGui::SameLine(0.0f, spacing); + ImGui::PushID(i); + + uint32_t spellId = available[i]; + bool isActive = (spellId == activeStance); + + VkDescriptorSet iconTex = assetMgr ? getSpellIcon(spellId, assetMgr) : VK_NULL_HANDLE; + + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImVec2 posEnd = ImVec2(pos.x + slotSize, pos.y + slotSize); + + // Background — green tint when active + ImU32 bgCol = isActive ? IM_COL32(30, 70, 30, 230) : IM_COL32(20, 20, 20, 220); + ImU32 borderCol = isActive ? IM_COL32(80, 220, 80, 255) : IM_COL32(80, 80, 80, 200); + dl->AddRectFilled(pos, posEnd, bgCol, 4.0f); + + if (iconTex) { + dl->AddImage((ImTextureID)(uintptr_t)iconTex, pos, posEnd); + // Darken inactive buttons slightly + if (!isActive) + dl->AddRectFilled(pos, posEnd, IM_COL32(0, 0, 0, 70), 4.0f); + } + dl->AddRect(pos, posEnd, borderCol, 4.0f, 0, 2.0f); + + ImGui::InvisibleButton("##btn", ImVec2(slotSize, slotSize)); + + if (ImGui::IsItemClicked(ImGuiMouseButton_Left)) + gameHandler.castSpell(spellId); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) ImGui::TextUnformatted(name.c_str()); + else ImGui::Text("Spell #%u", spellId); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); +} + // ============================================================ // Bag Bar // ============================================================ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* assetMgr = core::Application::getInstance().getAssetManager(); float slotSize = 42.0f; @@ -4274,6 +10091,27 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { dl->AddRect(bagSlotMins[i], bagSlotMaxs[i], IM_COL32(255, 255, 255, 255), 3.0f, 0, 2.0f); } + // Right-click context menu + if (ImGui::BeginPopupContextItem("##bagSlotCtx")) { + if (!bagItem.empty()) { + ImGui::TextDisabled("%s", bagItem.item.name.c_str()); + ImGui::Separator(); + bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBagOpen(i); + if (ImGui::MenuItem(isOpen ? "Close Bag" : "Open Bag")) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBag(i); + else + inventoryScreen.toggle(); + } + if (ImGui::MenuItem("Unequip Bag")) { + gameHandler.unequipToBackpack(bagSlot); + } + } else { + ImGui::TextDisabled("Empty Bag Slot"); + } + ImGui::EndPopup(); + } + // Accept dragged item from inventory if (hovered && inventoryScreen.isHoldingItem()) { const auto& heldItem = inventoryScreen.getHeldItem(); @@ -4364,6 +10202,24 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) { ImGui::SetTooltip("Backpack"); } + // Right-click context menu on backpack + if (ImGui::BeginPopupContextItem("##backpackCtx")) { + bool isOpen = inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen(); + if (ImGui::MenuItem(isOpen ? "Close Backpack" : "Open Backpack")) { + if (inventoryScreen.isSeparateBags()) + inventoryScreen.toggleBackpack(); + else + inventoryScreen.toggle(); + } + ImGui::Separator(); + if (ImGui::MenuItem("Open All Bags")) { + inventoryScreen.openAllBags(); + } + if (ImGui::MenuItem("Close All Bags")) { + inventoryScreen.closeAllBags(); + } + ImGui::EndPopup(); + } if (inventoryScreen.isSeparateBags() && inventoryScreen.isBackpackOpen()) { ImDrawList* dl = ImGui::GetWindowDrawList(); @@ -4406,26 +10262,43 @@ void GameScreen::renderBagBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { - uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); - if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) + uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); + uint32_t playerLevel = gameHandler.getPlayerLevel(); + // At max level, server sends nextLevelXp=0. Only skip entirely when we have + // no level info at all (not yet logged in / no update-field data). + const bool isMaxLevel = (nextLevelXp == 0 && playerLevel > 0); + if (nextLevelXp == 0 && !isMaxLevel) return; - uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t restedXp = gameHandler.getPlayerRestedXp(); + bool isResting = gameHandler.isPlayerResting(); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + (void)window; // Not used for positioning; kept for AssetManager if needed - // Position just above the action bar - float slotSize = 48.0f; + // Position just above both action bars (bar1 at screenH-barH, bar2 above that) + float slotSize = 48.0f * pendingActionBarScale; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; float barH = slotSize + 24.0f; - float actionBarY = screenH - barH; float xpBarH = 20.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; - float xpBarY = actionBarY - xpBarH - 2.0f; + // XP bar sits just above whichever bar is topmost. + // bar1 top edge: screenH - barH + // bar2 top edge (when visible): bar1 top - barH - 2 + bar2 vertical offset + float bar1TopY = screenH - barH; + float xpBarY; + if (pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH - 2.0f + pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); @@ -4440,18 +10313,36 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); if (ImGui::Begin("##XpBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* drawList = ImGui::GetWindowDrawList(); + + if (isMaxLevel) { + // Max-level bar: fully filled in muted gold with "Max Level" label + ImU32 bgML = IM_COL32(15, 12, 5, 220); + ImU32 fgML = IM_COL32(180, 140, 40, 200); + drawList->AddRectFilled(barMin, barMax, bgML, 2.0f); + drawList->AddRectFilled(barMin, barMax, fgML, 2.0f); + drawList->AddRect(barMin, barMax, IM_COL32(100, 80, 20, 220), 2.0f); + const char* mlLabel = "Max Level"; + ImVec2 mlSz = ImGui::CalcTextSize(mlLabel); + drawList->AddText( + ImVec2(barMin.x + (barSize.x - mlSz.x) * 0.5f, + barMin.y + (barSize.y - mlSz.y) * 0.5f), + IM_COL32(255, 230, 120, 255), mlLabel); + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Level %u — Maximum level reached", playerLevel); + } else { float pct = static_cast(currentXp) / static_cast(nextLevelXp); if (pct > 1.0f) pct = 1.0f; // Custom segmented XP bar (20 bubbles) - ImVec2 barMin = ImGui::GetCursorScreenPos(); - ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, xpBarH - 4.0f); - ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); - auto* drawList = ImGui::GetWindowDrawList(); - - ImU32 bg = IM_COL32(15, 15, 20, 220); - ImU32 fg = IM_COL32(148, 51, 238, 255); - ImU32 seg = IM_COL32(35, 35, 45, 255); + ImU32 bg = IM_COL32(15, 15, 20, 220); + ImU32 fg = IM_COL32(148, 51, 238, 255); + ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion + ImU32 seg = IM_COL32(35, 35, 45, 255); drawList->AddRectFilled(barMin, barMax, bg, 2.0f); drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); @@ -4460,6 +10351,19 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f); } + // Rested XP overlay: draw from current XP fill to (currentXp + restedXp) fill + if (restedXp > 0) { + float restedEndPct = std::min(1.0f, static_cast(currentXp + restedXp) + / static_cast(nextLevelXp)); + float restedStartX = barMin.x + fillW; + float restedEndX = barMin.x + barSize.x * restedEndPct; + if (restedEndX > restedStartX) { + drawList->AddRectFilled(ImVec2(restedStartX, barMin.y), + ImVec2(restedEndX, barMax.y), + fgRest, 2.0f); + } + } + const int segments = 20; float segW = barSize.x / static_cast(segments); for (int i = 1; i < segments; ++i) { @@ -4467,14 +10371,49 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f); } + // Rest indicator "zzz" to the right of the bar when resting + if (isResting) { + const char* zzz = "zzz"; + ImVec2 zSize = ImGui::CalcTextSize(zzz); + float zx = barMax.x - zSize.x - 4.0f; + float zy = barMin.y + (barSize.y - zSize.y) * 0.5f; + drawList->AddText(ImVec2(zx, zy), IM_COL32(180, 150, 255, 220), zzz); + } + char overlay[96]; - snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + if (restedXp > 0) { + snprintf(overlay, sizeof(overlay), "%u / %u XP (+%u rested)", currentXp, nextLevelXp, restedXp); + } else { + snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + } ImVec2 textSize = ImGui::CalcTextSize(overlay); float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; drawList->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), overlay); ImGui::Dummy(barSize); + + // Tooltip with XP-to-level and rested details + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + uint32_t xpToLevel = (currentXp < nextLevelXp) ? (nextLevelXp - currentXp) : 0; + ImGui::TextColored(ImVec4(0.9f, 0.85f, 1.0f, 1.0f), "Experience"); + ImGui::Separator(); + float xpPct = nextLevelXp > 0 ? (100.0f * currentXp / nextLevelXp) : 0.0f; + ImGui::Text("Current: %u / %u XP (%.1f%%)", currentXp, nextLevelXp, xpPct); + ImGui::Text("To next level: %u XP", xpToLevel); + if (restedXp > 0) { + float restedLevels = static_cast(restedXp) / static_cast(nextLevelXp); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.78f, 0.60f, 1.0f, 1.0f), + "Rested: +%u XP (%.1f%% of a level)", restedXp, restedLevels * 100.0f); + if (isResting) + ImGui::TextColored(ImVec4(0.6f, 0.9f, 0.6f, 1.0f), + "Resting — accumulating bonus XP"); + } + ImGui::EndTooltip(); + } + } } ImGui::End(); @@ -4482,6 +10421,126 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ============================================================ +// Reputation Bar +// ============================================================ + +void GameScreen::renderRepBar(game::GameHandler& gameHandler) { + uint32_t factionId = gameHandler.getWatchedFactionId(); + if (factionId == 0) return; + + const auto& standings = gameHandler.getFactionStandings(); + auto it = standings.find(factionId); + if (it == standings.end()) return; + + int32_t standing = it->second; + + // WoW reputation rank thresholds + struct RepRank { const char* name; int32_t min; int32_t max; ImU32 color; }; + static const RepRank kRanks[] = { + { "Hated", -42000, -6001, IM_COL32(180, 40, 40, 255) }, + { "Hostile", -6000, -3001, IM_COL32(180, 40, 40, 255) }, + { "Unfriendly", -3000, -1, IM_COL32(220, 100, 50, 255) }, + { "Neutral", 0, 2999, IM_COL32(200, 200, 60, 255) }, + { "Friendly", 3000, 8999, IM_COL32( 60, 180, 60, 255) }, + { "Honored", 9000, 20999, IM_COL32( 60, 160, 220, 255) }, + { "Revered", 21000, 41999, IM_COL32(140, 80, 220, 255) }, + { "Exalted", 42000, 42999, IM_COL32(255, 200, 50, 255) }, + }; + constexpr int kNumRanks = static_cast(sizeof(kRanks) / sizeof(kRanks[0])); + + int rankIdx = kNumRanks - 1; // default to Exalted + for (int i = 0; i < kNumRanks; ++i) { + if (standing <= kRanks[i].max) { rankIdx = i; break; } + } + const RepRank& rank = kRanks[rankIdx]; + + float fraction = 1.0f; + if (rankIdx < kNumRanks - 1) { + float range = static_cast(rank.max - rank.min + 1); + fraction = static_cast(standing - rank.min) / range; + fraction = std::max(0.0f, std::min(1.0f, fraction)); + } + + const std::string& factionName = gameHandler.getFactionNamePublic(factionId); + + // Position directly above the XP bar + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + float slotSize = 48.0f * pendingActionBarScale; + float spacing = 4.0f; + float padding = 8.0f; + float barW = 12 * slotSize + 11 * spacing + padding * 2; + float barH_ab = slotSize + 24.0f; + float xpBarH = 20.0f; + float repBarH = 12.0f; + float xpBarW = barW; + float xpBarX = (screenW - xpBarW) / 2.0f; + + float bar1TopY = screenH - barH_ab; + float xpBarY; + if (pendingShowActionBar2) { + float bar2TopY = bar1TopY - barH_ab - 2.0f + pendingActionBar2OffsetY; + xpBarY = bar2TopY - xpBarH - 2.0f; + } else { + xpBarY = bar1TopY - xpBarH - 2.0f; + } + float repBarY = xpBarY - repBarH - 2.0f; + + ImGui::SetNextWindowPos(ImVec2(xpBarX, repBarY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(xpBarW, repBarH + 4.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 2.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(2.0f, 2.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.8f)); + + if (ImGui::Begin("##RepBar", nullptr, flags)) { + ImVec2 barMin = ImGui::GetCursorScreenPos(); + ImVec2 barSize = ImVec2(ImGui::GetContentRegionAvail().x, repBarH - 4.0f); + ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); + auto* dl = ImGui::GetWindowDrawList(); + + dl->AddRectFilled(barMin, barMax, IM_COL32(15, 15, 20, 220), 2.0f); + dl->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); + + float fillW = barSize.x * fraction; + if (fillW > 0.0f) + dl->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), rank.color, 2.0f); + + // Label: "FactionName - Rank" + char label[96]; + snprintf(label, sizeof(label), "%s - %s", factionName.c_str(), rank.name); + ImVec2 textSize = ImGui::CalcTextSize(label); + float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; + float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; + dl->AddText(ImVec2(tx, ty), IM_COL32(230, 230, 230, 255), label); + + // Tooltip with exact values on hover + ImGui::Dummy(barSize); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + float cr = ((rank.color ) & 0xFF) / 255.0f; + float cg = ((rank.color >> 8) & 0xFF) / 255.0f; + float cb = ((rank.color >> 16) & 0xFF) / 255.0f; + ImGui::TextColored(ImVec4(cr, cg, cb, 1.0f), "%s", rank.name); + int32_t rankMin = rank.min; + int32_t rankMax = (rankIdx < kNumRanks - 1) ? rank.max : 42000; + ImGui::Text("%s: %d / %d", factionName.c_str(), standing - rankMin, rankMax - rankMin + 1); + ImGui::EndTooltip(); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(2); +} + // ============================================================ // Cast Bar (Phase 3) // ============================================================ @@ -4489,9 +10548,15 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { void GameScreen::renderCastBar(game::GameHandler& gameHandler) { if (!gameHandler.isCasting()) return; - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); + VkDescriptorSet iconTex = (currentSpellId != 0 && assetMgr) + ? getSpellIcon(currentSpellId, assetMgr) : VK_NULL_HANDLE; float barW = 300.0f; float barX = (screenW - barW) / 2.0f; @@ -4508,21 +10573,72 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.9f)); if (ImGui::Begin("##CastBar", nullptr, flags)) { - float progress = gameHandler.getCastProgress(); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.6f, 0.2f, 1.0f)); + const bool channeling = gameHandler.isChanneling(); + // Channels drain right-to-left; regular casts fill left-to-right + float progress = channeling + ? (1.0f - gameHandler.getCastProgress()) + : gameHandler.getCastProgress(); - char overlay[64]; - uint32_t currentSpellId = gameHandler.getCurrentCastSpellId(); - if (gameHandler.getCurrentCastSpellId() == 0) { + // Color by spell school for cast identification; channels always blue + ImVec4 barColor; + if (channeling) { + barColor = ImVec4(0.3f, 0.6f, 0.9f, 1.0f); // blue for channels + } else { + uint32_t school = (currentSpellId != 0) ? gameHandler.getSpellSchoolMask(currentSpellId) : 0; + if (school & 0x04) barColor = ImVec4(0.95f, 0.40f, 0.10f, 1.0f); // Fire: orange-red + else if (school & 0x10) barColor = ImVec4(0.30f, 0.65f, 0.95f, 1.0f); // Frost: icy blue + else if (school & 0x20) barColor = ImVec4(0.55f, 0.15f, 0.70f, 1.0f); // Shadow: purple + else if (school & 0x40) barColor = ImVec4(0.65f, 0.30f, 0.85f, 1.0f); // Arcane: violet + else if (school & 0x08) barColor = ImVec4(0.20f, 0.75f, 0.25f, 1.0f); // Nature: green + else if (school & 0x02) barColor = ImVec4(0.90f, 0.80f, 0.30f, 1.0f); // Holy: golden + else barColor = ImVec4(0.80f, 0.60f, 0.20f, 1.0f); // Physical/default: gold + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + + char overlay[96]; + if (currentSpellId == 0) { snprintf(overlay, sizeof(overlay), "Opening... (%.1fs)", gameHandler.getCastTimeRemaining()); } else { const std::string& spellName = gameHandler.getSpellName(currentSpellId); - if (!spellName.empty()) - snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); - else - snprintf(overlay, sizeof(overlay), "Casting... (%.1fs)", gameHandler.getCastTimeRemaining()); + const char* verb = channeling ? "Channeling" : "Casting"; + int queueLeft = gameHandler.getCraftQueueRemaining(); + if (!spellName.empty()) { + if (queueLeft > 0) + snprintf(overlay, sizeof(overlay), "%s (%.1fs) [%d left]", spellName.c_str(), gameHandler.getCastTimeRemaining(), queueLeft); + else + snprintf(overlay, sizeof(overlay), "%s (%.1fs)", spellName.c_str(), gameHandler.getCastTimeRemaining()); + } else { + snprintf(overlay, sizeof(overlay), "%s... (%.1fs)", verb, gameHandler.getCastTimeRemaining()); + } + } + + // Queued spell icon (right edge): the next spell queued to fire within 400ms. + uint32_t queuedId = gameHandler.getQueuedSpellId(); + VkDescriptorSet queuedTex = (queuedId != 0 && assetMgr) + ? getSpellIcon(queuedId, assetMgr) : VK_NULL_HANDLE; + + const float iconSz = 20.0f; + const float reservedRight = (queuedTex) ? (iconSz + 4.0f) : 0.0f; + + if (iconTex) { + // Spell icon to the left of the progress bar + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(iconSz, iconSz)); + ImGui::SameLine(0, 4); + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); + } else { + ImGui::ProgressBar(progress, ImVec2(-reservedRight - 1.0f, iconSz), overlay); + } + // Draw queued-spell icon on the right with a ">" arrow prefix tooltip. + if (queuedTex) { + ImGui::SameLine(0, 4); + ImGui::Image((ImTextureID)(uintptr_t)queuedTex, ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,0.8f), ImVec4(0,0,0,0)); // slightly dimmed + if (ImGui::IsItemHovered()) { + const std::string& qn = gameHandler.getSpellName(queuedId); + ImGui::SetTooltip("Queued: %s", qn.empty() ? "Unknown" : qn.c_str()); + } } - ImGui::ProgressBar(progress, ImVec2(-1, 20), overlay); ImGui::PopStyleColor(); } ImGui::End(); @@ -4536,9 +10652,9 @@ void GameScreen::renderCastBar(game::GameHandler& gameHandler) { // ============================================================ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { - auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; static const struct { const char* label; ImVec4 color; } kTimerInfo[3] = { { "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) }, @@ -4583,6 +10699,98 @@ void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { } } +// ============================================================ +// Cooldown Tracker — floating panel showing all active spell CDs +// ============================================================ + +void GameScreen::renderCooldownTracker(game::GameHandler& gameHandler) { + if (!showCooldownTracker_) return; + + const auto& cooldowns = gameHandler.getSpellCooldowns(); + if (cooldowns.empty()) return; + + // Collect spells with remaining cooldown > 0.5s (skip GCD noise) + struct CDEntry { uint32_t spellId; float remaining; }; + std::vector active; + active.reserve(16); + for (const auto& [sid, rem] : cooldowns) { + if (rem > 0.5f) active.push_back({sid, rem}); + } + if (active.empty()) return; + + // Sort: longest remaining first + std::sort(active.begin(), active.end(), [](const CDEntry& a, const CDEntry& b) { + return a.remaining > b.remaining; + }); + + auto* assetMgr = core::Application::getInstance().getAssetManager(); + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float TRACKER_W = 200.0f; + constexpr int MAX_SHOWN = 12; + float posX = screenW - TRACKER_W - 10.0f; + float posY = screenH - 220.0f; // above the action bar area + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0.0f), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4.0f, 4.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##CooldownTracker", nullptr, flags)) { + ImGui::TextDisabled("Cooldowns"); + ImGui::Separator(); + + int shown = 0; + for (const auto& cd : active) { + if (shown >= MAX_SHOWN) break; + + const std::string& name = gameHandler.getSpellName(cd.spellId); + if (name.empty()) continue; // skip unnamed spells (internal/passive) + + // Small icon if available + VkDescriptorSet icon = assetMgr ? getSpellIcon(cd.spellId, assetMgr) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(14, 14)); + ImGui::SameLine(0, 3); + } + + // Name (truncated) + remaining time + char timeStr[16]; + if (cd.remaining >= 60.0f) + snprintf(timeStr, sizeof(timeStr), "%dm%ds", (int)cd.remaining / 60, (int)cd.remaining % 60); + else + snprintf(timeStr, sizeof(timeStr), "%.0fs", cd.remaining); + + // Color: red > 30s, orange > 10s, yellow > 5s, green otherwise + ImVec4 cdColor = cd.remaining > 30.0f ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) : + cd.remaining > 10.0f ? ImVec4(1.0f, 0.6f, 0.2f, 1.0f) : + cd.remaining > 5.0f ? ImVec4(1.0f, 1.0f, 0.3f, 1.0f) : + ImVec4(0.5f, 1.0f, 0.5f, 1.0f); + + // Truncate name to fit + std::string displayName = name; + if (displayName.size() > 16) displayName = displayName.substr(0, 15) + "\xe2\x80\xa6"; // ellipsis + + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", displayName.c_str()); + ImGui::SameLine(TRACKER_W - 48.0f); + ImGui::TextColored(cdColor, "%s", timeStr); + + ++shown; + } + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + // ============================================================ // Quest Objective Tracker (right-side HUD) // ============================================================ @@ -4619,15 +10827,27 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } if (toShow.empty()) return; - float x = screenW - TRACKER_W - RIGHT_MARGIN; - float y = 200.0f; // below minimap area + float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; - ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); + // Default position: top-right, below minimap + buff bar space. + // questTrackerRightOffset_ stores pixels from the right edge so the tracker + // stays anchored to the right side when the window is resized. + if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) { + questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned + questTrackerPos_.y = 320.0f; + questTrackerPosInit_ = true; + } + // Recompute X from right offset every frame (handles window resize) + questTrackerPos_.x = screenW - questTrackerRightOffset_; - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; + ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); + ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoBringToFrontOnFocus; ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); @@ -4643,36 +10863,109 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); ImGui::PushStyleColor(ImGuiCol_Text, titleCol); if (ImGui::Selectable(q.title.c_str(), false, - ImGuiSelectableFlags_None, ImVec2(TRACKER_W - 12.0f, 0))) { + ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) { questLogScreen.openAndSelectQuest(q.questId); } - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Click to open Quest Log"); + if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { + ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options"); } ImGui::PopStyleColor(); + + // Right-click context menu for quest tracker entry + if (ImGui::BeginPopupContextItem("##QTCtx")) { + ImGui::TextDisabled("%s", q.title.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open in Quest Log")) { + questLogScreen.openAndSelectQuest(q.questId); + } + bool tracked = gameHandler.isQuestTracked(q.questId); + if (tracked) { + if (ImGui::MenuItem("Stop Tracking")) { + gameHandler.setQuestTracked(q.questId, false); + } + } else { + if (ImGui::MenuItem("Track")) { + gameHandler.setQuestTracked(q.questId, true); + } + } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + } + } + ImGui::EndPopup(); + } ImGui::PopID(); // Objectives line (condensed) if (q.complete) { ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), " (Complete)"); } else { - // Kill counts + // Kill counts — green when complete, gray when in progress for (const auto& [entry, progress] : q.killCounts) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %u/%u", progress.first, progress.second); + bool objDone = (progress.first >= progress.second && progress.second > 0); + ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } + if (!name.empty()) { + ImGui::TextColored(objColor, + " %s: %u/%u", name.c_str(), + progress.first, progress.second); + } else { + ImGui::TextColored(objColor, + " %u/%u", progress.first, progress.second); + } } - // Item counts + // Item counts — green when complete, gray when in progress for (const auto& [itemId, count] : q.itemCounts) { uint32_t required = 1; auto reqIt = q.requiredItemCounts.find(itemId); if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; + bool objDone = (count >= required); + ImVec4 objColor = objDone ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); const auto* info = gameHandler.getItemInfo(itemId); const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; - if (itemName) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + + // Show small icon if available + uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + ImGui::SameLine(0, 3); + ImGui::TextColored(objColor, + "%s: %u/%u", itemName ? itemName : "Item", count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + } else if (itemName) { + ImGui::TextColored(objColor, " %s: %u/%u", itemName, count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } } else { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + ImGui::TextColored(objColor, " Item: %u/%u", count, required); } } @@ -4692,6 +10985,29 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::Spacing(); } } + + // Capture position and size after drag/resize + ImVec2 newPos = ImGui::GetWindowPos(); + ImVec2 newSize = ImGui::GetWindowSize(); + bool changed = false; + + // Clamp within screen + newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || + std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { + questTrackerPos_ = newPos; + // Update right offset so resizes keep the new position anchored + questTrackerRightOffset_ = screenW - newPos.x; + changed = true; + } + if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f || + std::abs(newSize.y - questTrackerSize_.y) > 0.5f) { + questTrackerSize_ = newSize; + changed = true; + } + if (changed) saveSettings(); } ImGui::End(); @@ -4699,6 +11015,102 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } +// ============================================================ +// Raid Warning / Boss Emote Center-Screen Overlay +// ============================================================ + +void GameScreen::renderRaidWarningOverlay(game::GameHandler& gameHandler) { + // Scan chat history for new RAID_WARNING / RAID_BOSS_EMOTE messages + const auto& chatHistory = gameHandler.getChatHistory(); + size_t newCount = chatHistory.size(); + if (newCount > raidWarnChatSeenCount_) { + // Walk only the new messages (deque — iterate from back by skipping old ones) + size_t toScan = newCount - raidWarnChatSeenCount_; + size_t startIdx = newCount > toScan ? newCount - toScan : 0; + auto* renderer = core::Application::getInstance().getRenderer(); + for (size_t i = startIdx; i < newCount; ++i) { + const auto& msg = chatHistory[i]; + if (msg.type == game::ChatType::RAID_WARNING || + msg.type == game::ChatType::RAID_BOSS_EMOTE || + msg.type == game::ChatType::MONSTER_EMOTE) { + bool isBoss = (msg.type != game::ChatType::RAID_WARNING); + // Limit display text length to avoid giant overlay + std::string text = msg.message; + if (text.size() > 200) text = text.substr(0, 200) + "..."; + raidWarnEntries_.push_back({text, 0.0f, isBoss}); + if (raidWarnEntries_.size() > 3) + raidWarnEntries_.erase(raidWarnEntries_.begin()); + } + // Whisper audio notification + if (msg.type == game::ChatType::WHISPER && renderer) { + if (auto* ui = renderer->getUiSoundManager()) + ui->playWhisperReceived(); + } + } + raidWarnChatSeenCount_ = newCount; + } + + // Age and remove expired entries + float dt = ImGui::GetIO().DeltaTime; + for (auto& e : raidWarnEntries_) e.age += dt; + raidWarnEntries_.erase( + std::remove_if(raidWarnEntries_.begin(), raidWarnEntries_.end(), + [](const RaidWarnEntry& e){ return e.age >= RaidWarnEntry::LIFETIME; }), + raidWarnEntries_.end()); + + if (raidWarnEntries_.empty()) return; + + ImGuiIO& io = ImGui::GetIO(); + float screenW = io.DisplaySize.x; + float screenH = io.DisplaySize.y; + ImDrawList* fg = ImGui::GetForegroundDrawList(); + + // Stack entries vertically near upper-center (below target frame area) + float baseY = screenH * 0.28f; + for (const auto& e : raidWarnEntries_) { + float alpha = std::clamp(1.0f - (e.age / RaidWarnEntry::LIFETIME), 0.0f, 1.0f); + // Fade in quickly, hold, then fade out last 20% + if (e.age < 0.3f) alpha = e.age / 0.3f; + + // Truncate to fit screen width reasonably + const char* txt = e.text.c_str(); + const float fontSize = 22.0f; + ImFont* font = ImGui::GetFont(); + + // Word-wrap manually: compute text size, center horizontally + float maxW = screenW * 0.7f; + ImVec2 textSz = font->CalcTextSizeA(fontSize, maxW, maxW, txt); + float tx = (screenW - textSz.x) * 0.5f; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 200)); + ImU32 mainCol; + if (e.isBossEmote) { + mainCol = IM_COL32(255, 185, 60, static_cast(alpha * 255)); // amber + } else { + // Raid warning: alternating red/yellow flash during first second + float flashT = std::fmod(e.age * 4.0f, 1.0f); + if (flashT < 0.5f) + mainCol = IM_COL32(255, 50, 50, static_cast(alpha * 255)); + else + mainCol = IM_COL32(255, 220, 50, static_cast(alpha * 255)); + } + + // Background dim box for readability + float pad = 8.0f; + fg->AddRectFilled(ImVec2(tx - pad, baseY - pad), + ImVec2(tx + textSz.x + pad, baseY + textSz.y + pad), + IM_COL32(0, 0, 0, static_cast(alpha * 120)), 4.0f); + + // Shadow + main text + fg->AddText(font, fontSize, ImVec2(tx + 2.0f, baseY + 2.0f), shadowCol, txt, + nullptr, maxW); + fg->AddText(font, fontSize, ImVec2(tx, baseY), mainCol, txt, + nullptr, maxW); + + baseY += textSz.y + 6.0f; + } +} + // ============================================================ // Floating Combat Text (Phase 2) // ============================================================ @@ -4708,114 +11120,489 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { if (entries.empty()) return; auto* window = core::Application::getInstance().getWindow(); - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); - // Render combat text entries overlaid on screen - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + // Camera for world-space projection + auto* appRenderer = core::Application::getInstance().getRenderer(); + rendering::Camera* camera = appRenderer ? appRenderer->getCamera() : nullptr; + glm::mat4 viewProj; + if (camera) viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImDrawList* drawList = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + const float baseFontSize = ImGui::GetFontSize(); - if (ImGui::Begin("##CombatText", nullptr, flags)) { - // Incoming events (enemy attacks player) float near screen center (over the player). - // Outgoing events (player attacks enemy) float on the right side (near the target). - const float incomingX = screenW * 0.40f; - const float outgoingX = screenW * 0.68f; + // HUD fallback: entries without world-space anchor use classic screen-position layout. + // We still need an ImGui window for those. + const float hudIncomingX = screenW * 0.40f; + const float hudOutgoingX = screenW * 0.68f; + int hudInIdx = 0, hudOutIdx = 0; + bool needsHudWindow = false; - int inIdx = 0, outIdx = 0; - for (const auto& entry : entries) { - float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); - float yOffset = 200.0f - entry.age * 60.0f; - const bool outgoing = entry.isPlayerSource; + for (const auto& entry : entries) { + const float alpha = 1.0f - (entry.age / game::CombatTextEntry::LIFETIME); + const bool outgoing = entry.isPlayerSource; - ImVec4 color; - char text[64]; - switch (entry.type) { - case game::CombatTextEntry::MELEE_DAMAGE: - case game::CombatTextEntry::SPELL_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 1.0f, 0.3f, alpha) : // Outgoing = yellow - ImVec4(1.0f, 0.3f, 0.3f, alpha); // Incoming = red - break; - case game::CombatTextEntry::CRIT_DAMAGE: - snprintf(text, sizeof(text), "-%d!", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.8f, 0.0f, alpha) : // Outgoing crit = bright yellow - ImVec4(1.0f, 0.5f, 0.0f, alpha); // Incoming crit = orange - break; - case game::CombatTextEntry::HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::CRIT_HEAL: - snprintf(text, sizeof(text), "+%d!", entry.amount); - color = ImVec4(0.3f, 1.0f, 0.3f, alpha); - break; - case game::CombatTextEntry::MISS: - snprintf(text, sizeof(text), "Miss"); - color = ImVec4(0.7f, 0.7f, 0.7f, alpha); - break; - case game::CombatTextEntry::DODGE: - // outgoing=true: enemy dodged player's attack - // outgoing=false: player dodged incoming attack - snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PARRY: - snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::BLOCK: + // --- Format text and color (identical logic for both world and HUD paths) --- + ImVec4 color; + char text[128]; + switch (entry.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 1.0f, 0.3f, alpha) : + ImVec4(1.0f, 0.3f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_DAMAGE: + snprintf(text, sizeof(text), "-%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.8f, 0.0f, alpha) : + ImVec4(1.0f, 0.5f, 0.0f, alpha); + break; + case game::CombatTextEntry::HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::CRIT_HEAL: + snprintf(text, sizeof(text), "+%d!", entry.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, alpha); + break; + case game::CombatTextEntry::MISS: + snprintf(text, sizeof(text), "Miss"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DODGE: + snprintf(text, sizeof(text), outgoing ? "Dodge" : "You Dodge"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PARRY: + snprintf(text, sizeof(text), outgoing ? "Parry" : "You Parry"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::BLOCK: + if (entry.amount > 0) + snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); + else snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); - color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) - : ImVec4(0.4f, 0.9f, 1.0f, alpha); - break; - case game::CombatTextEntry::PERIODIC_DAMAGE: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = outgoing ? - ImVec4(1.0f, 0.9f, 0.3f, alpha) : // Outgoing DoT = pale yellow - ImVec4(1.0f, 0.4f, 0.4f, alpha); // Incoming DoT = pale red - break; - case game::CombatTextEntry::PERIODIC_HEAL: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.4f, 1.0f, 0.5f, alpha); - break; - case game::CombatTextEntry::ENVIRONMENTAL: - snprintf(text, sizeof(text), "-%d", entry.amount); - color = ImVec4(0.9f, 0.5f, 0.2f, alpha); // Orange for environmental - break; - case game::CombatTextEntry::ENERGIZE: - snprintf(text, sizeof(text), "+%d", entry.amount); - color = ImVec4(0.3f, 0.6f, 1.0f, alpha); // Blue for mana/energy - break; - case game::CombatTextEntry::XP_GAIN: - snprintf(text, sizeof(text), "+%d XP", entry.amount); - color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP - break; - case game::CombatTextEntry::IMMUNE: - snprintf(text, sizeof(text), "Immune!"); - color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune - break; - default: - snprintf(text, sizeof(text), "%d", entry.amount); - color = ImVec4(1.0f, 1.0f, 1.0f, alpha); - break; + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::EVADE: + snprintf(text, sizeof(text), outgoing ? "Evade" : "You Evade"); + color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) + : ImVec4(0.4f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::PERIODIC_DAMAGE: + snprintf(text, sizeof(text), "-%d", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.9f, 0.3f, alpha) : + ImVec4(1.0f, 0.4f, 0.4f, alpha); + break; + case game::CombatTextEntry::PERIODIC_HEAL: + snprintf(text, sizeof(text), "+%d", entry.amount); + color = ImVec4(0.4f, 1.0f, 0.5f, alpha); + break; + case game::CombatTextEntry::ENVIRONMENTAL: { + const char* envLabel = ""; + switch (entry.powerType) { + case 0: envLabel = "Fatigue "; break; + case 1: envLabel = "Drowning "; break; + case 2: envLabel = ""; break; + case 3: envLabel = "Lava "; break; + case 4: envLabel = "Slime "; break; + case 5: envLabel = "Fire "; break; + default: envLabel = ""; break; + } + snprintf(text, sizeof(text), "%s-%d", envLabel, entry.amount); + color = ImVec4(0.9f, 0.5f, 0.2f, alpha); + break; + } + case game::CombatTextEntry::ENERGIZE: + snprintf(text, sizeof(text), "+%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.2f, 0.2f, alpha); break; + case 2: color = ImVec4(1.0f, 0.6f, 0.1f, alpha); break; + case 3: color = ImVec4(1.0f, 0.9f, 0.2f, alpha); break; + case 6: color = ImVec4(0.3f, 0.9f, 0.8f, alpha); break; + default: color = ImVec4(0.3f, 0.6f, 1.0f, alpha); break; + } + break; + case game::CombatTextEntry::POWER_DRAIN: + snprintf(text, sizeof(text), "-%d", entry.amount); + switch (entry.powerType) { + case 1: color = ImVec4(1.0f, 0.35f, 0.35f, alpha); break; + case 2: color = ImVec4(1.0f, 0.7f, 0.2f, alpha); break; + case 3: color = ImVec4(1.0f, 0.95f, 0.35f, alpha); break; + case 6: color = ImVec4(0.45f, 0.95f, 0.85f, alpha); break; + default: color = ImVec4(0.45f, 0.75f, 1.0f, alpha); break; + } + break; + case game::CombatTextEntry::XP_GAIN: + snprintf(text, sizeof(text), "+%d XP", entry.amount); + color = ImVec4(0.7f, 0.3f, 1.0f, alpha); + break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); + break; + case game::CombatTextEntry::ABSORB: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Absorbed %d", entry.amount); + else + snprintf(text, sizeof(text), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, alpha); + break; + case game::CombatTextEntry::RESIST: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Resisted %d", entry.amount); + else + snprintf(text, sizeof(text), "Resisted"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); + break; + case game::CombatTextEntry::DEFLECT: + snprintf(text, sizeof(text), outgoing ? "Deflect" : "You Deflect"); + color = outgoing ? ImVec4(0.7f, 0.7f, 0.7f, alpha) + : ImVec4(0.5f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::REFLECT: { + const std::string& reflectName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!reflectName.empty()) + snprintf(text, sizeof(text), outgoing ? "Reflected: %s" : "Reflect: %s", reflectName.c_str()); + else + snprintf(text, sizeof(text), outgoing ? "Reflected" : "You Reflect"); + color = outgoing ? ImVec4(0.85f, 0.75f, 1.0f, alpha) + : ImVec4(0.75f, 0.85f, 1.0f, alpha); + break; + } + case game::CombatTextEntry::PROC_TRIGGER: { + const std::string& procName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!procName.empty()) + snprintf(text, sizeof(text), "%s!", procName.c_str()); + else + snprintf(text, sizeof(text), "PROC!"); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + } + case game::CombatTextEntry::DISPEL: + if (entry.spellId != 0) { + const std::string& dispelledName = gameHandler.getSpellName(entry.spellId); + if (!dispelledName.empty()) + snprintf(text, sizeof(text), "Dispel %s", dispelledName.c_str()); + else + snprintf(text, sizeof(text), "Dispel"); + } else { + snprintf(text, sizeof(text), "Dispel"); + } + color = ImVec4(0.6f, 0.9f, 1.0f, alpha); + break; + case game::CombatTextEntry::STEAL: + if (entry.spellId != 0) { + const std::string& stolenName = gameHandler.getSpellName(entry.spellId); + if (!stolenName.empty()) + snprintf(text, sizeof(text), "Spellsteal %s", stolenName.c_str()); + else + snprintf(text, sizeof(text), "Spellsteal"); + } else { + snprintf(text, sizeof(text), "Spellsteal"); + } + color = ImVec4(0.8f, 0.7f, 1.0f, alpha); + break; + case game::CombatTextEntry::INTERRUPT: { + const std::string& interruptedName = entry.spellId ? gameHandler.getSpellName(entry.spellId) : ""; + if (!interruptedName.empty()) + snprintf(text, sizeof(text), "Interrupt %s", interruptedName.c_str()); + else + snprintf(text, sizeof(text), "Interrupt"); + color = ImVec4(1.0f, 0.6f, 0.9f, alpha); + break; + } + case game::CombatTextEntry::INSTAKILL: + snprintf(text, sizeof(text), outgoing ? "Kill!" : "Killed!"); + color = outgoing ? ImVec4(1.0f, 0.25f, 0.25f, alpha) + : ImVec4(1.0f, 0.1f, 0.1f, alpha); + break; + case game::CombatTextEntry::HONOR_GAIN: + snprintf(text, sizeof(text), "+%d Honor", entry.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, alpha); + break; + case game::CombatTextEntry::GLANCING: + snprintf(text, sizeof(text), "~%d", entry.amount); + color = outgoing ? + ImVec4(0.75f, 0.75f, 0.5f, alpha) : + ImVec4(0.75f, 0.35f, 0.35f, alpha); + break; + case game::CombatTextEntry::CRUSHING: + snprintf(text, sizeof(text), "%d!", entry.amount); + color = outgoing ? + ImVec4(1.0f, 0.55f, 0.1f, alpha) : + ImVec4(1.0f, 0.15f, 0.15f, alpha); + break; + default: + snprintf(text, sizeof(text), "%d", entry.amount); + color = ImVec4(1.0f, 1.0f, 1.0f, alpha); + break; + } + + // --- Rendering style --- + bool isCrit = (entry.type == game::CombatTextEntry::CRIT_DAMAGE || + entry.type == game::CombatTextEntry::CRIT_HEAL); + float renderFontSize = isCrit ? baseFontSize * 1.35f : baseFontSize; + + ImU32 shadowCol = IM_COL32(0, 0, 0, static_cast(alpha * 180)); + ImU32 textCol = ImGui::ColorConvertFloat4ToU32(color); + + // --- Try world-space anchor if we have a destination entity --- + // Types that should always stay as HUD elements (no world anchor) + bool isHudOnly = (entry.type == game::CombatTextEntry::XP_GAIN || + entry.type == game::CombatTextEntry::HONOR_GAIN || + entry.type == game::CombatTextEntry::PROC_TRIGGER); + + bool rendered = false; + if (!isHudOnly && camera && entry.dstGuid != 0) { + // Look up the destination entity's render position + glm::vec3 renderPos; + bool havePos = core::Application::getInstance().getRenderPositionForGuid(entry.dstGuid, renderPos); + if (!havePos) { + // Fallback to entity canonical position + auto entity = gameHandler.getEntityManager().getEntity(entry.dstGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + havePos = true; + } + } } - // Outgoing → right side (near target), incoming → center-left (near player) - int& idx = outgoing ? outIdx : inIdx; - float baseX = outgoing ? outgoingX : incomingX; + if (havePos) { + // Float upward from above the entity's head + renderPos.z += 2.5f + entry.age * 1.2f; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w > 0.01f) { + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x >= -1.5f && ndc.x <= 1.5f && ndc.y >= -1.5f && ndc.y <= 1.5f) { + float sx = (ndc.x * 0.5f + 0.5f) * screenW; + float sy = (ndc.y * 0.5f + 0.5f) * screenH; + + // Horizontal stagger using the random seed + sx += entry.xSeed * 40.0f; + + // Center the text horizontally on the projected point + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + sx -= ts.x * 0.5f; + + // Clamp to screen bounds + sx = std::max(2.0f, std::min(sx, screenW - ts.x - 2.0f)); + + drawList->AddText(font, renderFontSize, + ImVec2(sx + 1.0f, sy + 1.0f), shadowCol, text); + drawList->AddText(font, renderFontSize, + ImVec2(sx, sy), textCol, text); + rendered = true; + } + } + } + } + + // --- HUD fallback for entries without world anchor or HUD-only types --- + if (!rendered) { + if (!needsHudWindow) { + needsHudWindow = true; + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, 400)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; + ImGui::Begin("##CombatText", nullptr, flags); + } + + float yOffset = 200.0f - entry.age * 60.0f; + int& idx = outgoing ? hudOutIdx : hudInIdx; + float baseX = outgoing ? hudOutgoingX : hudIncomingX; float xOffset = baseX + (idx % 3 - 1) * 60.0f; ++idx; + ImGui::SetCursorPos(ImVec2(xOffset, yOffset)); - ImGui::TextColored(color, "%s", text); + ImVec2 screenPos = ImGui::GetCursorScreenPos(); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText(font, renderFontSize, ImVec2(screenPos.x + 1.0f, screenPos.y + 1.0f), + shadowCol, text); + dl->AddText(font, renderFontSize, screenPos, textCol, text); + + ImVec2 ts = font->CalcTextSizeA(renderFontSize, FLT_MAX, 0.0f, text); + ImGui::Dummy(ts); + } + } + + if (needsHudWindow) { + ImGui::End(); + } +} + +// ============================================================ +// DPS / HPS Meter +// ============================================================ + +void GameScreen::renderDPSMeter(game::GameHandler& gameHandler) { + if (!showDPSMeter_) return; + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + const float dt = ImGui::GetIO().DeltaTime; + + // Track combat duration for accurate DPS denominator in short fights + bool inCombat = gameHandler.isInCombat(); + if (inCombat && !dpsWasInCombat_) { + // Just entered combat — reset encounter accumulators + dpsEncounterDamage_ = 0.0f; + dpsEncounterHeal_ = 0.0f; + dpsLogSeenCount_ = gameHandler.getCombatLog().size(); + dpsCombatAge_ = 0.0f; + } + if (inCombat) { + dpsCombatAge_ += dt; + // Scan any new log entries since last frame + const auto& log = gameHandler.getCombatLog(); + while (dpsLogSeenCount_ < log.size()) { + const auto& e = log[dpsLogSeenCount_++]; + if (!e.isPlayerSource) continue; + switch (e.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + case game::CombatTextEntry::CRIT_DAMAGE: + case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: + dpsEncounterDamage_ += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + dpsEncounterHeal_ += static_cast(e.amount); + break; + default: break; + } + } + } else if (dpsWasInCombat_) { + // Just left combat — keep encounter totals but stop accumulating + } + dpsWasInCombat_ = inCombat; + + // Sum all player-source damage and healing in the current combat-text window + float totalDamage = 0.0f, totalHeal = 0.0f; + for (const auto& e : gameHandler.getCombatText()) { + if (!e.isPlayerSource) continue; + switch (e.type) { + case game::CombatTextEntry::MELEE_DAMAGE: + case game::CombatTextEntry::SPELL_DAMAGE: + case game::CombatTextEntry::CRIT_DAMAGE: + case game::CombatTextEntry::PERIODIC_DAMAGE: + case game::CombatTextEntry::GLANCING: + case game::CombatTextEntry::CRUSHING: + totalDamage += static_cast(e.amount); + break; + case game::CombatTextEntry::HEAL: + case game::CombatTextEntry::CRIT_HEAL: + case game::CombatTextEntry::PERIODIC_HEAL: + totalHeal += static_cast(e.amount); + break; + default: break; + } + } + + // Only show if there's something to report (rolling window or lingering encounter data) + if (totalDamage < 1.0f && totalHeal < 1.0f && !inCombat && + dpsEncounterDamage_ < 1.0f && dpsEncounterHeal_ < 1.0f) return; + + // DPS window = min(combat age, combat-text lifetime) to avoid under-counting + // at the start of a fight and over-counting when entries expire. + float window = std::min(dpsCombatAge_, game::CombatTextEntry::LIFETIME); + if (window < 0.1f) window = 0.1f; + + float dps = totalDamage / window; + float hps = totalHeal / window; + + // Format numbers with K/M suffix for readability + auto fmtNum = [](float v, char* buf, int bufSz) { + if (v >= 1e6f) snprintf(buf, bufSz, "%.1fM", v / 1e6f); + else if (v >= 1000.f) snprintf(buf, bufSz, "%.1fK", v / 1000.f); + else snprintf(buf, bufSz, "%.0f", v); + }; + + char dpsBuf[16], hpsBuf[16]; + fmtNum(dps, dpsBuf, sizeof(dpsBuf)); + fmtNum(hps, hpsBuf, sizeof(hpsBuf)); + + // Position: small floating label just above the action bar, right of center + auto* appWin = core::Application::getInstance().getWindow(); + float screenW = appWin ? static_cast(appWin->getWidth()) : 1280.0f; + float screenH = appWin ? static_cast(appWin->getHeight()) : 720.0f; + + // Show encounter row when fight has been going long enough (> 3s) + bool showEnc = (dpsCombatAge_ > 3.0f || (!inCombat && dpsEncounterDamage_ > 0.0f)); + float encDPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterDamage_ / dpsCombatAge_ : 0.0f; + float encHPS = (dpsCombatAge_ > 0.1f) ? dpsEncounterHeal_ / dpsCombatAge_ : 0.0f; + + char encDpsBuf[16], encHpsBuf[16]; + fmtNum(encDPS, encDpsBuf, sizeof(encDpsBuf)); + fmtNum(encHPS, encHpsBuf, sizeof(encHpsBuf)); + + constexpr float WIN_W = 90.0f; + // Extra rows for encounter DPS/HPS if active + int extraRows = 0; + if (showEnc && encDPS > 0.5f) ++extraRows; + if (showEnc && encHPS > 0.5f) ++extraRows; + float WIN_H = 18.0f + extraRows * 14.0f; + if (dps > 0.5f || hps > 0.5f) WIN_H = std::max(WIN_H, 36.0f); + float wx = screenW * 0.5f + 160.0f; // right of cast bar + float wy = screenH - 130.0f; // above action bar area + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoInputs; + ImGui::SetNextWindowPos(ImVec2(wx, wy), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(WIN_W, WIN_H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.55f); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 3)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.3f, 0.3f, 0.7f)); + + if (ImGui::Begin("##DPSMeter", nullptr, flags)) { + if (dps > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.15f, 1.0f), "%s", dpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("dps"); + } + if (hps > 0.5f) { + ImGui::TextColored(ImVec4(0.35f, 1.0f, 0.35f, 1.0f), "%s", hpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("hps"); + } + // Encounter totals (full-fight average, shown when fight > 3s) + if (showEnc && encDPS > 0.5f) { + ImGui::TextColored(ImVec4(1.0f, 0.65f, 0.25f, 0.80f), "%s", encDpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); + } + if (showEnc && encHPS > 0.5f) { + ImGui::TextColored(ImVec4(0.50f, 1.0f, 0.50f, 0.80f), "%s", encHpsBuf); + ImGui::SameLine(0, 2); + ImGui::TextDisabled("enc"); } } ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); } // ============================================================ @@ -4825,6 +11612,9 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { void GameScreen::renderNameplates(game::GameHandler& gameHandler) { if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + // Reset mouseover each frame; we'll set it below when the cursor is over a nameplate + gameHandler.setMouseoverGuid(0); + auto* appRenderer = core::Application::getInstance().getRenderer(); if (!appRenderer) return; rendering::Camera* camera = appRenderer->getCamera(); @@ -4840,6 +11630,27 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { const uint64_t playerGuid = gameHandler.getPlayerGuid(); const uint64_t targetGuid = gameHandler.getTargetGuid(); + // Build set of creature entries that are kill objectives in active (incomplete) quests. + std::unordered_set questKillEntries; + { + const auto& questLog = gameHandler.getQuestLog(); + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& q : questLog) { + if (q.complete || q.questId == 0) continue; + // Only highlight for tracked quests (or all if nothing tracked). + if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId > 0 && obj.required > 0) { + // Check if not already completed. + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) { + questKillEntries.insert(static_cast(obj.npcOrGoId)); + } + } + } + } + } + ImDrawList* drawList = ImGui::GetBackgroundDrawList(); for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { @@ -4851,12 +11662,21 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); bool isTarget = (guid == targetGuid); - // Player nameplates are always shown; NPC nameplates respect the V-key toggle + // Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle + if (isPlayer && !showFriendlyNameplates_) continue; if (!isPlayer && !showNameplates_) continue; - // Convert canonical WoW position → render space, raise to head height - glm::vec3 renderPos = core::coords::canonicalToRender( - glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + // For corpses (dead units), only show a minimal grey nameplate if selected + bool isCorpse = (unit->getHealth() == 0); + if (isCorpse && !isTarget) continue; + + // Prefer the renderer's actual instance position so the nameplate tracks the + // rendered model exactly (avoids drift from the parallel entity interpolator). + glm::vec3 renderPos; + if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + } renderPos.z += 2.3f; // Cull distance: target or other players up to 40 units; NPC others up to 20 units @@ -4882,22 +11702,71 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float alpha = dist < (cullDist - 5.0f) ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f; auto A = [&](int v) { return static_cast(v * alpha); }; - // Bar colour by hostility + // Bar colour by hostility (grey for corpses) ImU32 barColor, bgColor; - if (unit->isHostile()) { - barColor = IM_COL32(220, 60, 60, A(200)); - bgColor = IM_COL32(100, 25, 25, A(160)); + if (isCorpse) { + // Minimal grey bar for selected corpses (loot/skin targets) + barColor = IM_COL32(140, 140, 140, A(200)); + bgColor = IM_COL32(70, 70, 70, A(160)); + } else if (unit->isHostile()) { + // Check if mob is tapped by another player (grey nameplate) + uint32_t dynFlags = unit->getDynamicFlags(); + bool tappedByOther = (dynFlags & 0x0004) != 0 && (dynFlags & 0x0008) == 0; // TAPPED but not TAPPED_BY_ALL_THREAT_LIST + if (tappedByOther) { + barColor = IM_COL32(160, 160, 160, A(200)); + bgColor = IM_COL32(80, 80, 80, A(160)); + } else { + barColor = IM_COL32(220, 60, 60, A(200)); + bgColor = IM_COL32(100, 25, 25, A(160)); + } + } else if (isPlayer) { + // Player nameplates: use class color for easy identification + uint8_t cid = entityClassId(unit); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + barColor = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), A(210)); + bgColor = IM_COL32( + static_cast(cv.x * 80), + static_cast(cv.y * 80), + static_cast(cv.z * 80), A(160)); + } else { + barColor = IM_COL32(60, 200, 80, A(200)); + bgColor = IM_COL32(25, 100, 35, A(160)); + } } else { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); } + // Check if this unit is targeting the local player (threat indicator) + bool isTargetingPlayer = false; + if (unit->isHostile() && !isCorpse) { + const auto& fields = entityPtr->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end() && loIt->second != 0) { + uint64_t unitTarget = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + unitTarget |= (static_cast(hiIt->second) << 32); + isTargetingPlayer = (unitTarget == playerGuid); + } + } + // Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver) + int creatureRank = -1; + if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry()); + + // Border: gold = currently selected, orange = targeting player, dark = default ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) - : IM_COL32(20, 20, 20, A(180)); + : isTargetingPlayer + ? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you + : IM_COL32(20, 20, 20, A(180)); // Bar geometry - constexpr float barW = 80.0f; - constexpr float barH = 8.0f; + const float barW = 80.0f * nameplateScale_; + const float barH = 8.0f * nameplateScale_; const float barX = sx - barW * 0.5f; float healthPct = std::clamp( @@ -4905,34 +11774,298 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { 0.0f, 1.0f); drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f); - drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f); + // For corpses, don't fill health bar (just show grey background) + if (!isCorpse) { + drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f); + } drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + // Elite/Boss/Rare decoration: extra outer border with rank-specific color + if (creatureRank == 1 || creatureRank == 2) { + // Elite / Rare Elite: gold double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 200, 50, A(200)), 3.0f); + } else if (creatureRank == 3) { + // Boss: red double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 40, 40, A(200)), 3.0f); + } else if (creatureRank == 4) { + // Rare: silver double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(170, 200, 230, A(200)), 3.0f); + } + + // HP % text centered on health bar (non-corpse, non-full-health for readability) + if (!isCorpse && unit->getMaxHealth() > 0) { + int hpPct = static_cast(healthPct * 100.0f + 0.5f); + char hpBuf[8]; + snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct); + ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf); + float hpTx = sx - hpTextSz.x * 0.5f; + float hpTy = sy + (barH - hpTextSz.y) * 0.5f; + drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf); + drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf); + } + + // Cast bar below health bar when unit is casting + float castBarBaseY = sy + barH + 2.0f; + float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots + { + const auto* cs = gameHandler.getUnitCastState(guid); + if (cs && cs->casting && cs->timeTotal > 0.0f) { + float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); + const float cbH = 6.0f * nameplateScale_; + + // Spell icon + name above the cast bar + const std::string& spellName = gameHandler.getSpellName(cs->spellId); + { + auto* castAm = core::Application::getInstance().getAssetManager(); + VkDescriptorSet castIcon = (cs->spellId && castAm) + ? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE; + float iconSz = cbH + 8.0f; + if (castIcon) { + // Draw icon to the left of the cast bar + float iconX = barX - iconSz - 2.0f; + float iconY = castBarBaseY; + drawList->AddImage((ImTextureID)(uintptr_t)castIcon, + ImVec2(iconX, iconY), + ImVec2(iconX + iconSz, iconY + iconSz)); + drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f), + ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f), + IM_COL32(0, 0, 0, A(180)), 1.0f); + } + if (!spellName.empty()) { + ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); + float snX = sx - snSz.x * 0.5f; + float snY = castBarBaseY; + drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); + drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); + castBarBaseY += snSz.y + 2.0f; + } + } + + // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete + ImU32 cbBg = IM_COL32(30, 25, 40, A(180)); + ImU32 cbFill; + if (castPct > 0.8f && unit->isHostile()) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + cbFill = cs->interruptible + ? IM_COL32(static_cast(40 * pulse), static_cast(220 * pulse), static_cast(40 * pulse), A(220)) // green pulse + : IM_COL32(static_cast(255 * pulse), static_cast(30 * pulse), static_cast(30 * pulse), A(220)); // red pulse + } else { + cbFill = cs->interruptible + ? IM_COL32(50, 190, 50, A(200)) // green = interruptible + : IM_COL32(190, 40, 40, A(200)); // red = uninterruptible + } + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f); + drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f), + ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f), + IM_COL32(20, 10, 40, A(200)), 2.0f); + + // Time remaining text + char timeBuf[12]; + snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining); + ImVec2 timeSz = ImGui::CalcTextSize(timeBuf); + float timeX = sx - timeSz.x * 0.5f; + float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f; + drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf); + drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf); + nameplateBottom = castBarBaseY + cbH + 2.0f; + } + } + + // Debuff dot indicators: small colored squares below the nameplate showing + // player-applied auras on the current hostile target. + // Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey + if (isTarget && unit->isHostile() && !isCorpse) { + const auto& auras = gameHandler.getTargetAuras(); + const uint64_t pguid = gameHandler.getPlayerGuid(); + const float dotSize = 6.0f * nameplateScale_; + const float dotGap = 2.0f; + float dotX = barX; + for (const auto& aura : auras) { + if (aura.isEmpty() || aura.casterGuid != pguid) continue; + uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId); + ImU32 dotCol; + switch (dispelType) { + case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue + case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple + case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown + case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green + default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey + } + drawList->AddRectFilled(ImVec2(dotX, nameplateBottom), + ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f); + drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), + ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), + IM_COL32(0, 0, 0, A(150)), 1.0f); + + // Duration clock-sweep overlay (like target frame auras) + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remainMs = aura.getRemainingMs(nowMs); + if (aura.maxDurationMs > 0 && remainMs > 0) { + float pct = 1.0f - static_cast(remainMs) / static_cast(aura.maxDurationMs); + pct = std::clamp(pct, 0.0f, 1.0f); + float cx = dotX + dotSize * 0.5f; + float cy = nameplateBottom + dotSize * 0.5f; + float r = dotSize * 0.5f; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + pct * IM_PI * 2.0f; + ImVec2 center(cx, cy); + const int segments = 12; + for (int seg = 0; seg < segments; seg++) { + float a0 = startAngle + (endAngle - startAngle) * seg / segments; + float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments; + drawList->AddTriangleFilled( + center, + ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)), + ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)), + IM_COL32(0, 0, 0, A(100))); + } + } + + // Stack count on dot (upper-left corner) + if (aura.charges > 1) { + char stackBuf[8]; + snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges); + drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf); + drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf); + } + + // Duration text below dot + if (remainMs > 0) { + char durBuf[8]; + if (remainMs >= 60000) + snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000); + else + snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000); + ImVec2 durSz = ImGui::CalcTextSize(durBuf); + float durX = dotX + (dotSize - durSz.x) * 0.5f; + float durY = nameplateBottom + dotSize + 1.0f; + drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf); + // Color: red if < 5s, yellow if < 15s, white otherwise + ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240)) + : remainMs < 15000 ? IM_COL32(255, 200, 60, A(240)) + : IM_COL32(230, 230, 230, A(220)); + drawList->AddText(ImVec2(durX, durY), durCol, durBuf); + } + + // Spell name + duration tooltip on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= dotX && mouse.x < dotX + dotSize && + mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { + const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); + if (!dotSpellName.empty()) { + if (remainMs > 0) { + int secs = remainMs / 1000; + int mins = secs / 60; + secs %= 60; + char tipBuf[128]; + if (mins > 0) + snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs); + else + snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs); + ImGui::SetTooltip("%s", tipBuf); + } else { + ImGui::SetTooltip("%s", dotSpellName.c_str()); + } + } + } + } + + dotX += dotSize + dotGap; + if (dotX + dotSize > barX + barW) break; + } + } + // Name + level label above health bar uint32_t level = unit->getLevel(); + const std::string& unitName = unit->getName(); char labelBuf[96]; - if (level > 0) { + if (isPlayer) { + // Player nameplates: show name only (no level clutter). + // Fall back to level as placeholder while the name query is pending. + if (!unitName.empty()) + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); + else { + // Name query may be pending; request it now to ensure it gets resolved + gameHandler.queryPlayerName(unit->getGuid()); + if (level > 0) + snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level); + else + snprintf(labelBuf, sizeof(labelBuf), "Player"); + } + } else if (level > 0) { uint32_t playerLevel = gameHandler.getPlayerLevel(); // Show skull for units more than 10 levels above the player if (playerLevel > 0 && level > playerLevel + 10) - snprintf(labelBuf, sizeof(labelBuf), "?? %s", unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str()); else - snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str()); } else { - snprintf(labelBuf, sizeof(labelBuf), "%s", unit->getName().c_str()); + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); } ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; float nameY = sy - barH - 12.0f; - // Name color: other player=cyan, hostile=red, non-hostile=yellow (WoW convention) - ImU32 nameColor = isPlayer - ? IM_COL32( 80, 200, 255, A(230)) // cyan — other players - : unit->isHostile() + // Name color: players get WoW class colors; NPCs use hostility (red/yellow) + ImU32 nameColor; + if (isPlayer) { + // Class color with cyan fallback for unknown class + uint8_t cid = entityClassId(unit); + ImVec4 cc = (cid != 0) ? classColorVec4(cid) : ImVec4(0.31f, 0.78f, 1.0f, 1.0f); + nameColor = IM_COL32(static_cast(cc.x*255), static_cast(cc.y*255), + static_cast(cc.z*255), A(230)); + } else { + nameColor = unit->isHostile() ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC + } + // Sub-label below the name: guild tag for players, subtitle for NPCs + std::string subLabel; + if (isPlayer) { + uint32_t guildId = gameHandler.getEntityGuildId(guid); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) subLabel = "<" + gn + ">"; + } + } else { + // NPC subtitle (e.g. "", "") + std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) subLabel = "<" + sub + ">"; + } + if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line + drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + // Sub-label below the name (WoW-style or in lighter color) + if (!subLabel.empty()) { + ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str()); + float subX = sx - subSz.x * 0.5f; + float subY = nameY + textSize.y + 1.0f; + drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str()); + drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str()); + } + + // Group leader crown to the right of the name on player nameplates + if (isPlayer && gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == guid) { + float crownX = nameX + textSize.x + 3.0f; + const char* crownSym = "\xe2\x99\x9b"; // ♛ + drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym); + drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym); + } + // Raid mark (if any) to the left of the name { static const struct { const char* sym; ImU32 col; } kNPMarks[] = { @@ -4951,20 +12084,114 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym); drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym); } + + // Quest kill objective indicator: small yellow sword icon to the right of the name + float questIconX = nameX + textSize.x + 4.0f; + if (!isPlayer && questKillEntries.count(unit->getEntry())) { + const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); + drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f; + } + + // Quest giver indicator: "!" for available quests, "?" for completable/incomplete + if (!isPlayer) { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(guid); + const char* qSym = nullptr; + ImU32 qCol = IM_COL32(255, 210, 0, A(255)); + if (qgs == QGS::AVAILABLE) { + qSym = "!"; + } else if (qgs == QGS::AVAILABLE_LOW) { + qSym = "!"; + qCol = IM_COL32(160, 160, 160, A(220)); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + qSym = "?"; + } else if (qgs == QGS::INCOMPLETE) { + qSym = "?"; + qCol = IM_COL32(160, 160, 160, A(220)); + } + if (qSym) { + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym); + drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym); + } + } } - // Click to target: detect left-click inside the combined nameplate region - if (!ImGui::GetIO().WantCaptureMouse && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + // Click to target / right-click context: detect clicks inside the nameplate region + if (!ImGui::GetIO().WantCaptureMouse) { ImVec2 mouse = ImGui::GetIO().MousePos; float nx0 = nameX - 2.0f; float ny0 = nameY - 1.0f; float nx1 = nameX + textSize.x + 2.0f; float ny1 = sy + barH + 2.0f; if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { - gameHandler.setTarget(guid); + // Track mouseover for [target=mouseover] macro conditionals + gameHandler.setMouseoverGuid(guid); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + gameHandler.setTarget(guid); + } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + nameplateCtxGuid_ = guid; + nameplateCtxPos_ = mouse; + ImGui::OpenPopup("##NameplateCtx"); + } } } } + + // Render nameplate context popup (uses a tiny overlay window as host) + if (nameplateCtxGuid_ != 0) { + ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always); + ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) { + if (ImGui::BeginPopup("##NameplateCtx")) { + auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_); + std::string ctxName = entityPtr ? getEntityName(entityPtr) : ""; + if (!ctxName.empty()) { + ImGui::TextDisabled("%s", ctxName.c_str()); + ImGui::Separator(); + } + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(nameplateCtxGuid_); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(nameplateCtxGuid_); + bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER; + if (isPlayer && !ctxName.empty()) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, ctxName.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(ctxName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(nameplateCtxGuid_); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(nameplateCtxGuid_); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(nameplateCtxGuid_); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(ctxName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(ctxName); + } + ImGui::EndPopup(); + } else { + nameplateCtxGuid_ = 0; + } + } + ImGui::End(); + } } // ============================================================ @@ -4974,6 +12201,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (!gameHandler.isInGroup()) return; + auto* assetMgr = core::Application::getInstance().getAssetManager(); const auto& partyData = gameHandler.getPartyData(); const bool isRaid = (partyData.groupType == 1); float frameY = 120.0f; @@ -5049,13 +12277,81 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { bool isDead = (m.onlineStatus & 0x0020) != 0; bool isGhost = (m.onlineStatus & 0x0010) != 0; - // Name text (truncated) + // Out-of-range check (40 yard threshold) + bool isOOR = false; + if (m.hasPartyStats && isOnline && !isDead && !isGhost && m.zoneId != 0) { + auto playerEnt = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEnt) { + float dx = playerEnt->getX() - static_cast(m.posX); + float dy = playerEnt->getY() - static_cast(m.posY); + isOOR = (dx * dx + dy * dy) > (40.0f * 40.0f); + } + } + // Dim cell overlay when out of range + if (isOOR) + draw->AddRectFilled(cellMin, cellMax, IM_COL32(0, 0, 0, 80), 3.0f); + + // Name text (truncated) — class color when alive+online, gray when dead/offline char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); - ImU32 nameCol = (!isOnline || isDead || isGhost) - ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); + bool isMemberLeader = (m.guid == partyData.leaderGuid); + ImU32 nameCol; + if (!isOnline || isDead || isGhost) { + nameCol = IM_COL32(140, 140, 140, 200); // gray for dead/offline + } else { + // Default: gold for leader, light gray for others + nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : IM_COL32(220, 220, 220, 255); + // Override with WoW class color if entity is loaded + auto mEnt = gameHandler.getEntityManager().getEntity(m.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) nameCol = classColorU32(cid); + } draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); + // Leader crown star in top-right of cell + if (isMemberLeader) + draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); + + // Raid mark symbol — small, just to the left of the leader crown + { + static const struct { const char* sym; ImU32 col; } kCellMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t rmk = gameHandler.getEntityRaidMark(m.guid); + if (rmk < game::GameHandler::kRaidMarkCount) { + ImFont* rmFont = ImGui::GetFont(); + ImVec2 rmsz = rmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kCellMarks[rmk].sym); + float rmX = cellMax.x - 10.0f - 2.0f - rmsz.x; + draw->AddText(rmFont, 9.0f, + ImVec2(rmX, cellMin.y + 2.0f), + kCellMarks[rmk].col, kCellMarks[rmk].sym); + } + } + + // LFG role badge in bottom-right corner of cell + if (m.roles & 0x02) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); + else if (m.roles & 0x04) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H"); + else if (m.roles & 0x08) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D"); + + // Tactical role badge in bottom-left corner (flags from SMSG_GROUP_LIST / SMSG_REAL_GROUP_UPDATE) + // 0x01=Assistant, 0x02=Main Tank, 0x04=Main Assist + if (m.flags & 0x02) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(255, 140, 0, 230), "MT"); + else if (m.flags & 0x04) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(100, 180, 255, 230), "MA"); + else if (m.flags & 0x01) + draw->AddText(ImVec2(cellMin.x + 2.0f, cellMax.y - 11.0f), IM_COL32(180, 215, 255, 180), "A"); + // Health bar uint32_t hp = m.hasPartyStats ? m.curHealth : 0; uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; @@ -5067,10 +12363,22 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); ImVec2 barFill(barBg.x, barBg.y); ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); - ImU32 hpCol = pct > 0.5f ? IM_COL32(60, 180, 60, 255) : - pct > 0.2f ? IM_COL32(200, 180, 50, 255) : - IM_COL32(200, 60, 60, 255); + ImU32 hpCol = isOOR ? IM_COL32(100, 100, 100, 160) : + pct > 0.5f ? IM_COL32(60, 180, 60, 255) : + pct > 0.2f ? IM_COL32(200, 180, 50, 255) : + IM_COL32(200, 60, 60, 255); draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); + // HP percentage or OOR text centered on bar + char hpPct[8]; + if (isOOR) + snprintf(hpPct, sizeof(hpPct), "OOR"); + else + snprintf(hpPct, sizeof(hpPct), "%d%%", static_cast(pct * 100.0f + 0.5f)); + ImVec2 ts = ImGui::CalcTextSize(hpPct); + float tx = (barBg.x + barBgEnd.x - ts.x) * 0.5f; + float ty = barBg.y + (BAR_H - ts.y) * 0.5f; + draw->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 180), hpPct); + draw->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 230), hpPct); } // Power bar @@ -5093,12 +12401,112 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); } + // Dispellable debuff dots at the bottom of the raid cell + // Mirrors party frame debuff indicators for healers in 25/40-man raids + if (!isDead && !isGhost) { + const std::vector* unitAuras = nullptr; + if (m.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (m.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(m.guid); + + if (unitAuras) { + bool shown[5] = {}; + float dotX = cellMin.x + 4.0f; + const float dotY = cellMax.y - 5.0f; + const float DOT_R = 3.5f; + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // debuffs only + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dc; + switch (dt) { + case 1: dc = ImVec4(0.25f, 0.50f, 1.00f, 0.90f); break; // Magic: blue + case 2: dc = ImVec4(0.70f, 0.15f, 0.90f, 0.90f); break; // Curse: purple + case 3: dc = ImVec4(0.65f, 0.45f, 0.10f, 0.90f); break; // Disease: brown + case 4: dc = ImVec4(0.10f, 0.75f, 0.10f, 0.90f); break; // Poison: green + default: continue; + } + ImU32 dotColU = ImGui::ColorConvertFloat4ToU32(dc); + draw->AddCircleFilled(ImVec2(dotX, dotY), DOT_R, dotColU); + draw->AddCircle(ImVec2(dotX, dotY), DOT_R + 0.5f, IM_COL32(0, 0, 0, 160), 8, 1.0f); + + float mdx = mouse.x - dotX, mdy = mouse.y - dotY; + if (mdx * mdx + mdy * mdy < (DOT_R + 4.0f) * (DOT_R + 4.0f)) { + static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + ImGui::BeginTooltip(); + ImGui::TextColored(dc, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } + dotX += 9.0f; + } + } + } + // Clickable invisible region over the whole cell ImGui::SetCursorScreenPos(cellMin); ImGui::PushID(static_cast(m.guid)); if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { gameHandler.setTarget(m.guid); } + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(m.guid); + } + if (ImGui::BeginPopupContextItem("RaidMemberCtx")) { + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(m.guid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(m.guid); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, m.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(m.guid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(m.guid); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + bool isLeader = (partyData.leaderGuid == gameHandler.getPlayerGuid()); + if (isLeader) { + ImGui::Separator(); + if (ImGui::MenuItem("Kick from Raid")) + gameHandler.uninvitePlayer(m.name); + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(m.guid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(m.guid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } ImGui::PopID(); } colIdx++; @@ -5133,11 +12541,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f)); if (ImGui::Begin("##PartyFrames", nullptr, flags)) { + const uint64_t leaderGuid = partyData.leaderGuid; for (const auto& member : partyData.members) { ImGui::PushID(static_cast(member.guid)); - // Name with level and status info - std::string label = member.name; + bool isLeader = (member.guid == leaderGuid); + + // Name with level and status info — leader gets a gold star prefix + std::string label = (isLeader ? "* " : " ") + member.name; if (member.hasPartyStats && member.level > 0) { label += " [" + std::to_string(member.level) + "]"; } @@ -5149,10 +12560,72 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { else if (isDead || isGhost) label += " (dead)"; } - // Clickable name to target + // Clickable name to target — use WoW class colors when entity is loaded, + // fall back to gold for leader / light gray for others + ImVec4 nameColor = isLeader + ? ImVec4(1.0f, 0.85f, 0.0f, 1.0f) + : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + { + auto memberEntity = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(memberEntity.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + // Set mouseover for [target=mouseover] macro conditionals + if (ImGui::IsItemHovered()) { + gameHandler.setMouseoverGuid(member.guid); + } + // Zone tooltip on name hover + if (ImGui::IsItemHovered() && member.hasPartyStats && member.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(member.zoneId); + if (!zoneName.empty()) + ImGui::SetTooltip("%s", zoneName.c_str()); + } + ImGui::PopStyleColor(); + + // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set + if (member.roles != 0) { + ImGui::SameLine(); + if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]"); + if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); } + if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } + } + + // Tactical role badge (MT/MA/Asst) from group flags + if (member.flags & 0x02) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.0f, 0.9f), "[MT]"); + } else if (member.flags & 0x04) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 0.9f), "[MA]"); + } else if (member.flags & 0x01) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 0.7f), "[A]"); + } + + // Raid mark symbol — shown on same line as name when this party member has a mark + { + static const struct { const char* sym; ImU32 col; } kPartyMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImGui::SameLine(); + ImGui::TextColored( + ImGui::ColorConvertU32ToFloat4(kPartyMarks[pmk].col), + "%s", kPartyMarks[pmk].sym); + } + } // Health bar: prefer party stats, fall back to entity uint32_t hp = 0, maxHp = 0; @@ -5167,18 +12640,68 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { maxHp = unit->getMaxHealth(); } } - if (maxHp > 0) { + // Check dead/ghost state for health bar rendering + bool memberDead = false; + bool memberOffline = false; + if (member.hasPartyStats) { + bool isOnline2 = (member.onlineStatus & 0x0001) != 0; + bool isDead2 = (member.onlineStatus & 0x0020) != 0; + bool isGhost2 = (member.onlineStatus & 0x0010) != 0; + memberDead = isDead2 || isGhost2; + memberOffline = !isOnline2; + } + + // Out-of-range check: compare player position to member's reported position + // Range threshold: 40 yards (standard heal/spell range) + bool memberOutOfRange = false; + if (member.hasPartyStats && !memberOffline && !memberDead && + member.zoneId != 0) { + // Same map: use 2D Euclidean distance in WoW coordinates (yards) + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEntity) { + float dx = playerEntity->getX() - static_cast(member.posX); + float dy = playerEntity->getY() - static_cast(member.posY); + float distSq = dx * dx + dy * dy; + memberOutOfRange = (distSq > 40.0f * 40.0f); + } + } + + if (memberDead) { + // Gray "Dead" bar for fallen party members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.35f, 0.35f, 0.35f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 1.0f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Dead"); + ImGui::PopStyleColor(2); + } else if (memberOffline) { + // Dim bar for offline members + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.25f, 0.25f, 0.25f, 0.6f)); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.1f, 0.1f, 0.1f, 0.6f)); + ImGui::ProgressBar(0.0f, ImVec2(-1, 14), "Offline"); + ImGui::PopStyleColor(2); + } else if (maxHp > 0) { float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : - pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) : - ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); - ImGui::ProgressBar(pct, ImVec2(-1, 12), ""); + // Out-of-range: desaturate health bar to gray + ImVec4 hpBarColor = memberOutOfRange + ? ImVec4(0.45f, 0.45f, 0.45f, 0.7f) + : (pct > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.8f, 0.8f, 0.2f, 1.0f) : + ImVec4(0.8f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpBarColor); + char hpText[32]; + if (memberOutOfRange) { + snprintf(hpText, sizeof(hpText), "OOR"); + } else if (maxHp >= 10000) { + snprintf(hpText, sizeof(hpText), "%dk/%dk", + (int)hp / 1000, (int)maxHp / 1000); + } else { + snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + } + ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); ImGui::PopStyleColor(); } - // Power bar (mana/rage/energy) from party stats - if (member.hasPartyStats && member.maxPower > 0) { + // Power bar (mana/rage/energy) from party stats — hidden for dead/offline/OOR + if (!memberDead && !memberOffline && member.hasPartyStats && member.maxPower > 0) { float powerPct = static_cast(member.curPower) / static_cast(member.maxPower); ImVec4 powerColor; switch (member.powerType) { @@ -5196,6 +12719,71 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Dispellable debuff indicators — small colored dots for party member debuffs + // Only show magic/curse/disease/poison (types 1-4); skip non-dispellable + if (!memberDead && !memberOffline) { + const std::vector* unitAuras = nullptr; + if (member.guid == gameHandler.getPlayerGuid()) + unitAuras = &gameHandler.getPlayerAuras(); + else if (member.guid == gameHandler.getTargetGuid()) + unitAuras = &gameHandler.getTargetAuras(); + else + unitAuras = gameHandler.getUnitAuras(member.guid); + + if (unitAuras) { + bool anyDebuff = false; + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; // only debuffs + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0) continue; // skip non-dispellable + anyDebuff = true; + break; + } + if (anyDebuff) { + // Render one dot per unique dispel type present + bool shown[5] = {}; + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 1.0f)); + for (const auto& aura : *unitAuras) { + if (aura.isEmpty()) continue; + if ((aura.flags & 0x80) == 0) continue; + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + if (dt == 0 || dt > 4 || shown[dt]) continue; + shown[dt] = true; + ImVec4 dotCol; + switch (dt) { + case 1: dotCol = ImVec4(0.25f, 0.50f, 1.00f, 1.0f); break; // Magic: blue + case 2: dotCol = ImVec4(0.70f, 0.15f, 0.90f, 1.0f); break; // Curse: purple + case 3: dotCol = ImVec4(0.65f, 0.45f, 0.10f, 1.0f); break; // Disease: brown + case 4: dotCol = ImVec4(0.10f, 0.75f, 0.10f, 1.0f); break; // Poison: green + default: break; + } + ImGui::PushStyleColor(ImGuiCol_Button, dotCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, dotCol); + ImGui::Button("##d", ImVec2(8.0f, 8.0f)); + ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) { + static const char* kDispelNames[] = { "", "Magic", "Curse", "Disease", "Poison" }; + // Find spell name(s) of this dispel type + ImGui::BeginTooltip(); + ImGui::TextColored(dotCol, "%s", kDispelNames[dt]); + for (const auto& da : *unitAuras) { + if (da.isEmpty() || (da.flags & 0x80) == 0) continue; + if (gameHandler.getSpellDispelType(da.spellId) != dt) continue; + const std::string& dName = gameHandler.getSpellName(da.spellId); + if (!dName.empty()) + ImGui::Text(" %s", dName.c_str()); + } + ImGui::EndTooltip(); + } + ImGui::SameLine(); + } + ImGui::NewLine(); + ImGui::PopStyleVar(); + } + } + } + // Party member cast bar — shows when the party member is casting if (auto* cs = gameHandler.getUnitCastState(member.guid)) { float castPct = (cs->timeTotal > 0.0f) @@ -5207,10 +12795,86 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); else snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + { + VkDescriptorSet pIcon = (cs->spellId != 0 && assetMgr) + ? getSpellIcon(cs->spellId, assetMgr) : VK_NULL_HANDLE; + if (pIcon) { + ImGui::Image((ImTextureID)(uintptr_t)pIcon, ImVec2(10, 10)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + } + } ImGui::PopStyleColor(); } + // Right-click context menu for party member actions + if (ImGui::BeginPopupContextItem("PartyMemberCtx")) { + ImGui::TextDisabled("%s", member.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) { + gameHandler.setTarget(member.guid); + } + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(member.guid); + } + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; // WHISPER + strncpy(whisperTargetBuffer, member.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Follow")) { + gameHandler.setTarget(member.guid); + gameHandler.followTarget(); + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(member.guid); + } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(member.guid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(member.guid); + gameHandler.inspectTarget(); + showInspectWindow_ = true; + } + ImGui::Separator(); + if (!member.name.empty()) { + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(member.name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(member.name); + } + } + // Leader-only actions + bool isLeader = (gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()); + if (isLeader) { + ImGui::Separator(); + if (ImGui::MenuItem("Kick from Group")) { + gameHandler.uninvitePlayer(member.name); + } + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + static const char* kRaidMarkNames[] = { + "{*} Star", "{O} Circle", "{<>} Diamond", "{^} Triangle", + "{)} Moon", "{ } Square", "{x} Cross", "{8} Skull" + }; + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(member.guid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(member.guid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } + ImGui::Separator(); ImGui::PopID(); } @@ -5221,11 +12885,444 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Durability Warning (equipment damage indicator) +// ============================================================ + +void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) { + auto* renderer = core::Application::getInstance().getRenderer(); + if (!renderer) return; + + // Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png + const char* home = std::getenv("HOME"); + if (!home) home = std::getenv("USERPROFILE"); + if (!home) home = "/tmp"; + std::string dir = std::string(home) + "/.wowee/screenshots"; + + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &tt); +#else + localtime_r(&tt, &tm); +#endif + + char filename[128]; + std::snprintf(filename, sizeof(filename), + "WoWee_%04d%02d%02d_%02d%02d%02d.png", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + + std::string path = dir + "/" + filename; + + if (renderer->captureScreenshot(path)) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Screenshot saved: " + path; + core::Application::getInstance().getGameHandler()->addLocalChatMessage(sysMsg); + } +} + +void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { + if (gameHandler.getPlayerGuid() == 0) return; + + const auto& inv = gameHandler.getInventory(); + + // Scan all equipment slots (skip bag slots which have no durability) + float minDurPct = 1.0f; + bool hasBroken = false; + + for (int i = static_cast(game::EquipSlot::HEAD); + i < static_cast(game::EquipSlot::BAG1); ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) { + hasBroken = true; + } + float pct = static_cast(slot.item.curDurability) / + static_cast(slot.item.maxDurability); + if (pct < minDurPct) minDurPct = pct; + } + + // Only show warning below 20% + if (minDurPct >= 0.2f && !hasBroken) return; + + ImGuiIO& io = ImGui::GetIO(); + const float screenW = io.DisplaySize.x; + const float screenH = io.DisplaySize.y; + + // Position: just above the XP bar / action bar area (bottom-center) + const float warningW = 220.0f; + const float warningH = 26.0f; + const float posX = (screenW - warningW) * 0.5f; + const float posY = screenH - 140.0f; // above action bar + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0)); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + if (ImGui::Begin("##durability_warn", nullptr, flags)) { + if (hasBroken) { + ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f), + "\xef\x94\x9b Gear broken! Visit a repair NPC"); + } else { + int pctInt = static_cast(minDurPct * 100.0f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.1f, 1.0f), + "\xef\x94\x9b Low durability: %d%%", pctInt); + } + if (ImGui::IsWindowHovered()) + ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC."); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +// ============================================================ +// UI Error Frame (WoW-style center-bottom error overlay) +// ============================================================ + +void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) { + // Age out old entries + for (auto& e : uiErrors_) e.age += deltaTime; + uiErrors_.erase( + std::remove_if(uiErrors_.begin(), uiErrors_.end(), + [](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }), + uiErrors_.end()); + + if (uiErrors_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Fixed invisible overlay + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + if (ImGui::Begin("##UIErrors", nullptr, flags)) { + // Render messages stacked above the action bar (~200px from bottom) + // The newest message is on top; older ones fade below it. + const float baseY = screenH - 200.0f; + const float lineH = 20.0f; + const int count = static_cast(uiErrors_.size()); + + ImDrawList* draw = ImGui::GetWindowDrawList(); + for (int i = count - 1; i >= 0; --i) { + const auto& e = uiErrors_[i]; + float alpha = 1.0f - (e.age / kUIErrorLifetime); + alpha = std::max(0.0f, std::min(1.0f, alpha)); + + // Fade fast in the last 0.5 s + if (e.age > kUIErrorLifetime - 0.5f) + alpha *= (kUIErrorLifetime - e.age) / 0.5f; + + uint8_t a8 = static_cast(alpha * 255.0f); + ImU32 textCol = IM_COL32(255, 50, 50, a8); + ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast(alpha * 180)); + + const char* txt = e.text.c_str(); + ImVec2 sz = ImGui::CalcTextSize(txt); + float x = std::round((screenW - sz.x) * 0.5f); + float y = std::round(baseY - (count - 1 - i) * lineH); + + // Drop shadow + draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt); + draw->AddText(ImVec2(x, y), textCol, txt); + } + } + ImGui::End(); + ImGui::PopStyleVar(); +} + +// ============================================================ +// Reputation change toasts +// ============================================================ + +void GameScreen::renderRepToasts(float deltaTime) { + for (auto& e : repToasts_) e.age += deltaTime; + repToasts_.erase( + std::remove_if(repToasts_.begin(), repToasts_.end(), + [](const RepToastEntry& e) { return e.age >= kRepToastLifetime; }), + repToasts_.end()); + + if (repToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Stack toasts in the lower-right corner (above the action bar), newest on top + const float toastW = 220.0f; + const float toastH = 26.0f; + const float padY = 4.0f; + const float rightEdge = screenW - 14.0f; + const float baseY = screenH - 180.0f; + + const int count = static_cast(repToasts_.size()); + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Compute standing tier label (Exalted, Revered, Honored, Friendly, Neutral, Unfriendly, Hostile, Hated) + auto standingLabel = [](int32_t s) -> const char* { + if (s >= 42000) return "Exalted"; + if (s >= 21000) return "Revered"; + if (s >= 9000) return "Honored"; + if (s >= 3000) return "Friendly"; + if (s >= 0) return "Neutral"; + if (s >= -3000) return "Unfriendly"; + if (s >= -6000) return "Hostile"; + return "Hated"; + }; + + for (int i = 0; i < count; ++i) { + const auto& e = repToasts_[i]; + // Slide in from right on appear, slide out at end + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kRepToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + + float alpha = std::clamp(slide, 0.0f, 1.0f); + float xFull = rightEdge - toastW; + float xStart = screenW + 10.0f; + float toastX = xStart + (xFull - xStart) * slide; + float toastY = baseY - i * (toastH + padY); + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + // Background + draw->AddRectFilled(tl, br, IM_COL32(15, 15, 20, (int)(alpha * 200)), 4.0f); + // Border: green for gain, red for loss + ImU32 borderCol = (e.delta > 0) + ? IM_COL32(80, 200, 80, (int)(alpha * 220)) + : IM_COL32(200, 60, 60, (int)(alpha * 220)); + draw->AddRect(tl, br, borderCol, 4.0f, 0, 1.5f); + + // Delta text: "+250" or "-250" + char deltaBuf[16]; + snprintf(deltaBuf, sizeof(deltaBuf), "%+d", e.delta); + ImU32 deltaCol = (e.delta > 0) ? IM_COL32(80, 220, 80, (int)(alpha * 255)) + : IM_COL32(220, 70, 70, (int)(alpha * 255)); + draw->AddText(font, fontSize, ImVec2(tl.x + 6.0f, tl.y + (toastH - fontSize) * 0.5f), + deltaCol, deltaBuf); + + // Faction name + standing + char nameBuf[64]; + snprintf(nameBuf, sizeof(nameBuf), "%s (%s)", e.factionName.c_str(), standingLabel(e.standing)); + draw->AddText(font, fontSize * 0.85f, ImVec2(tl.x + 44.0f, tl.y + (toastH - fontSize * 0.85f) * 0.5f), + IM_COL32(210, 210, 210, (int)(alpha * 220)), nameBuf); + } +} + +void GameScreen::renderQuestCompleteToasts(float deltaTime) { + for (auto& e : questCompleteToasts_) e.age += deltaTime; + questCompleteToasts_.erase( + std::remove_if(questCompleteToasts_.begin(), questCompleteToasts_.end(), + [](const QuestCompleteToastEntry& e) { return e.age >= kQuestCompleteToastLifetime; }), + questCompleteToasts_.end()); + + if (questCompleteToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + const float toastW = 260.0f; + const float toastH = 40.0f; + const float padY = 4.0f; + const float baseY = screenH - 220.0f; // above rep toasts + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + for (int i = 0; i < static_cast(questCompleteToasts_.size()); ++i) { + const auto& e = questCompleteToasts_[i]; + constexpr float kSlideDur = 0.3f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kQuestCompleteToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + float xFull = screenW - 14.0f - toastW; + float xStart = screenW + 10.0f; + float toastX = xStart + (xFull - xStart) * slide; + float toastY = baseY - i * (toastH + padY); + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + // Background + gold border (quest completion) + draw->AddRectFilled(tl, br, IM_COL32(20, 18, 8, (int)(alpha * 210)), 5.0f); + draw->AddRect(tl, br, IM_COL32(220, 180, 30, (int)(alpha * 230)), 5.0f, 0, 1.5f); + + // Scroll icon placeholder (gold diamond) + float iconCx = tl.x + 18.0f; + float iconCy = tl.y + toastH * 0.5f; + draw->AddCircleFilled(ImVec2(iconCx, iconCy), 7.0f, IM_COL32(210, 170, 20, (int)(alpha * 230))); + draw->AddCircle (ImVec2(iconCx, iconCy), 7.0f, IM_COL32(255, 220, 50, (int)(alpha * 200))); + + // "Quest Complete" header in gold + const char* header = "Quest Complete"; + draw->AddText(font, fontSize * 0.78f, + ImVec2(tl.x + 34.0f, tl.y + 4.0f), + IM_COL32(240, 200, 40, (int)(alpha * 240)), header); + + // Quest title in off-white + const char* titleStr = e.title.empty() ? "Unknown Quest" : e.title.c_str(); + draw->AddText(font, fontSize * 0.82f, + ImVec2(tl.x + 34.0f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(220, 215, 195, (int)(alpha * 220)), titleStr); + } +} + +// ============================================================ +// Zone Entry Toast +// ============================================================ + +void GameScreen::renderZoneToasts(float deltaTime) { + for (auto& e : zoneToasts_) e.age += deltaTime; + zoneToasts_.erase( + std::remove_if(zoneToasts_.begin(), zoneToasts_.end(), + [](const ZoneToastEntry& e) { return e.age >= kZoneToastLifetime; }), + zoneToasts_.end()); + + if (zoneToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + + for (int i = 0; i < static_cast(zoneToasts_.size()); ++i) { + const auto& e = zoneToasts_[i]; + constexpr float kSlideDur = 0.35f; + float slideIn = std::min(e.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kZoneToastLifetime - e.age), kSlideDur) / kSlideDur; + float slide = std::min(slideIn, slideOut); + float alpha = std::clamp(slide, 0.0f, 1.0f); + + // Measure text to size the toast + ImVec2 nameSz = font->CalcTextSizeA(14.0f, FLT_MAX, 0.0f, e.zoneName.c_str()); + const char* header = "Entering:"; + ImVec2 hdrSz = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, header); + + float toastW = std::max(nameSz.x, hdrSz.x) + 28.0f; + float toastH = 42.0f; + + // Center the toast horizontally, appear just below the zone name area (top-center) + float toastX = (screenW - toastW) * 0.5f; + float toastY = 56.0f + i * (toastH + 4.0f); + // Slide down from above + float offY = (1.0f - slide) * (-toastH - 10.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(10, 10, 16, (int)(alpha * 200)), 6.0f); + draw->AddRect(tl, br, IM_COL32(160, 140, 80, (int)(alpha * 220)), 6.0f, 0, 1.2f); + + float cx = tl.x + toastW * 0.5f; + draw->AddText(font, 11.0f, + ImVec2(cx - hdrSz.x * 0.5f, tl.y + 5.0f), + IM_COL32(180, 170, 120, (int)(alpha * 200)), header); + draw->AddText(font, 14.0f, + ImVec2(cx - nameSz.x * 0.5f, tl.y + toastH * 0.5f + 1.0f), + IM_COL32(255, 230, 140, (int)(alpha * 240)), e.zoneName.c_str()); + } +} + +// ─── Area Trigger Message Toasts ───────────────────────────────────────────── +void GameScreen::renderAreaTriggerToasts(float deltaTime, game::GameHandler& gameHandler) { + // Drain any pending messages from GameHandler + while (gameHandler.hasAreaTriggerMsg()) { + AreaTriggerToast t; + t.text = gameHandler.popAreaTriggerMsg(); + t.age = 0.0f; + areaTriggerToasts_.push_back(std::move(t)); + if (areaTriggerToasts_.size() > 4) + areaTriggerToasts_.erase(areaTriggerToasts_.begin()); + } + + // Age and prune + constexpr float kLifetime = 4.5f; + for (auto& t : areaTriggerToasts_) t.age += deltaTime; + areaTriggerToasts_.erase( + std::remove_if(areaTriggerToasts_.begin(), areaTriggerToasts_.end(), + [](const AreaTriggerToast& t) { return t.age >= kLifetime; }), + areaTriggerToasts_.end()); + if (areaTriggerToasts_.empty()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + constexpr float kSlideDur = 0.35f; + + for (int i = 0; i < static_cast(areaTriggerToasts_.size()); ++i) { + const auto& t = areaTriggerToasts_[i]; + + float slideIn = std::min(t.age, kSlideDur) / kSlideDur; + float slideOut = std::min(std::max(0.0f, kLifetime - t.age), kSlideDur) / kSlideDur; + float alpha = std::clamp(std::min(slideIn, slideOut), 0.0f, 1.0f); + + // Measure text + ImVec2 txtSz = font->CalcTextSizeA(13.0f, FLT_MAX, 0.0f, t.text.c_str()); + float toastW = txtSz.x + 30.0f; + float toastH = 30.0f; + + // Center horizontally, place below zone text (center of lower-third) + float toastX = (screenW - toastW) * 0.5f; + float toastY = screenH * 0.62f + i * (toastH + 3.0f); + // Slide up from below + float offY = (1.0f - std::min(slideIn, slideOut)) * (toastH + 12.0f); + toastY += offY; + + ImVec2 tl(toastX, toastY); + ImVec2 br(toastX + toastW, toastY + toastH); + + draw->AddRectFilled(tl, br, IM_COL32(8, 12, 22, (int)(alpha * 190)), 5.0f); + draw->AddRect(tl, br, IM_COL32(100, 160, 220, (int)(alpha * 200)), 5.0f, 0, 1.0f); + + float cx = tl.x + toastW * 0.5f; + // Shadow + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f + 1, tl.y + (toastH - txtSz.y) * 0.5f + 1), + IM_COL32(0, 0, 0, (int)(alpha * 180)), t.text.c_str()); + // Text in light blue + draw->AddText(font, 13.0f, + ImVec2(cx - txtSz.x * 0.5f, tl.y + (toastH - txtSz.y) * 0.5f), + IM_COL32(180, 220, 255, (int)(alpha * 240)), t.text.c_str()); + } +} + // ============================================================ // Boss Encounter Frames // ============================================================ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { + auto* assetMgr = core::Application::getInstance().getAssetManager(); + // Collect active boss unit slots struct BossSlot { uint32_t slot; uint64_t guid; }; std::vector active; @@ -5253,17 +13350,22 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { for (const auto& bs : active) { ImGui::PushID(static_cast(bs.guid)); - // Try to resolve name and health from entity manager + // Try to resolve name, health, and power from entity manager std::string name = "Boss"; uint32_t hp = 0, maxHp = 0; + uint8_t bossPowerType = 0; + uint32_t bossPower = 0, bossMaxPower = 0; auto entity = gameHandler.getEntityManager().getEntity(bs.guid); if (entity && (entity->getType() == game::ObjectType::UNIT || entity->getType() == game::ObjectType::PLAYER)) { auto unit = std::static_pointer_cast(entity); const auto& n = unit->getName(); if (!n.empty()) name = n; - hp = unit->getHealth(); - maxHp = unit->getMaxHealth(); + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + bossPowerType = unit->getPowerType(); + bossPower = unit->getPower(); + bossMaxPower = unit->getMaxPower(); } // Clickable name to target @@ -5284,6 +13386,25 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Boss power bar — shown when boss has a non-zero power pool + // Energy bosses (type 3) are particularly important: full energy signals ability use + if (bossMaxPower > 0 && bossPower > 0) { + float bpPct = static_cast(bossPower) / static_cast(bossMaxPower); + ImVec4 bpColor; + switch (bossPowerType) { + case 0: bpColor = ImVec4(0.2f, 0.3f, 0.9f, 1.0f); break; // Mana: blue + case 1: bpColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; // Rage: red + case 2: bpColor = ImVec4(0.9f, 0.6f, 0.1f, 1.0f); break; // Focus: orange + case 3: bpColor = ImVec4(0.9f, 0.9f, 0.1f, 1.0f); break; // Energy: yellow + default: bpColor = ImVec4(0.4f, 0.8f, 0.4f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bpColor); + char bpLabel[24]; + std::snprintf(bpLabel, sizeof(bpLabel), "%u", bossPower); + ImGui::ProgressBar(bpPct, ImVec2(-1, 6), bpLabel); + ImGui::PopStyleColor(); + } + // Boss cast bar — shown when the boss is casting (critical for interrupt) if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { float castPct = (cs->timeTotal > 0.0f) @@ -5291,17 +13412,185 @@ void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { uint32_t bspell = cs->spellId; const std::string& bcastName = (bspell != 0) ? gameHandler.getSpellName(bspell) : ""; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + // Green = interruptible, Red = immune; pulse when > 80% complete + ImVec4 bcastColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + bcastColor = cs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } else { + bcastColor = cs->interruptible + ? ImVec4(0.2f, 0.75f, 0.2f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, bcastColor); char bcastLabel[72]; if (!bcastName.empty()) snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", bcastName.c_str(), cs->timeRemaining); else snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + { + VkDescriptorSet bIcon = (bspell != 0 && assetMgr) + ? getSpellIcon(bspell, assetMgr) : VK_NULL_HANDLE; + if (bIcon) { + ImGui::Image((ImTextureID)(uintptr_t)bIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + } + } ImGui::PopStyleColor(); } + // Boss aura row: debuffs first (player DoTs), then boss buffs + { + const std::vector* bossAuras = nullptr; + if (bs.guid == gameHandler.getTargetGuid()) + bossAuras = &gameHandler.getTargetAuras(); + else + bossAuras = gameHandler.getUnitAuras(bs.guid); + + if (bossAuras) { + int bossActive = 0; + for (const auto& a : *bossAuras) if (!a.isEmpty()) bossActive++; + if (bossActive > 0) { + constexpr float BA_ICON = 16.0f; + constexpr int BA_PER_ROW = 10; + + uint64_t baNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: player-applied debuffs first (most relevant), then others + const uint64_t pguid = gameHandler.getPlayerGuid(); + std::vector baIdx; + baIdx.reserve(bossAuras->size()); + for (size_t i = 0; i < bossAuras->size(); ++i) + if (!(*bossAuras)[i].isEmpty()) baIdx.push_back(i); + std::sort(baIdx.begin(), baIdx.end(), [&](size_t a, size_t b) { + const auto& aa = (*bossAuras)[a]; + const auto& ab = (*bossAuras)[b]; + bool aPlayerDot = (aa.flags & 0x80) != 0 && aa.casterGuid == pguid; + bool bPlayerDot = (ab.flags & 0x80) != 0 && ab.casterGuid == pguid; + if (aPlayerDot != bPlayerDot) return aPlayerDot > bPlayerDot; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; + int32_t ra = aa.getRemainingMs(baNowMs); + int32_t rb = ab.getRemainingMs(baNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int baShown = 0; + for (size_t si = 0; si < baIdx.size() && baShown < 20; ++si) { + const auto& aura = (*bossAuras)[baIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + bool isPlayerCast = (aura.casterGuid == pguid); + + if (baShown > 0 && baShown % BA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(baIdx[si]) + 7000); + + ImVec4 borderCol; + if (isBuff) { + // Boss buffs: gold for important enrage/shield types + borderCol = ImVec4(0.8f, 0.6f, 0.1f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = isPlayerCast + ? ImVec4(0.90f, 0.30f, 0.10f, 0.9f) // player DoT: orange-red + : ImVec4(0.60f, 0.20f, 0.20f, 0.9f); // other debuff: dark red + break; + } + } + + VkDescriptorSet baIcon = assetMgr + ? getSpellIcon(aura.spellId, assetMgr) : VK_NULL_HANDLE; + if (baIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##baura", + (ImTextureID)(uintptr_t)baIcon, + ImVec2(BA_ICON - 2, BA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(BA_ICON, BA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t baRemain = aura.getRemainingMs(baNowMs); + if (baRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + int s = (baRemain + 999) / 1000; + if (s >= 3600) snprintf(ts, sizeof(ts), "%dh", s / 3600); + else if (s >= 60) snprintf(ts, sizeof(ts), "%d:%02d", s / 60, s % 60); + else snprintf(ts, sizeof(ts), "%d", s); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Stack / charge count — upper-left corner (parity with target/focus frames) + if (aura.charges > 1) { + ImVec2 baMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 2, baMin.y + 2), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(baMin.x + 1, baMin.y + 1), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + if (isPlayerCast && !isBuff) + ImGui::TextColored(ImVec4(0.9f, 0.7f, 0.3f, 1.0f), "Your DoT"); + if (baRemain > 0) { + int s = baRemain / 1000; + char db[32]; + if (s < 60) snprintf(db, sizeof(db), "Remaining: %ds", s); + else snprintf(db, sizeof(db), "Remaining: %dm %ds", s / 60, s % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", db); + } + ImGui::EndTooltip(); + } + + ImGui::PopID(); + baShown++; + } + ImGui::PopStyleVar(); + } + } + } + ImGui::PopID(); ImGui::Spacing(); } @@ -5364,6 +13653,47 @@ void GameScreen::renderDuelRequestPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderDuelCountdown(game::GameHandler& gameHandler) { + float remaining = gameHandler.getDuelCountdownRemaining(); + if (remaining <= 0.0f) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + auto* dl = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + + // Show integer countdown or "Fight!" when under 0.5s + char buf[32]; + if (remaining > 0.5f) { + snprintf(buf, sizeof(buf), "%d", static_cast(std::ceil(remaining))); + } else { + snprintf(buf, sizeof(buf), "Fight!"); + } + + // Large font by scaling — use 4x font size for dramatic effect + float scale = 4.0f; + float scaledSize = fontSize * scale; + ImVec2 textSz = font->CalcTextSizeA(scaledSize, FLT_MAX, 0.0f, buf); + float tx = (screenW - textSz.x) * 0.5f; + float ty = screenH * 0.35f - textSz.y * 0.5f; + + // Pulsing alpha: fades in and out per second + float pulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 6.28f); + uint8_t alpha = static_cast(255 * pulse); + + // Color: golden countdown, red "Fight!" + ImU32 color = (remaining > 0.5f) + ? IM_COL32(255, 200, 50, alpha) + : IM_COL32(255, 60, 60, alpha); + + // Drop shadow + dl->AddText(font, scaledSize, ImVec2(tx + 2.0f, ty + 2.0f), IM_COL32(0, 0, 0, alpha / 2), buf); + dl->AddText(font, scaledSize, ImVec2(tx, ty), color, buf); +} + void GameScreen::renderItemTextWindow(game::GameHandler& gameHandler) { if (!gameHandler.isItemTextOpen()) return; @@ -5482,6 +13812,170 @@ void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isTradeOpen()) return; + + const auto& mySlots = gameHandler.getMyTradeSlots(); + const auto& peerSlots = gameHandler.getPeerTradeSlots(); + const uint64_t myGold = gameHandler.getMyTradeGold(); + const uint64_t peerGold = gameHandler.getPeerTradeGold(); + const auto& peerName = gameHandler.getTradePeerName(); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (ImGui::Begin(("Trade with " + peerName).c_str(), &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + + auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) { + uint64_t g = copper / 10000; + uint64_t s = (copper % 10000) / 100; + uint64_t c = copper % 100; + if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc", + (unsigned long long)g, (unsigned long long)s, (unsigned long long)c); + else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc", + (unsigned long long)s, (unsigned long long)c); + else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c); + }; + + auto renderSlotColumn = [&](const char* label, + const std::array& slots, + uint64_t gold, bool isMine) { + ImGui::Text("%s", label); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) { + const auto& slot = slots[i]; + ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100)); + + if (slot.occupied && slot.itemId != 0) { + const auto* info = gameHandler.getItemInfo(slot.itemId); + std::string name = (info && info->valid && !info->name.empty()) + ? info->name + : ("Item " + std::to_string(slot.itemId)); + if (slot.stackCount > 1) + name += " x" + std::to_string(slot.stackCount); + ImVec4 qc = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ImVec4(1.0f, 0.9f, 0.5f, 1.0f); + if (info && info->valid && info->displayInfoId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(16, 16)); + ImGui::SameLine(); + } + } + ImGui::TextColored(qc, "%d. %s", i + 1, name.c_str()); + if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + gameHandler.clearTradeItem(static_cast(i)); + } + if (ImGui::IsItemHovered()) { + if (info && info->valid) inventoryScreen.renderItemTooltip(*info); + else if (isMine) ImGui::SetTooltip("Double-click to remove"); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + } else { + ImGui::TextDisabled(" %d. (empty)", i + 1); + + // Allow dragging inventory items into trade slots via right-click context menu + if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup(("##additem" + std::to_string(i)).c_str()); + } + } + + if (isMine) { + // Drag-from-inventory: show small popup listing bag items + if (ImGui::BeginPopup(("##additem" + std::to_string(i)).c_str())) { + ImGui::TextDisabled("Add from inventory:"); + const auto& inv = gameHandler.getInventory(); + // Backpack slots 0-15 (bag=255) + for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) { + const auto& slot = inv.getBackpackSlot(si); + if (slot.empty()) continue; + const auto* ii = gameHandler.getItemInfo(slot.item.itemId); + std::string iname = (ii && ii->valid && !ii->name.empty()) + ? ii->name + : (!slot.item.name.empty() ? slot.item.name + : ("Item " + std::to_string(slot.item.itemId))); + if (ImGui::Selectable(iname.c_str())) { + // bag=255 = main backpack + gameHandler.setTradeItem(static_cast(i), 255u, + static_cast(si)); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } + ImGui::PopID(); + } + + // Gold row + char gbuf[48]; + formatGold(gold, gbuf, sizeof(gbuf)); + ImGui::Spacing(); + if (isMine) { + ImGui::Text("Gold offered: %s", gbuf); + static char goldInput[32] = "0"; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput), + ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) { + uint64_t copper = std::strtoull(goldInput, nullptr, 10); + gameHandler.setTradeGold(copper); + } + ImGui::SameLine(); + ImGui::TextDisabled("(copper, Enter to set)"); + } else { + ImGui::Text("Gold offered: %s", gbuf); + } + }; + + // Two-column layout: my offer | peer offer + float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f; + ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true); + renderSlotColumn("Your offer", mySlots, myGold, true); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true); + renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false); + ImGui::EndChild(); + + // Buttons + ImGui::Spacing(); + ImGui::Separator(); + float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) { + gameHandler.acceptTrade(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(bw, 0))) { + gameHandler.cancelTrade(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.cancelTrade(); + } +} + void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (!gameHandler.hasPendingLootRoll()) return; @@ -5502,28 +13996,139 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImVec4(0.0f, 0.44f, 0.87f, 1.0f),// 3=rare (blue) ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4=epic (purple) ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5=legendary (orange) + ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 6=artifact (light gold) + ImVec4(0.90f, 0.80f, 0.50f, 1.0f),// 7=heirloom (light gold) }; uint8_t q = roll.itemQuality; - ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; + ImVec4 col = (q < 8) ? kQualityColors[q] : kQualityColors[1]; + + // Countdown bar + { + auto now = std::chrono::steady_clock::now(); + float elapsedMs = static_cast( + std::chrono::duration_cast(now - roll.rollStartedAt).count()); + float totalMs = static_cast(roll.rollCountdownMs > 0 ? roll.rollCountdownMs : 60000); + float fraction = 1.0f - std::min(elapsedMs / totalMs, 1.0f); + float remainSec = (totalMs - elapsedMs) / 1000.0f; + if (remainSec < 0.0f) remainSec = 0.0f; + + // Color: green → yellow → red + ImVec4 barColor; + if (fraction > 0.5f) + barColor = ImVec4(0.2f + (1.0f - fraction) * 1.4f, 0.85f, 0.2f, 1.0f); + else if (fraction > 0.2f) + barColor = ImVec4(1.0f, fraction * 1.7f, 0.1f, 1.0f); + else { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 6.0f); + barColor = ImVec4(pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } + + char timeBuf[16]; + std::snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remainSec); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + ImGui::ProgressBar(fraction, ImVec2(-1, 12), timeBuf); + ImGui::PopStyleColor(); + } ImGui::Text("An item is up for rolls:"); - ImGui::TextColored(col, "[%s]", roll.itemName.c_str()); + + // Show item icon if available + const auto* rollInfo = gameHandler.getItemInfo(roll.itemId); + uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0; + VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE; + if (rollIcon) { + ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); + ImGui::SameLine(); + } + // Prefer live item info (arrives via SMSG_ITEM_QUERY_SINGLE_RESPONSE after the + // roll popup opens); fall back to the name cached at SMSG_LOOT_START_ROLL time. + const char* displayName = (rollInfo && rollInfo->valid && !rollInfo->name.empty()) + ? rollInfo->name.c_str() + : roll.itemName.c_str(); + if (rollInfo && rollInfo->valid) + col = (rollInfo->quality < 8) ? kQualityColors[rollInfo->quality] : kQualityColors[1]; + ImGui::TextColored(col, "[%s]", displayName); + if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { + inventoryScreen.renderItemTooltip(*rollInfo); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && rollInfo && rollInfo->valid && !rollInfo->name.empty()) { + std::string link = buildItemChatLink(rollInfo->entry, rollInfo->quality, rollInfo->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::Spacing(); - if (ImGui::Button("Need", ImVec2(80, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + // voteMask bits: 0x01=pass, 0x02=need, 0x04=greed, 0x08=disenchant + const uint8_t vm = roll.voteMask; + bool first = true; + if (vm & 0x02) { + if (ImGui::Button("Need", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 0); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Greed", ImVec2(80, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + if (vm & 0x04) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Greed", ImVec2(80, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 1); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Disenchant", ImVec2(95, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + if (vm & 0x08) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Disenchant", ImVec2(95, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 2); + first = false; } - ImGui::SameLine(); - if (ImGui::Button("Pass", ImVec2(70, 30))) { - gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); + if (vm & 0x01) { + if (!first) ImGui::SameLine(); + if (ImGui::Button("Pass", ImVec2(70, 30))) + gameHandler.sendLootRoll(roll.objectGuid, roll.slot, 96); + } + + // Live roll results from group members + if (!roll.playerRolls.empty()) { + ImGui::Separator(); + ImGui::TextDisabled("Rolls so far:"); + // Roll-type label + color + static const char* kRollLabels[] = {"Need", "Greed", "Disenchant", "Pass"}; + static const ImVec4 kRollColors[] = { + ImVec4(0.2f, 0.9f, 0.2f, 1.0f), // Need — green + ImVec4(0.3f, 0.6f, 1.0f, 1.0f), // Greed — blue + ImVec4(0.7f, 0.3f, 0.9f, 1.0f), // Disenchant — purple + ImVec4(0.5f, 0.5f, 0.5f, 1.0f), // Pass — gray + }; + auto rollTypeIndex = [](uint8_t t) -> int { + if (t == 0) return 0; + if (t == 1) return 1; + if (t == 2) return 2; + return 3; // pass (96 or unknown) + }; + + if (ImGui::BeginTable("##lootrolls", 3, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 72.0f); + ImGui::TableSetupColumn("Roll", ImGuiTableColumnFlags_WidthFixed, 32.0f); + for (const auto& r : roll.playerRolls) { + int ri = rollTypeIndex(r.rollType); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.playerName.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(kRollColors[ri], "%s", kRollLabels[ri]); + ImGui::TableSetColumnIndex(2); + if (r.rollType != 96) { + ImGui::TextColored(kRollColors[ri], "%d", static_cast(r.rollNum)); + } else { + ImGui::TextDisabled("—"); + } + } + ImGui::EndTable(); + } } } ImGui::End(); @@ -5583,13 +14188,288 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { gameHandler.respondToReadyCheck(false); gameHandler.dismissReadyCheck(); } + + // Live player responses + const auto& results = gameHandler.getReadyCheckResults(); + if (!results.empty()) { + ImGui::Separator(); + if (ImGui::BeginTable("##rcresults", 2, + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("Player", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 72.0f); + for (const auto& r : results) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(r.name.c_str()); + ImGui::TableSetColumnIndex(1); + if (r.ready) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "Ready"); + } else { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "Not Ready"); + } + } + ImGui::EndTable(); + } + } } ImGui::End(); } +void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingBgInvite()) return; + + const auto& queues = gameHandler.getBgQueues(); + // Find the first WAIT_JOIN slot + const game::GameHandler::BgQueueSlot* slot = nullptr; + for (const auto& s : queues) { + if (s.statusId == 2) { slot = &s; break; } + } + if (!slot) return; + + // Compute time remaining + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - slot->inviteReceivedTime).count(); + double remaining = static_cast(slot->inviteTimeout) - elapsed; + + // If invite has expired, clear it silently (server will handle the queue) + if (remaining <= 0.0) { + gameHandler.declineBattlefield(slot->queueSlot); + return; + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 70), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags popupFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) { + // BG name from stored queue data + std::string bgName = slot->bgName.empty() ? "Battleground" : slot->bgName; + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str()); + ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast(remaining)); + ImGui::Spacing(); + + // Countdown progress bar + float frac = static_cast(remaining / static_cast(slot->inviteTimeout)); + frac = std::clamp(frac, 0.0f, 1.0f); + ImVec4 barColor = frac > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) + : frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f) + : ImVec4(0.9f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + char countdownLabel[32]; + snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast(remaining)); + ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel); + ImGui::PopStyleColor(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) { + gameHandler.acceptBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Leave Queue", ImVec2(175, 30))) { + gameHandler.declineBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void GameScreen::renderBfMgrInvitePopup(game::GameHandler& gameHandler) { + // Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager) + if (!gameHandler.hasBfMgrInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battlefield", nullptr, flags)) { + // Resolve zone name for Wintergrasp (zoneId 4197) + uint32_t zoneId = gameHandler.getBfMgrZoneId(); + const char* zoneName = nullptr; + if (zoneId == 4197) zoneName = "Wintergrasp"; + else if (zoneId == 5095) zoneName = "Tol Barad"; + + if (zoneName) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield"); + } + ImGui::Spacing(); + ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?"); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Enter Battlefield", ImVec2(178, 28))) { + gameHandler.acceptBfMgrInvite(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Decline", ImVec2(175, 28))) { + gameHandler.declineBfMgrInvite(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::Proposal) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Dungeon Finder", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "A group has been found!"); + ImGui::Spacing(); + ImGui::TextWrapped("Please accept or decline to join the dungeon."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void GameScreen::renderLfgRoleCheckPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::RoleCheck) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 160.0f, screenH / 2.0f - 80.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(320.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.5f, 0.9f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.1f, 0.3f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Role Check##LfgRoleCheck", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.4f, 0.7f, 1.0f, 1.0f), "Confirm your role:"); + ImGui::Spacing(); + + // Role checkboxes + bool isTank = (lfgRoles_ & 0x02) != 0; + bool isHealer = (lfgRoles_ & 0x04) != 0; + bool isDps = (lfgRoles_ & 0x08) != 0; + + if (ImGui::Checkbox("Tank", &isTank)) lfgRoles_ = (lfgRoles_ & ~0x02) | (isTank ? 0x02 : 0); + ImGui::SameLine(120.0f); + if (ImGui::Checkbox("Healer", &isHealer)) lfgRoles_ = (lfgRoles_ & ~0x04) | (isHealer ? 0x04 : 0); + ImGui::SameLine(220.0f); + if (ImGui::Checkbox("DPS", &isDps)) lfgRoles_ = (lfgRoles_ & ~0x08) | (isDps ? 0x08 : 0); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + bool hasRole = (lfgRoles_ & 0x0E) != 0; + if (!hasRole) ImGui::BeginDisabled(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.4f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); + if (ImGui::Button("Accept", ImVec2(140.0f, 28.0f))) { + gameHandler.lfgSetRoles(lfgRoles_); + } + ImGui::PopStyleColor(2); + + if (!hasRole) ImGui::EndDisabled(); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.4f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.6f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Leave Queue", ImVec2(140.0f, 28.0f))) { + gameHandler.lfgLeave(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { - // O key toggle (WoW default Social/Guild keybind) - if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { + // Guild Roster toggle (customizable keybind) + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + !ImGui::GetIO().WantCaptureKeyboard && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_GUILD_ROSTER)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { // Open friends tab directly if not in guild @@ -5621,7 +14501,8 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { uint32_t gold = cost / 10000; uint32_t silver = (cost % 10000) / 100; uint32_t copper = cost % 100; - ImGui::Text("Cost: %ug %us %uc", gold, silver, copper); + ImGui::TextDisabled("Cost:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); ImGui::Spacing(); ImGui::Text("Guild Name:"); ImGui::InputText("##petitionname", petitionNameBuffer_, sizeof(petitionNameBuffer_)); @@ -5641,6 +14522,62 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndPopup(); } + // Petition signatures window (shown when a petition item is used or offered) + if (gameHandler.hasPetitionSignaturesUI()) { + ImGui::OpenPopup("PetitionSignatures"); + gameHandler.clearPetitionSignaturesUI(); + } + if (ImGui::BeginPopupModal("PetitionSignatures", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + const auto& pInfo = gameHandler.getPetitionInfo(); + if (!pInfo.guildName.empty()) + ImGui::Text("Guild Charter: %s", pInfo.guildName.c_str()); + else + ImGui::Text("Guild Charter"); + ImGui::Separator(); + + ImGui::Text("Signatures: %u / %u", pInfo.signatureCount, pInfo.signaturesRequired); + ImGui::Spacing(); + + if (!pInfo.signatures.empty()) { + for (size_t i = 0; i < pInfo.signatures.size(); ++i) { + const auto& sig = pInfo.signatures[i]; + // Try to resolve name from entity manager + std::string sigName; + if (sig.playerGuid != 0) { + auto entity = gameHandler.getEntityManager().getEntity(sig.playerGuid); + if (entity) { + auto* unit = dynamic_cast(entity.get()); + if (unit) sigName = unit->getName(); + } + } + if (sigName.empty()) + sigName = "Player " + std::to_string(i + 1); + ImGui::BulletText("%s", sigName.c_str()); + } + ImGui::Spacing(); + } + + // If we're not the owner, show Sign button + bool isOwner = (pInfo.ownerGuid == gameHandler.getPlayerGuid()); + if (!isOwner) { + if (ImGui::Button("Sign", ImVec2(120, 0))) { + gameHandler.signPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } else if (pInfo.signatureCount >= pInfo.signaturesRequired) { + // Owner with enough sigs — turn in + if (ImGui::Button("Turn In", ImVec2(120, 0))) { + gameHandler.turnInPetition(pInfo.petitionGuid); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + } + if (ImGui::Button("Close", ImVec2(120, 0))) + ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } + if (!showGuildRoster_) return; // Get zone manager for name lookup @@ -5704,19 +14641,14 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { return a.name < b.name; }); - static const char* classNames[] = { - "Unknown", "Warrior", "Paladin", "Hunter", "Rogue", - "Priest", "Death Knight", "Shaman", "Mage", "Warlock", - "", "Druid" - }; - for (const auto& m : sortedMembers) { ImGui::TableNextRow(); ImVec4 textColor = m.online ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImVec4 nameColor = m.online ? classColorVec4(m.classId) : textColor; ImGui::TableNextColumn(); - ImGui::TextColored(textColor, "%s", m.name.c_str()); + ImGui::TextColored(nameColor, "%s", m.name.c_str()); // Right-click context menu if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { @@ -5736,8 +14668,9 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::TextColored(textColor, "%u", m.level); ImGui::TableNextColumn(); - const char* className = (m.classId < 12) ? classNames[m.classId] : "Unknown"; - ImGui::TextColored(textColor, "%s", className); + const char* className = classNameStr(m.classId); + ImVec4 classCol = m.online ? classColorVec4(m.classId) : textColor; + ImGui::TextColored(classCol, "%s", className); ImGui::TableNextColumn(); // Zone name lookup @@ -5763,8 +14696,33 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // Context menu popup if (ImGui::BeginPopup("GuildMemberContext")) { - ImGui::Text("%s", selectedGuildMember_.c_str()); + ImGui::TextDisabled("%s", selectedGuildMember_.c_str()); ImGui::Separator(); + // Social actions — only for online members + bool memberOnline = false; + for (const auto& mem : roster.members) { + if (mem.name == selectedGuildMember_) { memberOnline = mem.online; break; } + } + if (memberOnline) { + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, selectedGuildMember_.c_str(), + sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(selectedGuildMember_); + } + ImGui::Separator(); + } + if (!selectedGuildMember_.empty()) { + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(selectedGuildMember_); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(selectedGuildMember_); + ImGui::Separator(); + } if (ImGui::MenuItem("Promote")) { gameHandler.promoteGuildMember(selectedGuildMember_); } @@ -5965,12 +14923,31 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { guildRosterTab_ = 2; const auto& contacts = gameHandler.getContacts(); + // Add Friend row + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("Add Friend") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + ImGui::Separator(); + + // Note-edit state + static std::string friendNoteTarget; + static char friendNoteBuf[256] = {}; + static bool openNotePopup = false; + // Filter to friends only int friendCount = 0; - for (const auto& c : contacts) { + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; if (!c.isFriend()) continue; ++friendCount; + ImGui::PushID(static_cast(ci)); + // Status dot ImU32 dotColor = c.isOnline() ? IM_COL32(80, 200, 80, 255) @@ -5981,33 +14958,160 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::Dummy(ImVec2(14.0f, 0.0f)); ImGui::SameLine(); - // Name + // Name as Selectable for right-click context menu const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); ImVec4 nameCol = c.isOnline() ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); - ImGui::TextColored(nameCol, "%s", displayName); + ImGui::PushStyleColor(ImGuiCol_Text, nameCol); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap, ImVec2(130.0f, 0.0f)); + ImGui::PopStyleColor(); - // Level and status on same line (right-aligned) + // Double-click to whisper + if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) + && !c.name.empty()) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper") && !c.name.empty()) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (c.isOnline() && ImGui::MenuItem("Invite to Group") && !c.name.empty()) { + gameHandler.inviteToGroup(c.name); + } + if (ImGui::MenuItem("Edit Note")) { + friendNoteTarget = c.name; + strncpy(friendNoteBuf, c.note.c_str(), sizeof(friendNoteBuf) - 1); + friendNoteBuf[sizeof(friendNoteBuf) - 1] = '\0'; + openNotePopup = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Remove Friend")) { + gameHandler.removeFriend(c.name); + } + ImGui::EndPopup(); + } + + // Note tooltip on hover + if (ImGui::IsItemHovered() && !c.note.empty()) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } + + // Level, class, and status if (c.isOnline()) { - ImGui::SameLine(); + ImGui::SameLine(150.0f); const char* statusLabel = - (c.status == 2) ? "(AFK)" : - (c.status == 3) ? "(DND)" : ""; - if (c.level > 0) { - ImGui::TextDisabled("Lv %u %s", c.level, statusLabel); + (c.status == 2) ? " (AFK)" : + (c.status == 3) ? " (DND)" : ""; + // Class color for the level/class display + ImVec4 friendClassCol = classColorVec4(static_cast(c.classId)); + const char* friendClassName = classNameStr(static_cast(c.classId)); + if (c.level > 0 && c.classId > 0) { + ImGui::TextColored(friendClassCol, "Lv%u %s%s", c.level, friendClassName, statusLabel); + } else if (c.level > 0) { + ImGui::TextDisabled("Lv %u%s", c.level, statusLabel); } else if (*statusLabel) { - ImGui::TextDisabled("%s", statusLabel); + ImGui::TextDisabled("%s", statusLabel + 1); + } + + // Tooltip: zone info + if (ImGui::IsItemHovered() && c.areaId != 0) { + ImGui::BeginTooltip(); + if (zoneManager) { + const auto* zi = zoneManager->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) + ImGui::Text("Zone: %s", zi->name.c_str()); + else + ImGui::TextDisabled("Area ID: %u", c.areaId); + } else { + ImGui::TextDisabled("Area ID: %u", c.areaId); + } + ImGui::EndTooltip(); } } + + ImGui::PopID(); } if (friendCount == 0) { - ImGui::TextDisabled("No friends online."); + ImGui::TextDisabled("No friends found."); } + // Note edit modal + if (openNotePopup) { + ImGui::OpenPopup("EditFriendNote"); + openNotePopup = false; + } + if (ImGui::BeginPopupModal("EditFriendNote", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::Text("Note for %s:", friendNoteTarget.c_str()); + ImGui::SetNextItemWidth(240.0f); + ImGui::InputText("##fnote", friendNoteBuf, sizeof(friendNoteBuf)); + if (ImGui::Button("Save", ImVec2(110, 0))) { + gameHandler.setFriendNote(friendNoteTarget, friendNoteBuf); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(110, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::EndTabItem(); + } + + // ---- Ignore List tab ---- + if (ImGui::BeginTabItem("Ignore")) { + guildRosterTab_ = 3; + const auto& contacts = gameHandler.getContacts(); + + // Add Ignore row + static char addIgnoreBuf[64] = {}; + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##addignore", addIgnoreBuf, sizeof(addIgnoreBuf)); + ImGui::SameLine(); + if (ImGui::Button("Ignore Player") && addIgnoreBuf[0] != '\0') { + gameHandler.addIgnore(addIgnoreBuf); + addIgnoreBuf[0] = '\0'; + } ImGui::Separator(); - ImGui::TextDisabled("Right-click a player's name in chat to add friends."); + + int ignoreCount = 0; + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isIgnored()) continue; + ++ignoreCount; + + ImGui::PushID(static_cast(ci) + 10000); + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImGui::Selectable(displayName, false, ImGuiSelectableFlags_AllowOverlap); + if (ImGui::BeginPopupContextItem("IgnoreCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (ImGui::MenuItem("Remove Ignore")) { + gameHandler.removeIgnore(c.name); + } + ImGui::EndPopup(); + } + ImGui::PopID(); + } + + if (ignoreCount == 0) { + ImGui::TextDisabled("Ignore list is empty."); + } + ImGui::EndTabItem(); } @@ -6018,6 +15122,377 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { showGuildRoster_ = open; } +// ============================================================ +// Social Frame — compact online friends panel (toggled by showSocialFrame_) +// ============================================================ + +void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { + if (!showSocialFrame_) return; + + const auto& contacts = gameHandler.getContacts(); + // Count online friends for early-out + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW - 230.0f, 240.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(220.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.92f)); + + // State for "Set Note" inline editing + static int noteEditContactIdx = -1; + static char noteEditBuf[128] = {}; + + bool open = showSocialFrame_; + char socialTitle[32]; + snprintf(socialTitle, sizeof(socialTitle), "Social (%d online)##SocialFrame", onlineCount); + if (ImGui::Begin(socialTitle, &open, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + + // Get zone manager for area name lookups + game::ZoneManager* socialZoneMgr = nullptr; + if (auto* rend = core::Application::getInstance().getRenderer()) + socialZoneMgr = rend->getZoneManager(); + + if (ImGui::BeginTabBar("##SocialTabs")) { + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + ImGui::BeginChild("##FriendsList", ImVec2(200, 200), false); + + // Online friends first + int shown = 0; + for (int pass = 0; pass < 2; ++pass) { + bool wantOnline = (pass == 0); + for (size_t ci = 0; ci < contacts.size(); ++ci) { + const auto& c = contacts[ci]; + if (!c.isFriend()) continue; + if (c.isOnline() != wantOnline) continue; + + ImGui::PushID(static_cast(ci)); + + // Status dot + ImU32 dotColor; + if (!c.isOnline()) dotColor = IM_COL32(100, 100, 100, 200); + else if (c.status == 2) dotColor = IM_COL32(255, 200, 50, 255); // AFK + else if (c.status == 3) dotColor = IM_COL32(255, 120, 50, 255); // DND + else dotColor = IM_COL32( 50, 220, 50, 255); // online + + ImVec2 dotMin = ImGui::GetCursorScreenPos(); + dotMin.y += 4.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotMin.x + 5.0f, dotMin.y + 5.0f), 4.5f, dotColor); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? classColorVec4(static_cast(c.classId)) + : ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); + + if (c.isOnline() && c.level > 0) { + ImGui::SameLine(); + // Show level and class name in class color + ImGui::TextColored(classColorVec4(static_cast(c.classId)), + "Lv%u %s", c.level, classNameStr(static_cast(c.classId))); + } + + // Tooltip: zone info and note + if (ImGui::IsItemHovered() || (c.isOnline() && ImGui::IsItemHovered())) { + if (c.isOnline() && (c.areaId != 0 || !c.note.empty())) { + ImGui::BeginTooltip(); + if (c.areaId != 0) { + const char* zoneName = nullptr; + if (socialZoneMgr) { + const auto* zi = socialZoneMgr->getZoneInfo(c.areaId); + if (zi && !zi->name.empty()) zoneName = zi->name.c_str(); + } + if (zoneName) + ImGui::Text("Zone: %s", zoneName); + else + ImGui::Text("Area ID: %u", c.areaId); + } + if (!c.note.empty()) + ImGui::TextDisabled("Note: %s", c.note.c_str()); + ImGui::EndTooltip(); + } + } + + // Right-click context menu + if (ImGui::BeginPopupContextItem("FriendCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (c.isOnline()) { + if (ImGui::MenuItem("Whisper")) { + showSocialFrame_ = false; + strncpy(whisperTargetBuffer, c.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + selectedChatType = 4; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(c.name); + if (c.guid != 0 && ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(c.guid); + } + if (ImGui::MenuItem("Set Note")) { + noteEditContactIdx = static_cast(ci); + strncpy(noteEditBuf, c.note.c_str(), sizeof(noteEditBuf) - 1); + noteEditBuf[sizeof(noteEditBuf) - 1] = '\0'; + ImGui::OpenPopup("##SetFriendNote"); + } + if (ImGui::MenuItem("Remove Friend")) + gameHandler.removeFriend(c.name); + ImGui::EndPopup(); + } + + ++shown; + ImGui::PopID(); + } + // Separator between online and offline if there are both + if (pass == 0 && shown > 0) { + ImGui::Separator(); + } + } + + if (shown == 0) { + ImGui::TextDisabled("No friends yet."); + } + + ImGui::EndChild(); + + // "Set Note" modal popup + if (ImGui::BeginPopup("##SetFriendNote")) { + const std::string& noteName = (noteEditContactIdx >= 0 && + noteEditContactIdx < static_cast(contacts.size())) + ? contacts[noteEditContactIdx].name : ""; + ImGui::TextDisabled("Note for %s:", noteName.c_str()); + ImGui::SetNextItemWidth(180.0f); + bool confirm = ImGui::InputText("##noteinput", noteEditBuf, sizeof(noteEditBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (confirm || ImGui::Button("OK")) { + if (!noteName.empty()) + gameHandler.setFriendNote(noteName, noteEditBuf); + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + noteEditContactIdx = -1; + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + ImGui::Separator(); + + // Add friend + static char addFriendBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addfriend", addFriendBuf, sizeof(addFriendBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##addfriend") && addFriendBuf[0] != '\0') { + gameHandler.addFriend(addFriendBuf); + addFriendBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + // ---- Ignore tab ---- + if (ImGui::BeginTabItem("Ignore")) { + const auto& ignores = gameHandler.getIgnoreCache(); + ImGui::BeginChild("##IgnoreList", ImVec2(200, 200), false); + + if (ignores.empty()) { + ImGui::TextDisabled("Ignore list is empty."); + } else { + for (const auto& kv : ignores) { + ImGui::PushID(kv.first.c_str()); + ImGui::TextUnformatted(kv.first.c_str()); + if (ImGui::BeginPopupContextItem("IgnoreCtx")) { + ImGui::TextDisabled("%s", kv.first.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Unignore")) + gameHandler.removeIgnore(kv.first); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + + ImGui::EndChild(); + ImGui::Separator(); + + // Add ignore + static char addIgnBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_addignore", addIgnBuf, sizeof(addIgnBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##addignore") && addIgnBuf[0] != '\0') { + gameHandler.addIgnore(addIgnBuf); + addIgnBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + // ---- Channels tab ---- + if (ImGui::BeginTabItem("Channels")) { + const auto& channels = gameHandler.getJoinedChannels(); + ImGui::BeginChild("##ChannelList", ImVec2(200, 200), false); + + if (channels.empty()) { + ImGui::TextDisabled("Not in any channels."); + } else { + for (size_t ci = 0; ci < channels.size(); ++ci) { + ImGui::PushID(static_cast(ci)); + ImGui::TextUnformatted(channels[ci].c_str()); + if (ImGui::BeginPopupContextItem("ChanCtx")) { + ImGui::TextDisabled("%s", channels[ci].c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Leave Channel")) + gameHandler.leaveChannel(channels[ci]); + ImGui::EndPopup(); + } + ImGui::PopID(); + } + } + + ImGui::EndChild(); + ImGui::Separator(); + + // Join a channel + static char joinChanBuf[64] = {}; + ImGui::SetNextItemWidth(140.0f); + ImGui::InputText("##sf_joinchan", joinChanBuf, sizeof(joinChanBuf)); + ImGui::SameLine(); + if (ImGui::Button("+##joinchan") && joinChanBuf[0] != '\0') { + gameHandler.joinChannel(joinChanBuf); + joinChanBuf[0] = '\0'; + } + + ImGui::EndTabItem(); + } + + // ---- Arena tab (WotLK: shows per-team rating/record + roster) ---- + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (!arenaStats.empty()) { + if (ImGui::BeginTabItem("Arena")) { + ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false); + + for (size_t ai = 0; ai < arenaStats.size(); ++ai) { + const auto& ts = arenaStats[ai]; + ImGui::PushID(static_cast(ai)); + + // Team header: "2v2: Team Name" or fallback "Team #id" + std::string teamLabel; + if (ts.teamType > 0) + teamLabel = std::to_string(ts.teamType) + "v" + std::to_string(ts.teamType) + ": "; + if (!ts.teamName.empty()) + teamLabel += ts.teamName; + else + teamLabel += "Team #" + std::to_string(ts.teamId); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", teamLabel.c_str()); + + ImGui::Indent(8.0f); + // Rating and rank + ImGui::Text("Rating: %u", ts.rating); + if (ts.rank > 0) { + ImGui::SameLine(0, 6); + ImGui::TextDisabled("(Rank #%u)", ts.rank); + } + + // Weekly record + uint32_t weekLosses = ts.weekGames > ts.weekWins + ? ts.weekGames - ts.weekWins : 0; + ImGui::Text("Week: %u W / %u L", ts.weekWins, weekLosses); + + // Season record + uint32_t seasLosses = ts.seasonGames > ts.seasonWins + ? ts.seasonGames - ts.seasonWins : 0; + ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); + + // Roster members (from SMSG_ARENA_TEAM_ROSTER) + const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId); + if (roster && !roster->members.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("-- Roster (%zu members) --", + roster->members.size()); + ImGui::SameLine(); + if (ImGui::SmallButton("Refresh")) + gameHandler.requestArenaTeamRoster(ts.teamId); + + // Column headers + ImGui::Columns(4, "##arenaRosterCols", false); + ImGui::SetColumnWidth(0, 110.0f); + ImGui::SetColumnWidth(1, 60.0f); + ImGui::SetColumnWidth(2, 60.0f); + ImGui::SetColumnWidth(3, 60.0f); + ImGui::TextDisabled("Name"); ImGui::NextColumn(); + ImGui::TextDisabled("Rating"); ImGui::NextColumn(); + ImGui::TextDisabled("Week"); ImGui::NextColumn(); + ImGui::TextDisabled("Season"); ImGui::NextColumn(); + ImGui::Separator(); + + for (const auto& m : roster->members) { + // Name coloured green (online) or grey (offline) + if (m.online) + ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f), + "%s", m.name.c_str()); + else + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::NextColumn(); + + ImGui::Text("%u", m.personalRating); + ImGui::NextColumn(); + + uint32_t wL = m.weekGames > m.weekWins + ? m.weekGames - m.weekWins : 0; + ImGui::Text("%uW/%uL", m.weekWins, wL); + ImGui::NextColumn(); + + uint32_t sL = m.seasonGames > m.seasonWins + ? m.seasonGames - m.seasonWins : 0; + ImGui::Text("%uW/%uL", m.seasonWins, sL); + ImGui::NextColumn(); + } + ImGui::Columns(1); + } else { + ImGui::Spacing(); + if (ImGui::SmallButton("Load Roster")) + gameHandler.requestArenaTeamRoster(ts.teamId); + } + + ImGui::Unindent(8.0f); + + if (ai + 1 < arenaStats.size()) + ImGui::Separator(); + + ImGui::PopID(); + } + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + } + + ImGui::EndTabBar(); + } + } + ImGui::End(); + showSocialFrame_ = open; + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + // ============================================================ // Buff/Debuff Bar (Phase 3) // ============================================================ @@ -6034,12 +15509,15 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { auto* assetMgr = core::Application::getInstance().getAssetManager(); - // Position below the player frame in top-left + // Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210) + // Anchored to the right side to stay away from party frames on the left constexpr float ICON_SIZE = 32.0f; constexpr int ICONS_PER_ROW = 8; float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f; - // Dock under player frame in top-left (player frame is at 10, 30 with ~110px height) - ImGui::SetNextWindowPos(ImVec2(10.0f, 145.0f), ImGuiCond_Always); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + // Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210) + ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | @@ -6050,17 +15528,59 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); if (ImGui::Begin("##BuffBar", nullptr, flags)) { - int shown = 0; - for (size_t i = 0; i < auras.size() && shown < 16; ++i) { + // Pre-sort auras: buffs first, then debuffs; within each group, shorter remaining first + uint64_t buffNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector buffSortedIdx; + buffSortedIdx.reserve(auras.size()); + for (size_t i = 0; i < auras.size(); ++i) + if (!auras[i].isEmpty()) buffSortedIdx.push_back(i); + std::sort(buffSortedIdx.begin(), buffSortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = auras[a]; const auto& ab = auras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff < bDebuff; // buffs (0) first + int32_t ra = aa.getRemainingMs(buffNowMs); + int32_t rb = ab.getRemainingMs(buffNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + // Render one pass for buffs, one for debuffs + for (int pass = 0; pass < 2; ++pass) { + bool wantBuff = (pass == 0); + int shown = 0; + for (size_t si = 0; si < buffSortedIdx.size() && shown < 40; ++si) { + size_t i = buffSortedIdx[si]; const auto& aura = auras[i]; if (aura.isEmpty()) continue; + bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag + if (isBuff != wantBuff) continue; // only render matching pass + if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); - ImGui::PushID(static_cast(i)); + ImGui::PushID(static_cast(i) + (pass * 256)); - bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag - ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); + // Determine border color: buffs = green; debuffs use WoW dispel-type colors + ImVec4 borderColor; + if (isBuff) { + borderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); // green + } else { + // Debuff: color by dispel type (0=none/red, 1=magic/blue, 2=curse/purple, + // 3=disease/brown, 4=poison/green, other=dark-red) + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: borderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: borderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: borderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: borderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } // Try to get spell icon VkDescriptorSet iconTex = VK_NULL_HANDLE; @@ -6090,6 +15610,32 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { std::chrono::steady_clock::now().time_since_epoch()).count()); int32_t remainMs = aura.getRemainingMs(nowMs); + // Clock-sweep overlay: dark fan shows elapsed time (WoW style) + if (remainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 iconMin2 = ImGui::GetItemRectMin(); + ImVec2 iconMax2 = ImGui::GetItemRectMax(); + float cx2 = (iconMin2.x + iconMax2.x) * 0.5f; + float cy2 = (iconMin2.y + iconMax2.y) * 0.5f; + float fanR2 = (iconMax2.x - iconMin2.x) * 0.5f; + float total2 = static_cast(aura.maxDurationMs); + float elapsedFrac2 = std::clamp( + 1.0f - static_cast(remainMs) / total2, 0.0f, 1.0f); + if (elapsedFrac2 > 0.005f) { + constexpr int SWEEP_SEGS = 24; + float sa = -IM_PI * 0.5f; + float ea = sa + elapsedFrac2 * 2.0f * IM_PI; + ImVec2 pts[SWEEP_SEGS + 2]; + pts[0] = ImVec2(cx2, cy2); + for (int s = 0; s <= SWEEP_SEGS; ++s) { + float a = sa + (ea - sa) * s / static_cast(SWEEP_SEGS); + pts[s + 1] = ImVec2(cx2 + std::cos(a) * fanR2, + cy2 + std::sin(a) * fanR2); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + pts, SWEEP_SEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + // Duration countdown overlay — always visible on the icon bottom if (remainMs > 0) { ImVec2 iconMin = ImGui::GetItemRectMin(); @@ -6105,11 +15651,26 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(timeStr); float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; float cy = iconMax.y - textSize.y - 2.0f; + // Choose timer color based on urgency + ImU32 timerColor; + if (remainMs < 10000) { + // < 10s: pulse red + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + timerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (remainMs < 30000) { + timerColor = IM_COL32(255, 165, 0, 255); // orange + } else { + timerColor = IM_COL32(255, 255, 255, 255); // white + } // Drop shadow for readability over any icon colour ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 200), timeStr); ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 255, 255), timeStr); + timerColor, timeStr); } // Stack / charge count overlay — upper-left corner of the icon @@ -6133,28 +15694,35 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } } - // Tooltip with spell name and countdown + // Tooltip: rich spell info + remaining duration if (ImGui::IsItemHovered()) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } if (remainMs > 0) { int seconds = remainMs / 1000; - if (seconds < 60) { - ImGui::SetTooltip("%s (%ds)", name.c_str(), seconds); - } else { - ImGui::SetTooltip("%s (%dm %ds)", name.c_str(), seconds / 60, seconds % 60); - } - } else { - ImGui::SetTooltip("%s", name.c_str()); + char durBuf[32]; + if (seconds < 60) snprintf(durBuf, sizeof(durBuf), "Remaining: %ds", seconds); + else snprintf(durBuf, sizeof(durBuf), "Remaining: %dm %ds", seconds / 60, seconds % 60); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", durBuf); } + ImGui::EndTooltip(); } ImGui::PopID(); shown++; - } + } // end aura loop + // Add visual gap between buffs and debuffs + if (pass == 0 && shown > 0) ImGui::Spacing(); + } // end pass loop + // Dismiss Pet button if (gameHandler.hasPet()) { - if (shown > 0) ImGui::Spacing(); + ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f)); if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) { @@ -6162,6 +15730,60 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { } ImGui::PopStyleColor(2); } + + // Temporary weapon enchant timers (Shaman imbues, Rogue poisons, whetstones, etc.) + { + const auto& timers = gameHandler.getTempEnchantTimers(); + if (!timers.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + static const ImVec4 kEnchantSlotColors[] = { + ImVec4(0.9f, 0.6f, 0.1f, 1.0f), // main-hand: gold + ImVec4(0.5f, 0.8f, 0.9f, 1.0f), // off-hand: teal + ImVec4(0.7f, 0.5f, 0.9f, 1.0f), // ranged: purple + }; + uint64_t enchNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (const auto& t : timers) { + if (t.slot > 2) continue; + uint64_t remMs = (t.expireMs > enchNowMs) ? (t.expireMs - enchNowMs) : 0; + if (remMs == 0) continue; + + ImVec4 col = kEnchantSlotColors[t.slot]; + // Flash red when < 60s remaining + if (remMs < 60000) { + float pulse = 0.6f + 0.4f * std::sin( + static_cast(ImGui::GetTime()) * 4.0f); + col = ImVec4(pulse, 0.2f, 0.1f, 1.0f); + } + + // Format remaining time + uint32_t secs = static_cast((remMs + 999) / 1000); + char timeStr[16]; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh%02dm", secs / 3600, (secs % 3600) / 60); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%ds", secs); + + ImGui::PushID(static_cast(t.slot) + 5000); + ImGui::PushStyleColor(ImGuiCol_Button, col); + char label[40]; + snprintf(label, sizeof(label), "~%s %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], timeStr); + ImGui::Button(label, ImVec2(-1, 16)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Temporary weapon enchant: %s\nRemaining: %s", + game::GameHandler::kTempEnchantSlotNames[t.slot], + timeStr); + ImGui::PopStyleColor(); + ImGui::PopID(); + } + } + } } ImGui::End(); @@ -6186,10 +15808,11 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { if (ImGui::Begin("Loot", &open, ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { const auto& loot = gameHandler.getCurrentLoot(); - // Gold + // Gold (auto-looted on open; shown for feedback) if (loot.gold > 0) { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%ug %us %uc", - loot.getGold(), loot.getSilver(), loot.getCopper()); + ImGui::TextDisabled("Gold:"); + ImGui::SameLine(0, 4); + renderCoinsText(loot.getGold(), loot.getSilver(), loot.getCopper()); ImGui::Separator(); } @@ -6210,6 +15833,7 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { itemName = "Item #" + std::to_string(item.itemId); } ImVec4 qColor = InventoryScreen::getQualityColor(quality); + bool startsQuest = (info && info->startQuestId != 0); // Get item icon uint32_t displayId = item.displayInfoId; @@ -6221,13 +15845,32 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Invisible selectable for click handling if (ImGui::Selectable("##loot", false, 0, ImVec2(0, rowH))) { - lootSlotClicked = item.slotIndex; + if (ImGui::GetIO().KeyShift && info && !info->name.empty()) { + // Shift-click: insert item link into chat + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } else { + lootSlotClicked = item.slotIndex; + } } if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) { lootSlotClicked = item.slotIndex; } bool hovered = ImGui::IsItemHovered(); + // Show item tooltip on hover + if (hovered && info && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered && info && !info->name.empty()) { + // Item info received but not yet fully valid — show name at minimum + ImGui::SetTooltip("%s", info->name.c_str()); + } + ImDrawList* drawList = ImGui::GetWindowDrawList(); // Draw hover highlight @@ -6251,6 +15894,14 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { drawList->AddRect(cursor, ImVec2(cursor.x + iconSize, cursor.y + iconSize), IM_COL32(80, 80, 80, 200)); } + // Quest-starter: gold outer glow border + "!" badge on top-right corner + if (startsQuest) { + drawList->AddRect(ImVec2(cursor.x - 2.0f, cursor.y - 2.0f), + ImVec2(cursor.x + iconSize + 2.0f, cursor.y + iconSize + 2.0f), + IM_COL32(255, 210, 0, 210), 0.0f, 0, 2.0f); + drawList->AddText(ImVec2(cursor.x + iconSize - 10.0f, cursor.y + 1.0f), + IM_COL32(255, 210, 0, 255), "!"); + } // Draw item name float textX = cursor.x + iconSize + 6.0f; @@ -6258,12 +15909,15 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(textX, textY), ImGui::ColorConvertFloat4ToU32(qColor), itemName.c_str()); - // Draw count if > 1 - if (item.count > 1) { + // Draw count or "Begins a Quest" label on second line + float secondLineY = textY + ImGui::GetTextLineHeight(); + if (startsQuest) { + drawList->AddText(ImVec2(textX, secondLineY), + IM_COL32(255, 210, 0, 255), "Begins a Quest"); + } else if (item.count > 1) { char countStr[32]; snprintf(countStr, sizeof(countStr), "x%u", item.count); - float countY = textY + ImGui::GetTextLineHeight(); - drawList->AddText(ImVec2(textX, countY), IM_COL32(200, 200, 200, 220), countStr); + drawList->AddText(ImVec2(textX, secondLineY), IM_COL32(200, 200, 200, 220), countStr); } ImGui::PopID(); @@ -6271,7 +15925,43 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { // Process deferred loot pickup (after loop to avoid iterator invalidation) if (lootSlotClicked >= 0) { - gameHandler.lootItem(static_cast(lootSlotClicked)); + if (gameHandler.hasMasterLootCandidates()) { + // Master looter: open popup to choose recipient + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", lootSlotClicked); + ImGui::OpenPopup(popupId); + } else { + gameHandler.lootItem(static_cast(lootSlotClicked)); + } + } + + // Master loot "Give to" popups + if (gameHandler.hasMasterLootCandidates()) { + for (const auto& item : loot.items) { + char popupId[32]; + snprintf(popupId, sizeof(popupId), "##MLGive%d", item.slotIndex); + if (ImGui::BeginPopup(popupId)) { + ImGui::TextDisabled("Give to:"); + ImGui::Separator(); + const auto& candidates = gameHandler.getMasterLootCandidates(); + for (uint64_t candidateGuid : candidates) { + auto entity = gameHandler.getEntityManager().getEntity(candidateGuid); + auto* unit = entity ? dynamic_cast(entity.get()) : nullptr; + const char* cName = unit ? unit->getName().c_str() : nullptr; + char nameBuf[64]; + if (!cName || cName[0] == '\0') { + snprintf(nameBuf, sizeof(nameBuf), "Player 0x%llx", + static_cast(candidateGuid)); + cName = nameBuf; + } + if (ImGui::MenuItem(cName)) { + gameHandler.lootMasterGive(item.slotIndex, candidateGuid); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } } if (loot.items.empty() && loot.gold == 0) { @@ -6279,6 +15969,15 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } ImGui::Spacing(); + bool hasItems = !loot.items.empty(); + if (hasItems) { + if (ImGui::Button("Loot All", ImVec2(-1, 0))) { + for (const auto& item : loot.items) { + gameHandler.lootItem(item.slotIndex); + } + } + ImGui::Spacing(); + } if (ImGui::Button("Close", ImVec2(-1, 0))) { gameHandler.closeLoot(); } @@ -6375,6 +16074,9 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { std::string processedText = replaceGenderPlaceholders(displayText, gameHandler); std::string label = std::string(icon) + " " + processedText; if (ImGui::Selectable(label.c_str())) { + if (opt.text == "GOSSIP_OPTION_ARMORER") { + gameHandler.setVendorCanRepair(true); + } gameHandler.selectGossipOption(opt.id); } ImGui::PopID(); @@ -6409,9 +16111,38 @@ void GameScreen::renderGossipWindow(game::GameHandler& gameHandler) { for (size_t qi = 0; qi < gossip.quests.size(); qi++) { const auto& quest = gossip.quests[qi]; ImGui::PushID(static_cast(qi)); + + // Determine icon and color based on QuestGiverStatus stored in questIcon + // 5=INCOMPLETE (gray?), 6=REWARD_REP (yellow?), 7=AVAILABLE_LOW (gray!), + // 8=AVAILABLE (yellow!), 10=REWARD (yellow?) + const char* statusIcon = "!"; + ImVec4 statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + switch (quest.questIcon) { + case 5: // INCOMPLETE — in progress but not done + statusIcon = "?"; + statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray + break; + case 6: // REWARD_REP — repeatable, ready to turn in + case 10: // REWARD — ready to turn in + statusIcon = "?"; + statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + break; + case 7: // AVAILABLE_LOW — available but gray (low-level) + statusIcon = "!"; + statusColor = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); // gray + break; + default: // AVAILABLE (8) and any others + statusIcon = "!"; + statusColor = ImVec4(1.0f, 1.0f, 0.3f, 1.0f); // yellow + break; + } + + // Render: colored icon glyph then [Lv] Title + ImGui::TextColored(statusColor, "%s", statusIcon); + ImGui::SameLine(0, 4); char qlabel[256]; snprintf(qlabel, sizeof(qlabel), "[%d] %s", quest.questLevel, quest.title.c_str()); - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, statusColor); if (ImGui::Selectable(qlabel)) { gameHandler.selectGossipQuest(quest.questId); } @@ -6465,7 +16196,66 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { ImGui::TextWrapped("%s", processedObjectives.c_str()); } - // Rewards + // Choice reward items (player picks one) + auto renderQuestRewardItem = [&](const game::QuestRewardItem& ri) { + gameHandler.ensureItemInfo(ri.itemId); + auto* info = gameHandler.getItemInfo(ri.itemId); + VkDescriptorSet iconTex = VK_NULL_HANDLE; + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) iconTex = inventoryScreen.getItemIcon(dispId); + + std::string label; + ImVec4 nameCol = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (info && info->valid && !info->name.empty()) { + label = info->name; + nameCol = InventoryScreen::getQualityColor(static_cast(info->quality)); + } else { + label = "Item " + std::to_string(ri.itemId); + } + if (ri.count > 1) label += " x" + std::to_string(ri.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + ImGui::SameLine(); + } + ImGui::TextColored(nameCol, " %s", label.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + }; + + if (!quest.rewardChoiceItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Choose one reward:"); + for (const auto& ri : quest.rewardChoiceItems) { + renderQuestRewardItem(ri); + } + } + + // Fixed reward items (always given) + if (!quest.rewardItems.empty()) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will receive:"); + for (const auto& ri : quest.rewardItems) { + renderQuestRewardItem(ri); + } + } + + // XP and money rewards if (quest.rewardXp > 0 || quest.rewardMoney > 0) { ImGui::Spacing(); ImGui::Separator(); @@ -6477,9 +16267,8 @@ void GameScreen::renderQuestDetailsWindow(game::GameHandler& gameHandler) { uint32_t gold = quest.rewardMoney / 10000; uint32_t silver = (quest.rewardMoney % 10000) / 100; uint32_t copper = quest.rewardMoney % 100; - if (gold > 0) ImGui::Text(" %ug %us %uc", gold, silver, copper); - else if (silver > 0) ImGui::Text(" %us %uc", silver, copper); - else ImGui::Text(" %uc", copper); + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); } } @@ -6556,14 +16345,34 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { for (const auto& item : quest.requiredItems) { uint32_t have = countItemInInventory(item.itemId); bool enough = have >= item.count; + ImVec4 textCol = enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f); auto* info = gameHandler.getItemInfo(item.itemId); const char* name = (info && info->valid) ? info->name.c_str() : nullptr; + + // Show icon if display info is available + uint32_t dispId = item.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + ImGui::SameLine(); + } + } if (name && *name) { - ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f), - " %s %u/%u", name, have, item.count); + ImGui::TextColored(textCol, "%s %u/%u", name, have, item.count); } else { - ImGui::TextColored(enough ? ImVec4(0.6f, 1.0f, 0.6f, 1.0f) : ImVec4(1.0f, 0.6f, 0.6f, 1.0f), - " Item %u %u/%u", item.itemId, have, item.count); + ImGui::TextColored(textCol, "Item %u %u/%u", item.itemId, have, item.count); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } } } } @@ -6573,7 +16382,8 @@ void GameScreen::renderQuestRequestItemsWindow(game::GameHandler& gameHandler) { uint32_t g = quest.requiredMoney / 10000; uint32_t s = (quest.requiredMoney % 10000) / 100; uint32_t c = quest.requiredMoney % 100; - ImGui::Text("Required money: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Required money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Complete / Cancel buttons @@ -6631,6 +16441,36 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } // Choice rewards (pick one) + // Trigger item info fetch for all reward items + for (const auto& item : quest.choiceRewards) gameHandler.ensureItemInfo(item.itemId); + for (const auto& item : quest.fixedRewards) gameHandler.ensureItemInfo(item.itemId); + + // Helper: resolve icon tex + quality color for a reward item + auto resolveRewardItemVis = [&](const game::QuestRewardItem& ri) + -> std::pair + { + auto* info = gameHandler.getItemInfo(ri.itemId); + uint32_t dispId = ri.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + ImVec4 col = (info && info->valid) + ? InventoryScreen::getQualityColor(static_cast(info->quality)) + : ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + return {iconTex, col}; + }; + + // Helper: show full item tooltip (reuses InventoryScreen's rich tooltip) + auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 /*nameCol*/) { + auto* info = gameHandler.getItemInfo(ri.itemId); + if (!info || !info->valid) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Loading item data..."); + ImGui::EndTooltip(); + return; + } + inventoryScreen.renderItemTooltip(*info); + }; + if (!quest.choiceRewards.empty()) { ImGui::Spacing(); ImGui::Separator(); @@ -6639,48 +16479,39 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { for (size_t i = 0; i < quest.choiceRewards.size(); ++i) { const auto& item = quest.choiceRewards[i]; auto* info = gameHandler.getItemInfo(item.itemId); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); bool selected = (selectedChoice == static_cast(i)); + ImGui::PushID(static_cast(i)); - // Get item icon if we have displayInfoId - VkDescriptorSet iconTex = VK_NULL_HANDLE; - if (info && info->valid && info->displayInfoId != 0) { - iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + // Icon then selectable on same line + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(20, 20)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); } - - // Quality color - ImVec4 qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); // White (poor) - if (info && info->valid) { - switch (info->quality) { - case 1: qualityColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); break; // Common (white) - case 2: qualityColor = ImVec4(0.0f, 1.0f, 0.0f, 1.0f); break; // Uncommon (green) - case 3: qualityColor = ImVec4(0.0f, 0.5f, 1.0f, 1.0f); break; // Rare (blue) - case 4: qualityColor = ImVec4(0.64f, 0.21f, 0.93f, 1.0f); break; // Epic (purple) - case 5: qualityColor = ImVec4(1.0f, 0.5f, 0.0f, 1.0f); break; // Legendary (orange) + ImGui::PushStyleColor(ImGuiCol_Text, qualityColor); + if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 20))) { + if (ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } else { + selectedChoice = static_cast(i); } } + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); - // Render item with icon + visible selectable label - ImGui::PushID(static_cast(i)); - std::string label; - if (info && info->valid && !info->name.empty()) { - label = info->name; - } else { - label = "Item " + std::to_string(item.itemId); - } - if (item.count > 1) { - label += " x" + std::to_string(item.count); - } - if (ImGui::Selectable(label.c_str(), selected, 0, ImVec2(0, 24))) { - selectedChoice = static_cast(i); - } - if (ImGui::IsItemHovered() && iconTex) { - ImGui::SetTooltip("Reward option"); - } - if (iconTex) { - ImGui::SameLine(); - ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); - } ImGui::PopID(); } } @@ -6692,10 +16523,30 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "You will also receive:"); for (const auto& item : quest.fixedRewards) { auto* info = gameHandler.getItemInfo(item.itemId); - if (info && info->valid) - ImGui::Text(" %s x%u", info->name.c_str(), item.count); - else - ImGui::Text(" Item %u x%u", item.itemId, item.count); + auto [iconTex, qualityColor] = resolveRewardItemVis(item); + + std::string label; + if (info && info->valid && !info->name.empty()) label = info->name; + else label = "Item " + std::to_string(item.itemId); + if (item.count > 1) label += " x" + std::to_string(item.count); + + if (iconTex) { + ImGui::Image((void*)(intptr_t)iconTex, ImVec2(18, 18)); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + ImGui::SameLine(); + } + ImGui::TextColored(qualityColor, " %s", label.c_str()); + if (ImGui::IsItemHovered()) rewardItemTooltip(item, qualityColor); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } } @@ -6710,9 +16561,8 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { uint32_t g = quest.rewardMoney / 10000; uint32_t s = (quest.rewardMoney % 10000) / 100; uint32_t c = quest.rewardMoney % 100; - if (g > 0) ImGui::Text(" %ug %us %uc", g, s, c); - else if (s > 0) ImGui::Text(" %us %uc", s, c); - else ImGui::Text(" %uc", c); + ImGui::TextDisabled(" Money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } } @@ -6750,6 +16600,61 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// ItemExtendedCost.dbc loader +// ============================================================ + +void GameScreen::loadExtendedCostDBC() { + if (extendedCostDbLoaded_) return; + extendedCostDbLoaded_ = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + auto dbc = am->loadDBC("ItemExtendedCost.dbc"); + if (!dbc || !dbc->isLoaded()) return; + // WotLK ItemExtendedCost.dbc: field 0=ID, 1=honorPoints, 2=arenaPoints, + // 3=arenaSlotRestrictions, 4-8=itemId[5], 9-13=itemCount[5], 14=reqRating, 15=purchaseGroup + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + if (id == 0) continue; + ExtendedCostEntry e; + e.honorPoints = dbc->getUInt32(i, 1); + e.arenaPoints = dbc->getUInt32(i, 2); + for (int j = 0; j < 5; ++j) { + e.itemId[j] = dbc->getUInt32(i, 4 + j); + e.itemCount[j] = dbc->getUInt32(i, 9 + j); + } + extendedCostCache_[id] = e; + } + LOG_INFO("ItemExtendedCost.dbc: loaded ", extendedCostCache_.size(), " entries"); +} + +std::string GameScreen::formatExtendedCost(uint32_t extendedCostId, game::GameHandler& gameHandler) { + loadExtendedCostDBC(); + auto it = extendedCostCache_.find(extendedCostId); + if (it == extendedCostCache_.end()) return "[Tokens]"; + const auto& e = it->second; + std::string result; + if (e.honorPoints > 0) { + result += std::to_string(e.honorPoints) + " Honor"; + } + if (e.arenaPoints > 0) { + if (!result.empty()) result += ", "; + result += std::to_string(e.arenaPoints) + " Arena"; + } + for (int j = 0; j < 5; ++j) { + if (e.itemId[j] == 0 || e.itemCount[j] == 0) continue; + if (!result.empty()) result += ", "; + gameHandler.ensureItemInfo(e.itemId[j]); // query if not cached + const auto* itemInfo = gameHandler.getItemInfo(e.itemId[j]); + if (itemInfo && itemInfo->valid && !itemInfo->name.empty()) { + result += std::to_string(e.itemCount[j]) + "x " + itemInfo->name; + } else { + result += std::to_string(e.itemCount[j]) + "x Item#" + std::to_string(e.itemId[j]); + } + } + return result.empty() ? "[Tokens]" : result; +} + // ============================================================ // Vendor Window (Phase 5) // ============================================================ @@ -6772,56 +16677,147 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); + + if (vendor.canRepair) { + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 8.0f); + if (ImGui::SmallButton("Repair All")) { + gameHandler.repairAll(vendor.vendorGuid, false); + } + if (ImGui::IsItemHovered()) { + // Show durability summary of all equipment + const auto& inv = gameHandler.getInventory(); + int damagedCount = 0; + int brokenCount = 0; + for (int s = 0; s < static_cast(game::EquipSlot::BAG1); s++) { + const auto& slot = inv.getEquipSlot(static_cast(s)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) brokenCount++; + else if (slot.item.curDurability < slot.item.maxDurability) damagedCount++; + } + if (brokenCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d damaged, %d broken", damagedCount, brokenCount); + else if (damagedCount > 0) + ImGui::SetTooltip("Repair all equipped items\n%d item%s need repair", damagedCount, damagedCount > 1 ? "s" : ""); + else + ImGui::SetTooltip("All equipment is in good condition"); + } + if (gameHandler.isInGuild()) { + ImGui::SameLine(); + if (ImGui::SmallButton("Repair (Guild)")) { + gameHandler.repairAll(vendor.vendorGuid, true); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Repair all equipped items using guild bank funds"); + } + } + } ImGui::Separator(); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Right-click bag items to sell"); + + // Count grey (POOR quality) sellable items across backpack and bags + const auto& inv = gameHandler.getInventory(); + int junkCount = 0; + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& sl = inv.getBackpackSlot(i); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + ++junkCount; + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < inv.getBagSize(b); ++s) { + const auto& sl = inv.getBagSlot(b, s); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + ++junkCount; + } + } + if (junkCount > 0) { + char junkLabel[64]; + snprintf(junkLabel, sizeof(junkLabel), "Sell All Junk (%d item%s)", + junkCount, junkCount == 1 ? "" : "s"); + if (ImGui::Button(junkLabel, ImVec2(-1, 0))) { + for (int i = 0; i < inv.getBackpackSize(); ++i) { + const auto& sl = inv.getBackpackSlot(i); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + gameHandler.sellItemBySlot(i); + } + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS; ++b) { + for (int s = 0; s < inv.getBagSize(b); ++s) { + const auto& sl = inv.getBagSlot(b, s); + if (!sl.empty() && sl.item.quality == game::ItemQuality::POOR && sl.item.sellPrice > 0) + gameHandler.sellItemInBag(b, s); + } + } + } + } ImGui::Separator(); const auto& buyback = gameHandler.getBuybackItems(); if (!buyback.empty()) { ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Buy Back"); - if (ImGui::BeginTable("BuybackTable", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + if (ImGui::BeginTable("BuybackTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 110.0f); ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 62.0f); ImGui::TableHeadersRow(); - // Show only the most recently sold item (LIFO). - const int i = 0; - const auto& entry = buyback[0]; - uint32_t sellPrice = entry.item.sellPrice; - if (sellPrice == 0) { - if (auto* info = gameHandler.getItemInfo(entry.item.itemId); info && info->valid) { - sellPrice = info->sellPrice; + // Show all buyback items (most recently sold first) + for (int i = 0; i < static_cast(buyback.size()); ++i) { + const auto& entry = buyback[i]; + gameHandler.ensureItemInfo(entry.item.itemId); + auto* bbInfo = gameHandler.getItemInfo(entry.item.itemId); + uint32_t sellPrice = entry.item.sellPrice; + if (sellPrice == 0) { + if (bbInfo && bbInfo->valid) sellPrice = bbInfo->sellPrice; } - } - uint64_t price = static_cast(sellPrice) * - static_cast(entry.count > 0 ? entry.count : 1); - uint32_t g = static_cast(price / 10000); - uint32_t s = static_cast((price / 100) % 100); - uint32_t c = static_cast(price % 100); - bool canAfford = money >= price; + uint64_t price = static_cast(sellPrice) * + static_cast(entry.count > 0 ? entry.count : 1); + uint32_t g = static_cast(price / 10000); + uint32_t s = static_cast((price / 100) % 100); + uint32_t c = static_cast(price % 100); + bool canAfford = money >= price; - ImGui::TableNextRow(); - ImGui::PushID(8000 + i); - ImGui::TableSetColumnIndex(0); - const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); - if (entry.count > 1) { - ImGui::Text("%s x%u", name, entry.count); - } else { - ImGui::Text("%s", name); + ImGui::TableNextRow(); + ImGui::PushID(8000 + i); + ImGui::TableSetColumnIndex(0); + { + uint32_t dispId = entry.item.displayInfoId; + if (bbInfo && bbInfo->valid && bbInfo->displayInfoId != 0) dispId = bbInfo->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } + } + ImGui::TableSetColumnIndex(1); + game::ItemQuality bbQuality = entry.item.quality; + if (bbInfo && bbInfo->valid) bbQuality = static_cast(bbInfo->quality); + ImVec4 bbQc = InventoryScreen::getQualityColor(bbQuality); + const char* name = entry.item.name.empty() ? "Unknown Item" : entry.item.name.c_str(); + if (entry.count > 1) { + ImGui::TextColored(bbQc, "%s x%u", name, entry.count); + } else { + ImGui::TextColored(bbQc, "%s", name); + } + if (ImGui::IsItemHovered() && bbInfo && bbInfo->valid) + inventoryScreen.renderItemTooltip(*bbInfo); + ImGui::TableSetColumnIndex(2); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } + ImGui::TableSetColumnIndex(3); + if (!canAfford) ImGui::BeginDisabled(); + char bbLabel[32]; + snprintf(bbLabel, sizeof(bbLabel), "Buy Back##bb%d", i); + if (ImGui::SmallButton(bbLabel)) { + gameHandler.buyBackItem(static_cast(i)); + } + if (!canAfford) ImGui::EndDisabled(); + ImGui::PopID(); } - ImGui::TableSetColumnIndex(1); - if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); - ImGui::Text("%ug %us %uc", g, s, c); - if (!canAfford) ImGui::PopStyleColor(); - ImGui::TableSetColumnIndex(2); - if (!canAfford) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Buy Back##buyback_0")) { - gameHandler.buyBackItem(0); - } - if (!canAfford) ImGui::EndDisabled(); - ImGui::PopID(); ImGui::EndTable(); } ImGui::Separator(); @@ -6830,84 +16826,142 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (vendor.items.empty()) { ImGui::TextDisabled("This vendor has nothing for sale."); } else { - if (ImGui::BeginTable("VendorTable", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + // Search + quantity controls on one row + ImGui::SetNextItemWidth(200.0f); + ImGui::InputTextWithHint("##VendorSearch", "Search...", vendorSearchFilter_, sizeof(vendorSearchFilter_)); + ImGui::SameLine(); + ImGui::Text("Qty:"); + ImGui::SameLine(); + ImGui::SetNextItemWidth(60.0f); + static int vendorBuyQty = 1; + ImGui::InputInt("##VendorQty", &vendorBuyQty, 1, 5); + if (vendorBuyQty < 1) vendorBuyQty = 1; + if (vendorBuyQty > 99) vendorBuyQty = 99; + ImGui::Spacing(); + + if (ImGui::BeginTable("VendorTable", 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Item", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Price", ImGuiTableColumnFlags_WidthFixed, 120.0f); ImGui::TableSetupColumn("Stock", ImGuiTableColumnFlags_WidthFixed, 60.0f); ImGui::TableSetupColumn("Buy", ImGuiTableColumnFlags_WidthFixed, 50.0f); ImGui::TableHeadersRow(); - // Quality colors (matching WoW) - static const ImVec4 qualityColors[] = { - ImVec4(0.6f, 0.6f, 0.6f, 1.0f), // 0 Poor (gray) - ImVec4(1.0f, 1.0f, 1.0f, 1.0f), // 1 Common (white) - ImVec4(0.12f, 1.0f, 0.0f, 1.0f), // 2 Uncommon (green) - ImVec4(0.0f, 0.44f, 0.87f, 1.0f), // 3 Rare (blue) - ImVec4(0.64f, 0.21f, 0.93f, 1.0f),// 4 Epic (purple) - ImVec4(1.0f, 0.5f, 0.0f, 1.0f), // 5 Legendary (orange) - }; + std::string vendorFilter(vendorSearchFilter_); + // Lowercase filter for case-insensitive match + for (char& c : vendorFilter) c = static_cast(std::tolower(static_cast(c))); for (int vi = 0; vi < static_cast(vendor.items.size()); ++vi) { const auto& item = vendor.items[vi]; + + // Proactively ensure vendor item info is loaded + gameHandler.ensureItemInfo(item.itemId); + auto* info = gameHandler.getItemInfo(item.itemId); + + // Apply search filter + if (!vendorFilter.empty()) { + std::string nameLC = info && info->valid ? info->name : ("Item " + std::to_string(item.itemId)); + for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); + if (nameLC.find(vendorFilter) == std::string::npos) { + ImGui::PushID(vi); + ImGui::PopID(); + continue; + } + } + ImGui::TableNextRow(); ImGui::PushID(vi); + // Icon column ImGui::TableSetColumnIndex(0); - auto* info = gameHandler.getItemInfo(item.itemId); + { + uint32_t dispId = item.displayInfoId; + if (info && info->valid && info->displayInfoId != 0) dispId = info->displayInfoId; + if (dispId != 0) { + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(dispId); + if (iconTex) ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(18, 18)); + } + } + + // Name column + ImGui::TableSetColumnIndex(1); if (info && info->valid) { - uint32_t q = info->quality < 6 ? info->quality : 1; - ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); - // Tooltip with stats on hover + ImVec4 qc = InventoryScreen::getQualityColor(static_cast(info->quality)); + ImGui::TextColored(qc, "%s", info->name.c_str()); if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); - if (info->damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", info->damageMin, info->damageMax); - if (info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("Speed %.2f", speed); - ImGui::Text("%.1f damage per second", dps); - } + inventoryScreen.renderItemTooltip(*info, &gameHandler.getInventory()); + } + // Shift-click: insert item link into chat + if (ImGui::IsItemClicked() && ImGui::GetIO().KeyShift) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; } - if (info->armor > 0) ImGui::Text("Armor: %d", info->armor); - if (info->stamina > 0) ImGui::Text("+%d Stamina", info->stamina); - if (info->strength > 0) ImGui::Text("+%d Strength", info->strength); - if (info->agility > 0) ImGui::Text("+%d Agility", info->agility); - if (info->intellect > 0) ImGui::Text("+%d Intellect", info->intellect); - if (info->spirit > 0) ImGui::Text("+%d Spirit", info->spirit); - ImGui::EndTooltip(); } } else { ImGui::Text("Item %u", item.itemId); } - ImGui::TableSetColumnIndex(1); + ImGui::TableSetColumnIndex(2); if (item.buyPrice == 0 && item.extendedCost != 0) { - // Token-only item (no gold cost) - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "[Tokens]"); + // Token-only item — show detailed cost from ItemExtendedCost.dbc + std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", costStr.c_str()); } else { uint32_t g = item.buyPrice / 10000; uint32_t s = (item.buyPrice / 100) % 100; uint32_t c = item.buyPrice % 100; bool canAfford = money >= item.buyPrice; - if (!canAfford) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); - ImGui::Text("%ug %us %uc", g, s, c); - if (!canAfford) ImGui::PopStyleColor(); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } + // Show additional token cost if both gold and tokens are required + if (item.extendedCost != 0) { + std::string costStr = formatExtendedCost(item.extendedCost, gameHandler); + if (costStr != "[Tokens]") { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 0.8f), "+ %s", costStr.c_str()); + } + } } - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); if (item.maxCount < 0) { - ImGui::Text("Inf"); + ImGui::TextDisabled("Inf"); + } else if (item.maxCount == 0) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Out"); + } else if (item.maxCount <= 5) { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), "%d", item.maxCount); } else { ImGui::Text("%d", item.maxCount); } - ImGui::TableSetColumnIndex(3); + ImGui::TableSetColumnIndex(4); + bool outOfStock = (item.maxCount == 0); + if (outOfStock) ImGui::BeginDisabled(); std::string buyBtnId = "Buy##vendor_" + std::to_string(vi); if (ImGui::SmallButton(buyBtnId.c_str())) { - gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, 1); + int qty = vendorBuyQty; + if (item.maxCount > 0 && qty > item.maxCount) qty = item.maxCount; + uint32_t totalCost = item.buyPrice * static_cast(qty); + if (totalCost >= 10000) { // >= 1 gold: confirm + vendorConfirmOpen_ = true; + vendorConfirmGuid_ = vendor.vendorGuid; + vendorConfirmItemId_ = item.itemId; + vendorConfirmSlot_ = item.slot; + vendorConfirmQty_ = static_cast(qty); + vendorConfirmPrice_ = totalCost; + vendorConfirmItemName_ = (info && info->valid) ? info->name : "Item"; + } else { + gameHandler.buyItem(vendor.vendorGuid, item.itemId, item.slot, + static_cast(qty)); + } } + if (outOfStock) ImGui::EndDisabled(); ImGui::PopID(); } @@ -6921,6 +16975,33 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { if (!open) { gameHandler.closeVendor(); } + + // Vendor purchase confirmation popup for expensive items + if (vendorConfirmOpen_) { + ImGui::OpenPopup("Confirm Purchase##vendor"); + vendorConfirmOpen_ = false; + } + if (ImGui::BeginPopupModal("Confirm Purchase##vendor", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + ImGui::Text("Buy %s", vendorConfirmItemName_.c_str()); + if (vendorConfirmQty_ > 1) + ImGui::Text("Quantity: %u", vendorConfirmQty_); + uint32_t g = vendorConfirmPrice_ / 10000; + uint32_t s = (vendorConfirmPrice_ / 100) % 100; + uint32_t c = vendorConfirmPrice_ % 100; + ImGui::Text("Cost: %ug %us %uc", g, s, c); + ImGui::Spacing(); + if (ImGui::Button("Buy", ImVec2(80, 0))) { + gameHandler.buyItem(vendorConfirmGuid_, vendorConfirmItemId_, + vendorConfirmSlot_, vendorConfirmQty_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } // ============================================================ @@ -6932,13 +17013,22 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + auto* assetMgr = core::Application::getInstance().getAssetManager(); ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 225, 100), ImGuiCond_Appearing); ImGui::SetNextWindowSize(ImVec2(500, 450), ImGuiCond_Appearing); bool open = true; if (ImGui::Begin("Trainer", &open)) { + // If user clicked window close, short-circuit before rendering large trainer tables. + if (!open) { + ImGui::End(); + gameHandler.closeTrainer(); + return; + } + const auto& trainer = gameHandler.getTrainerSpells(); + const bool isProfessionTrainer = (trainer.trainerType == 2); // NPC name auto npcEntity = gameHandler.getEntityManager().getEntity(trainer.trainerGuid); @@ -6960,11 +17050,15 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); - // Filter checkbox + // Filter controls static bool showUnavailable = false; ImGui::Checkbox("Show unavailable spells", &showUnavailable); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##TrainerSearch", "Search...", trainerSearchFilter_, sizeof(trainerSearchFilter_)); ImGui::Separator(); if (trainer.spells.empty()) { @@ -7014,6 +17108,20 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { continue; } + // Apply text search filter + if (trainerSearchFilter_[0] != '\0') { + std::string trainerFilter(trainerSearchFilter_); + for (char& c : trainerFilter) c = static_cast(std::tolower(static_cast(c))); + const std::string& spellName = gameHandler.getSpellName(spell->spellId); + std::string nameLC = spellName.empty() ? std::to_string(spell->spellId) : spellName; + for (char& c : nameLC) c = static_cast(std::tolower(static_cast(c))); + if (nameLC.find(trainerFilter) == std::string::npos) { + ImGui::PushID(static_cast(spell->spellId)); + ImGui::PopID(); + continue; + } + } + ImGui::TableNextRow(); ImGui::PushID(static_cast(spell->spellId)); @@ -7031,8 +17139,23 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { statusLabel = "Unavailable"; } - // Spell name + // Icon column ImGui::TableSetColumnIndex(0); + { + VkDescriptorSet spellIcon = getSpellIcon(spell->spellId, assetMgr); + if (spellIcon) { + if (effectiveState == 1 && !alreadyKnown) { + ImGui::ImageWithBg((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18), + ImVec2(0, 0), ImVec2(1, 1), + ImVec4(0, 0, 0, 0), ImVec4(0.5f, 0.5f, 0.5f, 0.6f)); + } else { + ImGui::Image((ImTextureID)(uintptr_t)spellIcon, ImVec2(18, 18)); + } + } + } + + // Spell name + ImGui::TableSetColumnIndex(1); const std::string& name = gameHandler.getSpellName(spell->spellId); const std::string& rank = gameHandler.getSpellRank(spell->spellId); if (!name.empty()) { @@ -7047,10 +17170,18 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { if (ImGui::IsItemHovered()) { ImGui::BeginTooltip(); if (!name.empty()) { - ImGui::Text("%s", name.c_str()); - if (!rank.empty()) ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", rank.c_str()); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", name.c_str()); + if (!rank.empty()) ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "%s", rank.c_str()); } - ImGui::Text("Status: %s", statusLabel); + const std::string& spDesc = gameHandler.getSpellDescription(spell->spellId); + if (!spDesc.empty()) { + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 300.0f); + ImGui::TextWrapped("%s", spDesc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + ImGui::TextDisabled("Status: %s", statusLabel); if (spell->reqLevel > 0) { ImVec4 lvlColor = levelMet ? ImVec4(0.7f, 0.7f, 0.7f, 1.0f) : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); ImGui::TextColored(lvlColor, "Required Level: %u", spell->reqLevel); @@ -7073,24 +17204,27 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } // Level - ImGui::TableSetColumnIndex(1); + ImGui::TableSetColumnIndex(2); ImGui::TextColored(color, "%u", spell->reqLevel); // Cost - ImGui::TableSetColumnIndex(2); + ImGui::TableSetColumnIndex(3); if (spell->spellCost > 0) { uint32_t g = spell->spellCost / 10000; uint32_t s = (spell->spellCost / 100) % 100; uint32_t c = spell->spellCost % 100; bool canAfford = money >= spell->spellCost; - ImVec4 costColor = canAfford ? color : ImVec4(1.0f, 0.3f, 0.3f, 1.0f); - ImGui::TextColored(costColor, "%ug %us %uc", g, s, c); + if (canAfford) { + renderCoinsText(g, s, c); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%ug %us %uc", g, s, c); + } } else { ImGui::TextColored(color, "Free"); } // Train button - only enabled if available, affordable, prereqs met - ImGui::TableSetColumnIndex(3); + ImGui::TableSetColumnIndex(4); // Use effectiveState so newly available spells (after learning prereqs) can be trained bool canTrain = !alreadyKnown && effectiveState == 0 && prereqsMet && levelMet @@ -7115,19 +17249,30 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { logCount++; } - if (!canTrain) ImGui::BeginDisabled(); - if (ImGui::SmallButton("Train")) { - gameHandler.trainSpell(spell->spellId); + if (isProfessionTrainer && alreadyKnown) { + // Profession trainer: known recipes show "Create" button to craft + bool isCasting = gameHandler.isCasting(); + if (isCasting) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Create")) { + gameHandler.castSpell(spell->spellId, 0); + } + if (isCasting) ImGui::EndDisabled(); + } else { + if (!canTrain) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Train")) { + gameHandler.trainSpell(spell->spellId); + } + if (!canTrain) ImGui::EndDisabled(); } - if (!canTrain) ImGui::EndDisabled(); ImGui::PopID(); } }; auto renderSpellTable = [&](const char* tableId, const std::vector& spells) { - if (ImGui::BeginTable(tableId, 4, + if (ImGui::BeginTable(tableId, 5, ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("##icon", ImGuiTableColumnFlags_WidthFixed, 22.0f); ImGui::TableSetupColumn("Spell", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); ImGui::TableSetupColumn("Cost", ImGuiTableColumnFlags_WidthFixed, 120.0f); @@ -7165,6 +17310,136 @@ void GameScreen::renderTrainerWindow(game::GameHandler& gameHandler) { } renderSpellTable("TrainerTable", allSpells); } + + // Count how many spells are trainable right now + int trainableCount = 0; + uint64_t totalCost = 0; + for (const auto& spell : trainer.spells) { + bool prereq1Met = isKnown(spell.chainNode1); + bool prereq2Met = isKnown(spell.chainNode2); + bool prereq3Met = isKnown(spell.chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); + bool alreadyKnown = isKnown(spell.spellId); + uint8_t effectiveState = spell.state; + if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell.spellCost); + if (canTrain) { + ++trainableCount; + totalCost += spell.spellCost; + } + } + + ImGui::Separator(); + bool canAffordAll = (money >= totalCost); + bool hasTrainable = (trainableCount > 0) && canAffordAll; + if (!hasTrainable) ImGui::BeginDisabled(); + uint32_t tag = static_cast(totalCost / 10000); + uint32_t tas = static_cast((totalCost / 100) % 100); + uint32_t tac = static_cast(totalCost % 100); + char trainAllLabel[80]; + if (trainableCount == 0) { + snprintf(trainAllLabel, sizeof(trainAllLabel), "Train All Available (none)"); + } else { + snprintf(trainAllLabel, sizeof(trainAllLabel), + "Train All Available (%d spell%s, %ug %us %uc)", + trainableCount, trainableCount == 1 ? "" : "s", + tag, tas, tac); + } + if (ImGui::Button(trainAllLabel, ImVec2(-1.0f, 0.0f))) { + for (const auto& spell : trainer.spells) { + bool prereq1Met = isKnown(spell.chainNode1); + bool prereq2Met = isKnown(spell.chainNode2); + bool prereq3Met = isKnown(spell.chainNode3); + bool prereqsMet = prereq1Met && prereq2Met && prereq3Met; + bool levelMet = (spell.reqLevel == 0 || playerLevel >= spell.reqLevel); + bool alreadyKnown = isKnown(spell.spellId); + uint8_t effectiveState = spell.state; + if (spell.state == 1 && prereqsMet && levelMet) effectiveState = 0; + bool canTrain = !alreadyKnown && effectiveState == 0 + && prereqsMet && levelMet + && (money >= spell.spellCost); + if (canTrain) { + gameHandler.trainSpell(spell.spellId); + } + } + } + if (!hasTrainable) ImGui::EndDisabled(); + + // Profession trainer: craft quantity controls + if (isProfessionTrainer) { + ImGui::Separator(); + static int craftQuantity = 1; + static uint32_t selectedCraftSpell = 0; + + // Show craft queue status if active + int queueRemaining = gameHandler.getCraftQueueRemaining(); + if (queueRemaining > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), + "Crafting... %d remaining", queueRemaining); + ImGui::SameLine(); + if (ImGui::SmallButton("Stop")) { + gameHandler.cancelCraftQueue(); + gameHandler.cancelCast(); + } + } else { + // Spell selector + quantity input + // Build list of known (craftable) spells + std::vector craftable; + for (const auto& spell : trainer.spells) { + if (isKnown(spell.spellId)) { + craftable.push_back(&spell); + } + } + if (!craftable.empty()) { + // Combo box for recipe selection + const char* previewName = "Select recipe..."; + for (const auto* sp : craftable) { + if (sp->spellId == selectedCraftSpell) { + const std::string& n = gameHandler.getSpellName(sp->spellId); + if (!n.empty()) previewName = n.c_str(); + break; + } + } + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x * 0.55f); + if (ImGui::BeginCombo("##CraftSelect", previewName)) { + for (const auto* sp : craftable) { + const std::string& n = gameHandler.getSpellName(sp->spellId); + const std::string& r = gameHandler.getSpellRank(sp->spellId); + char label[128]; + if (!r.empty()) + snprintf(label, sizeof(label), "%s (%s)##%u", + n.empty() ? "???" : n.c_str(), r.c_str(), sp->spellId); + else + snprintf(label, sizeof(label), "%s##%u", + n.empty() ? "???" : n.c_str(), sp->spellId); + if (ImGui::Selectable(label, sp->spellId == selectedCraftSpell)) { + selectedCraftSpell = sp->spellId; + } + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::SetNextItemWidth(50.0f); + ImGui::InputInt("##CraftQty", &craftQuantity, 0, 0); + if (craftQuantity < 1) craftQuantity = 1; + if (craftQuantity > 99) craftQuantity = 99; + ImGui::SameLine(); + bool canCraft = selectedCraftSpell != 0 && !gameHandler.isCasting(); + if (!canCraft) ImGui::BeginDisabled(); + if (ImGui::Button("Create")) { + if (craftQuantity == 1) { + gameHandler.castSpell(selectedCraftSpell, 0); + } else { + gameHandler.startCraftQueue(selectedCraftSpell, craftQuantity); + } + } + if (!canCraft) ImGui::EndDisabled(); + } + } + } } } ImGui::End(); @@ -7188,7 +17463,7 @@ void GameScreen::renderEscapeMenu() { ImGuiIO& io = ImGui::GetIO(); float screenW = io.DisplaySize.x; float screenH = io.DisplaySize.y; - ImVec2 size(260.0f, 220.0f); + ImVec2 size(260.0f, 248.0f); ImVec2 pos((screenW - size.x) * 0.5f, (screenH - size.y) * 0.5f); ImGui::SetNextWindowPos(pos, ImGuiCond_Always); @@ -7224,6 +17499,10 @@ void GameScreen::renderEscapeMenu() { showInstanceLockouts_ = true; showEscapeMenu = false; } + if (ImGui::Button("Help / GM Ticket", ImVec2(-1, 0))) { + showGmTicketWindow_ = true; + showEscapeMenu = false; + } ImGui::Spacing(); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(10.0f, 10.0f)); @@ -7236,6 +17515,236 @@ void GameScreen::renderEscapeMenu() { ImGui::End(); } +// ============================================================ +// Barber Shop Window +// ============================================================ + +void GameScreen::renderBarberShopWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isBarberShopOpen()) { + barberInitialized_ = false; + return; + } + + const auto* ch = gameHandler.getActiveCharacter(); + if (!ch) return; + + uint8_t race = static_cast(ch->race); + game::Gender gender = ch->gender; + game::Race raceEnum = ch->race; + + // Initialize sliders from current appearance + if (!barberInitialized_) { + barberOrigHairStyle_ = static_cast((ch->appearanceBytes >> 16) & 0xFF); + barberOrigHairColor_ = static_cast((ch->appearanceBytes >> 24) & 0xFF); + barberOrigFacialHair_ = static_cast(ch->facialFeatures); + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + barberInitialized_ = true; + } + + int maxHairStyle = static_cast(game::getMaxHairStyle(raceEnum, gender)); + int maxHairColor = static_cast(game::getMaxHairColor(raceEnum, gender)); + int maxFacialHair = static_cast(game::getMaxFacialFeature(raceEnum, gender)); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float winW = 300.0f; + float winH = 220.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - winW) / 2.0f, (screenH - winH) / 2.0f), ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Appearing); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse; + bool open = true; + if (ImGui::Begin("Barber Shop", &open, flags)) { + ImGui::Text("Choose your new look:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushItemWidth(-1); + + // Hair Style + ImGui::Text("Hair Style"); + ImGui::SliderInt("##HairStyle", &barberHairStyle_, 0, maxHairStyle, + "%d"); + + // Hair Color + ImGui::Text("Hair Color"); + ImGui::SliderInt("##HairColor", &barberHairColor_, 0, maxHairColor, + "%d"); + + // Facial Hair / Piercings / Markings + const char* facialLabel = (gender == game::Gender::FEMALE) ? "Piercings" : "Facial Hair"; + // Some races use "Markings" or "Tusks" etc. + if (race == 8 || race == 6) facialLabel = "Features"; // Trolls, Tauren + ImGui::Text("%s", facialLabel); + ImGui::SliderInt("##FacialHair", &barberFacialHair_, 0, maxFacialHair, + "%d"); + + ImGui::PopItemWidth(); + + ImGui::Spacing(); + ImGui::Separator(); + + // Show whether anything changed + bool changed = (barberHairStyle_ != barberOrigHairStyle_ || + barberHairColor_ != barberOrigHairColor_ || + barberFacialHair_ != barberOrigFacialHair_); + + // OK / Reset / Cancel buttons + float btnW = 80.0f; + float totalW = btnW * 3 + ImGui::GetStyle().ItemSpacing.x * 2; + ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) / 2.0f); + + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("OK", ImVec2(btnW, 0))) { + gameHandler.sendAlterAppearance( + static_cast(barberHairStyle_), + static_cast(barberHairColor_), + static_cast(barberFacialHair_)); + // Keep window open — server will respond with SMSG_BARBER_SHOP_RESULT + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!changed) ImGui::BeginDisabled(); + if (ImGui::Button("Reset", ImVec2(btnW, 0))) { + barberHairStyle_ = barberOrigHairStyle_; + barberHairColor_ = barberOrigHairColor_; + barberFacialHair_ = barberOrigFacialHair_; + } + if (!changed) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.closeBarberShop(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.closeBarberShop(); + } +} + +// ============================================================ +// Pet Stable Window +// ============================================================ + +void GameScreen::renderStableWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isStableWindowOpen()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), + ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (!ImGui::Begin("Pet Stable", &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + ImGui::End(); + if (!open) { + // User closed the window; clear stable state + gameHandler.closeStableWindow(); + } + return; + } + + const auto& pets = gameHandler.getStabledPets(); + uint8_t numSlots = gameHandler.getStableSlots(); + + ImGui::TextDisabled("Stable slots: %u", static_cast(numSlots)); + ImGui::Separator(); + + // Active pets section + bool hasActivePets = false; + for (const auto& p : pets) { + if (p.isActive) { hasActivePets = true; break; } + } + + if (hasActivePets) { + ImGui::TextColored(ImVec4(0.4f, 0.9f, 0.4f, 1.0f), "Active / Summoned"); + for (const auto& p : pets) { + if (!p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber) * -1 - 1); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u)", displayName.c_str(), p.level); + ImGui::SameLine(); + ImGui::TextDisabled("[Active]"); + + // Offer to stable the active pet if there are free slots + uint8_t usedSlots = 0; + for (const auto& sp : pets) { if (!sp.isActive) ++usedSlots; } + if (usedSlots < numSlots) { + ImGui::SameLine(); + if (ImGui::SmallButton("Store in stable")) { + // Slot 1 is first stable slot; server handles free slot assignment. + gameHandler.stablePet(1); + } + } + ImGui::PopID(); + } + ImGui::Separator(); + } + + // Stabled pets section + ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.4f, 1.0f), "Stabled Pets"); + + bool hasStabledPets = false; + for (const auto& p : pets) { + if (!p.isActive) { hasStabledPets = true; break; } + } + + if (!hasStabledPets) { + ImGui::TextDisabled(" (No pets in stable)"); + } else { + for (const auto& p : pets) { + if (p.isActive) continue; + ImGui::PushID(static_cast(p.petNumber)); + + const std::string displayName = p.name.empty() + ? ("Pet #" + std::to_string(p.petNumber)) + : p.name; + ImGui::Text(" %s (Level %u, Entry %u)", + displayName.c_str(), p.level, p.entry); + ImGui::SameLine(); + if (ImGui::SmallButton("Retrieve")) { + gameHandler.unstablePet(p.petNumber); + } + ImGui::PopID(); + } + } + + // Empty slots + uint8_t usedStableSlots = 0; + for (const auto& p : pets) { if (!p.isActive) ++usedStableSlots; } + if (usedStableSlots < numSlots) { + ImGui::TextDisabled(" %u empty slot(s) available", + static_cast(numSlots - usedStableSlots)); + } + + ImGui::Separator(); + if (ImGui::Button("Refresh")) { + gameHandler.requestStabledPetList(); + } + ImGui::SameLine(); + if (ImGui::Button("Close")) { + gameHandler.closeStableWindow(); + } + + ImGui::End(); + if (!open) { + gameHandler.closeStableWindow(); + } +} + // ============================================================ // Taxi Window // ============================================================ @@ -7302,13 +17811,7 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { } ImGui::TableSetColumnIndex(1); - if (gold > 0) { - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper); - } else if (silver > 0) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), "%us %uc", silver, copper); - } else { - ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.2f, 1.0f), "%uc", copper); - } + renderCoinsText(gold, silver, copper); ImGui::TableSetColumnIndex(2); if (ImGui::SmallButton("Fly")) { @@ -7344,12 +17847,85 @@ void GameScreen::renderTaxiWindow(game::GameHandler& gameHandler) { } } +// ============================================================ +// Logout Countdown +// ============================================================ + +void GameScreen::renderLogoutCountdown(game::GameHandler& gameHandler) { + if (!gameHandler.isLoggingOut()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + constexpr float W = 280.0f; + constexpr float H = 80.0f; + ImGui::SetNextWindowPos(ImVec2((screenW - W) * 0.5f, screenH * 0.5f - H * 0.5f - 60.0f), + ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(W, H), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.88f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.8f, 1.0f)); + + if (ImGui::Begin("##LogoutCountdown", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus)) { + + float cd = gameHandler.getLogoutCountdown(); + if (cd > 0.0f) { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 6.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out in 20s...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), + "Logging out in %ds...", static_cast(std::ceil(cd))); + + // Progress bar (20 second countdown) + float frac = 1.0f - std::min(cd / 20.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.5f, 0.5f, 0.9f, 1.0f)); + ImGui::ProgressBar(frac, ImVec2(-1.0f, 8.0f), ""); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } else { + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + 14.0f); + ImGui::SetCursorPosX((W - ImGui::CalcTextSize("Logging out...").x) * 0.5f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.3f, 1.0f), "Logging out..."); + ImGui::Spacing(); + } + + // Cancel button — only while countdown is still running + if (cd > 0.0f) { + float btnW = 100.0f; + ImGui::SetCursorPosX((W - btnW) * 0.5f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.15f, 0.15f, 1.0f)); + if (ImGui::Button("Cancel", ImVec2(btnW, 0))) { + gameHandler.cancelLogout(); + } + ImGui::PopStyleColor(2); + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Death Screen // ============================================================ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { - if (!gameHandler.showDeathDialog()) return; + if (!gameHandler.showDeathDialog()) { + deathTimerRunning_ = false; + deathElapsed_ = 0.0f; + return; + } + float dt = ImGui::GetIO().DeltaTime; + if (!deathTimerRunning_) { + deathElapsed_ = 0.0f; + deathTimerRunning_ = true; + } else { + deathElapsed_ += dt; + } auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; @@ -7366,8 +17942,10 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); // "Release Spirit" dialog centered on screen + const bool hasSelfRes = gameHandler.canSelfRes(); float dlgW = 280.0f; - float dlgH = 100.0f; + // Extra height when self-res button is available; +20 for the "wait for res" hint + float dlgH = hasSelfRes ? 190.0f : 150.0f; ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.35f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); @@ -7386,9 +17964,34 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::SetCursorPosX((dlgW - textW) / 2); ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "%s", deathText); + // Respawn timer: show how long until the server auto-releases the spirit + float timeLeft = kForcedReleaseSec - deathElapsed_; + if (timeLeft > 0.0f) { + int mins = static_cast(timeLeft) / 60; + int secs = static_cast(timeLeft) % 60; + char timerBuf[48]; + snprintf(timerBuf, sizeof(timerBuf), "Auto-release in %d:%02d", mins, secs); + float tw = ImGui::CalcTextSize(timerBuf).x; + ImGui::SetCursorPosX((dlgW - tw) / 2); + ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "%s", timerBuf); + } + ImGui::Spacing(); ImGui::Spacing(); + // Self-resurrection button (Reincarnation / Twisting Nether / Deathpact) + if (hasSelfRes) { + float btnW2 = 220.0f; + ImGui::SetCursorPosX((dlgW - btnW2) / 2); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.55f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.5f, 0.75f, 1.0f)); + if (ImGui::Button("Use Self-Resurrection", ImVec2(btnW2, 30))) { + gameHandler.useSelfRes(); + } + ImGui::PopStyleColor(2); + ImGui::Spacing(); + } + // Center the Release Spirit button float btnW = 180.0f; ImGui::SetCursorPosX((dlgW - btnW) / 2); @@ -7398,6 +18001,12 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { gameHandler.releaseSpirit(); } ImGui::PopStyleColor(2); + + // Hint: player can stay dead and wait for another player to cast Resurrection + const char* resHint = "Or wait for a player to resurrect you."; + float hw = ImGui::CalcTextSize(resHint).x; + ImGui::SetCursorPosX((dlgW - hw) / 2); + ImGui::TextColored(ImVec4(0.5f, 0.6f, 0.5f, 0.85f), "%s", resHint); } ImGui::End(); ImGui::PopStyleColor(2); @@ -7411,21 +18020,49 @@ void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float delaySec = gameHandler.getCorpseReclaimDelaySec(); + bool onDelay = (delaySec > 0.0f); + float btnW = 220.0f, btnH = 36.0f; + float winH = btnH + 16.0f + (onDelay ? 20.0f : 0.0f); ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, winH), ImGuiCond_Always); ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); if (ImGui::Begin("##ReclaimCorpse", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoBringToFrontOnFocus)) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); - if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { - gameHandler.reclaimCorpse(); + if (onDelay) { + // Greyed-out button while PvP reclaim timer ticks down + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.25f, 0.25f, 1.0f)); + ImGui::BeginDisabled(true); + char delayLabel[64]; + snprintf(delayLabel, sizeof(delayLabel), "Resurrect from Corpse (%.0fs)", delaySec); + ImGui::Button(delayLabel, ImVec2(btnW, btnH)); + ImGui::EndDisabled(); + ImGui::PopStyleColor(2); + const char* waitMsg = "You cannot reclaim your corpse yet."; + float tw = ImGui::CalcTextSize(waitMsg).x; + ImGui::SetCursorPosX((btnW + 16.0f - tw) * 0.5f); + ImGui::TextColored(ImVec4(0.8f, 0.5f, 0.2f, 1.0f), "%s", waitMsg); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); + if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { + gameHandler.reclaimCorpse(); + } + ImGui::PopStyleColor(2); + float corpDist = gameHandler.getCorpseDistance(); + if (corpDist >= 0.0f) { + char distBuf[48]; + snprintf(distBuf, sizeof(distBuf), "Corpse: %.0f yards away", corpDist); + float dw = ImGui::CalcTextSize(distBuf).x; + ImGui::SetCursorPosX((btnW + 16.0f - dw) * 0.5f); + ImGui::TextDisabled("%s", distBuf); + } } - ImGui::PopStyleColor(2); } ImGui::End(); ImGui::PopStyleColor(); @@ -7489,6 +18126,148 @@ void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Talent Wipe Confirm Dialog +// ============================================================ + +void GameScreen::renderTalentWipeConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showTalentWipeConfirmDialog()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##TalentWipeDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getTalentWipeCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = "Reset your talents for "; + text += costStr; + text += "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f)); + if (ImGui::Button("Confirm", ImVec2(btnW, 30))) { + gameHandler.confirmTalentWipe(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f)); + if (ImGui::Button("Cancel", ImVec2(btnW, 30))) { + gameHandler.cancelTalentWipe(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void GameScreen::renderPetUnlearnConfirmDialog(game::GameHandler& gameHandler) { + if (!gameHandler.showPetUnlearnDialog()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float dlgW = 340.0f; + float dlgH = 130.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - dlgW / 2, screenH * 0.3f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(dlgW, dlgH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.15f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.8f, 0.7f, 0.2f, 1.0f)); + + if (ImGui::Begin("##PetUnlearnDialog", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { + + ImGui::Spacing(); + uint32_t cost = gameHandler.getPetUnlearnCost(); + uint32_t gold = cost / 10000; + uint32_t silver = (cost % 10000) / 100; + uint32_t copper = cost % 100; + char costStr[64]; + if (gold > 0) + std::snprintf(costStr, sizeof(costStr), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + std::snprintf(costStr, sizeof(costStr), "%us %uc", silver, copper); + else + std::snprintf(costStr, sizeof(costStr), "%uc", copper); + + std::string text = std::string("Reset your pet's talents for ") + costStr + "?"; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.4f, 1.0f), "%s", text.c_str()); + + ImGui::Spacing(); + ImGui::SetCursorPosX(8.0f); + ImGui::TextDisabled("All pet talent points will be refunded."); + ImGui::Spacing(); + + float btnW = 110.0f; + float spacing = 20.0f; + ImGui::SetCursorPosX((dlgW - btnW * 2 - spacing) / 2); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.5f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.3f, 0.7f, 0.3f, 1.0f)); + if (ImGui::Button("Confirm##petunlearn", ImVec2(btnW, 30))) { + gameHandler.confirmPetUnlearn(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(0, spacing); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.2f, 0.2f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.3f, 0.3f, 1.0f)); + if (ImGui::Button("Cancel##petunlearn", ImVec2(btnW, 30))) { + gameHandler.cancelPetUnlearn(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + // ============================================================ // Settings Window // ============================================================ @@ -7552,6 +18331,7 @@ void GameScreen::renderSettingsWindow() { pendingMinimapRotate = minimapRotate_; pendingMinimapSquare = minimapSquare_; pendingMinimapNpcDots = minimapNpcDots_; + pendingShowLatencyMeter = showLatencyMeter_; if (renderer) { if (auto* minimap = renderer->getMinimap()) { minimap->setRotateWithCamera(minimapRotate_); @@ -7586,16 +18366,37 @@ void GameScreen::renderSettingsWindow() { if (ImGui::BeginTabItem("Video")) { ImGui::Spacing(); + // Graphics Quality Presets + { + const char* presetLabels[] = { "Custom", "Low", "Medium", "High", "Ultra" }; + int presetIdx = static_cast(pendingGraphicsPreset); + if (ImGui::Combo("Quality Preset", &presetIdx, presetLabels, 5)) { + pendingGraphicsPreset = static_cast(presetIdx); + if (pendingGraphicsPreset != GraphicsPreset::CUSTOM) { + applyGraphicsPreset(pendingGraphicsPreset); + saveSettings(); + } + } + ImGui::TextDisabled("Adjust these for custom settings"); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + if (ImGui::Checkbox("Fullscreen", &pendingFullscreen)) { window->setFullscreen(pendingFullscreen); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } if (ImGui::Checkbox("VSync", &pendingVsync)) { window->setVsync(pendingVsync); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } if (ImGui::Checkbox("Shadows", &pendingShadows)) { if (renderer) renderer->setShadowsEnabled(pendingShadows); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } if (pendingShadows) { @@ -7603,22 +18404,16 @@ void GameScreen::renderSettingsWindow() { ImGui::SetNextItemWidth(150.0f); if (ImGui::SliderFloat("Distance##shadow", &pendingShadowDistance, 40.0f, 500.0f, "%.0f")) { if (renderer) renderer->setShadowDistance(pendingShadowDistance); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } } { - bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled()); - if (!fsrActive && pendingWaterRefraction) { - // FSR was disabled while refraction was on — auto-disable - pendingWaterRefraction = false; - if (renderer) renderer->setWaterRefractionEnabled(false); - } - if (!fsrActive) ImGui::BeginDisabled(); - if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) { + if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } - if (!fsrActive) ImGui::EndDisabled(); } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; @@ -7634,8 +18429,23 @@ void GameScreen::renderSettingsWindow() { VK_SAMPLE_COUNT_4_BIT, VK_SAMPLE_COUNT_8_BIT }; if (renderer) renderer->setMsaaSamples(aaSamples[pendingAntiAliasing]); + updateGraphicsPresetFromCurrentSettings(); saveSettings(); } + // FXAA — post-process, combinable with MSAA or FSR3 + { + if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { + if (renderer) renderer->setFXAAEnabled(pendingFXAA); + updateGraphicsPresetFromCurrentSettings(); + saveSettings(); + } + if (ImGui::IsItemHovered()) { + if (fsr2Active) + ImGui::SetTooltip("FXAA applies spatial anti-aliasing after FSR3 upscaling.\nFSR3 + FXAA is the recommended ultra-quality combination."); + else + ImGui::SetTooltip("FXAA smooths jagged edges as a post-process pass.\nCan be combined with MSAA for extra quality."); + } + } } // FSR Upscaling { @@ -7793,6 +18603,16 @@ void GameScreen::renderSettingsWindow() { ImGui::Separator(); ImGui::Spacing(); + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderInt("Brightness", &pendingBrightness, 0, 100, "%d%%")) { + if (renderer) renderer->setBrightness(static_cast(pendingBrightness) / 50.0f); + saveSettings(); + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + if (ImGui::Button("Restore Video Defaults", ImVec2(-1, 0))) { pendingFullscreen = kDefaultFullscreen; pendingVsync = kDefaultVsync; @@ -7805,9 +18625,11 @@ void GameScreen::renderSettingsWindow() { pendingPOM = true; pendingPOMQuality = 1; pendingResIndex = defaultResIndex; + pendingBrightness = 50; window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); + if (renderer) renderer->setBrightness(1.0f); pendingWaterRefraction = false; if (renderer) { renderer->setShadowsEnabled(pendingShadows); @@ -7840,6 +18662,121 @@ void GameScreen::renderSettingsWindow() { ImGui::EndTabItem(); } + // ============================================================ + // INTERFACE TAB + // ============================================================ + if (ImGui::BeginTabItem("Interface")) { + ImGui::Spacing(); + ImGui::BeginChild("InterfaceSettings", ImVec2(0, 360), true); + + ImGui::SeparatorText("Action Bars"); + ImGui::Spacing(); + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderFloat("Action Bar Scale", &pendingActionBarScale, 0.5f, 1.5f, "%.2fx")) { + saveSettings(); + } + ImGui::Spacing(); + + if (ImGui::Checkbox("Show Second Action Bar", &pendingShowActionBar2)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Shift+1 through Shift+=)"); + + if (pendingShowActionBar2) { + ImGui::Spacing(); + ImGui::TextUnformatted("Second Bar Position Offset"); + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Horizontal##bar2x", &pendingActionBar2OffsetX, -600.0f, 600.0f, "%.0f px")) { + saveSettings(); + } + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical##bar2y", &pendingActionBar2OffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + if (ImGui::Button("Reset Position##bar2")) { + pendingActionBar2OffsetX = 0.0f; + pendingActionBar2OffsetY = 0.0f; + saveSettings(); + } + } + + ImGui::Spacing(); + if (ImGui::Checkbox("Show Right Side Bar", &pendingShowRightBar)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Slots 25-36)"); + if (pendingShowRightBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##rbar", &pendingRightBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + } + + ImGui::Spacing(); + if (ImGui::Checkbox("Show Left Side Bar", &pendingShowLeftBar)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(Slots 37-48)"); + if (pendingShowLeftBar) { + ImGui::SetNextItemWidth(160.0f); + if (ImGui::SliderFloat("Vertical Offset##lbar", &pendingLeftBarOffsetY, -400.0f, 400.0f, "%.0f px")) { + saveSettings(); + } + } + + ImGui::Spacing(); + ImGui::SeparatorText("Nameplates"); + ImGui::Spacing(); + ImGui::SetNextItemWidth(200.0f); + if (ImGui::SliderFloat("Nameplate Scale", &nameplateScale_, 0.5f, 2.0f, "%.2fx")) { + saveSettings(); + } + + ImGui::Spacing(); + ImGui::SeparatorText("Network"); + ImGui::Spacing(); + if (ImGui::Checkbox("Show Latency Meter", &pendingShowLatencyMeter)) { + showLatencyMeter_ = pendingShowLatencyMeter; + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(ms indicator near minimap)"); + + if (ImGui::Checkbox("Show DPS/HPS Meter", &showDPSMeter_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(damage/healing per second above action bar)"); + + if (ImGui::Checkbox("Show Cooldown Tracker", &showCooldownTracker_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(active spell cooldowns near action bar)"); + + ImGui::Spacing(); + ImGui::SeparatorText("Screen Effects"); + ImGui::Spacing(); + if (ImGui::Checkbox("Damage Flash", &damageFlashEnabled_)) { + if (!damageFlashEnabled_) damageFlashAlpha_ = 0.0f; + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(red vignette on taking damage)"); + + if (ImGui::Checkbox("Low Health Vignette", &lowHealthVignetteEnabled_)) { + saveSettings(); + } + ImGui::SameLine(); + ImGui::TextDisabled("(pulsing red edges below 20%% HP)"); + + ImGui::EndChild(); + ImGui::EndTabItem(); + } + // ============================================================ // AUDIO TAB // ============================================================ @@ -8023,6 +18960,17 @@ void GameScreen::renderSettingsWindow() { if (ImGui::IsItemHovered()) ImGui::SetTooltip("Allow the camera to zoom out further than normal"); + if (ImGui::SliderFloat("Field of View", &pendingFov, 45.0f, 110.0f, "%.0f°")) { + if (renderer) { + if (auto* camera = renderer->getCamera()) { + camera->setFov(pendingFov); + } + } + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Camera field of view in degrees (default: 70)"); + ImGui::Spacing(); ImGui::Spacing(); @@ -8085,6 +19033,16 @@ void GameScreen::renderSettingsWindow() { } if (ImGui::IsItemHovered()) ImGui::SetTooltip("Automatically pick up all items when looting"); + if (ImGui::Checkbox("Auto Sell Greys", &pendingAutoSellGrey)) { + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically sell all grey (poor quality) items when opening a vendor"); + if (ImGui::Checkbox("Auto Repair", &pendingAutoRepair)) { + saveSettings(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Automatically repair all damaged equipment when opening an armorer vendor"); ImGui::Spacing(); ImGui::Text("Bags"); @@ -8093,6 +19051,10 @@ void GameScreen::renderSettingsWindow() { inventoryScreen.setSeparateBags(pendingSeparateBags); saveSettings(); } + if (ImGui::Checkbox("Show Key Ring", &pendingShowKeyring)) { + inventoryScreen.setShowKeyring(pendingShowKeyring); + saveSettings(); + } ImGui::Spacing(); ImGui::Separator(); @@ -8108,6 +19070,8 @@ void GameScreen::renderSettingsWindow() { pendingMinimapNpcDots = false; pendingSeparateBags = true; inventoryScreen.setSeparateBags(true); + pendingShowKeyring = true; + inventoryScreen.setShowKeyring(true); uiOpacity_ = 0.65f; minimapRotate_ = false; minimapSquare_ = false; @@ -8129,6 +19093,108 @@ void GameScreen::renderSettingsWindow() { ImGui::EndTabItem(); } + // ============================================================ + // CONTROLS TAB + // ============================================================ + if (ImGui::BeginTabItem("Controls")) { + ImGui::Spacing(); + + ImGui::Text("Keybindings"); + ImGui::Separator(); + + auto& km = ui::KeybindingManager::getInstance(); + int numActions = km.getActionCount(); + + for (int i = 0; i < numActions; ++i) { + auto action = static_cast(i); + const char* actionName = km.getActionName(action); + ImGuiKey currentKey = km.getKeyForAction(action); + + // Display current binding + ImGui::Text("%s:", actionName); + ImGui::SameLine(200); + + // Get human-readable key name (basic implementation) + const char* keyName = "Unknown"; + if (currentKey >= ImGuiKey_A && currentKey <= ImGuiKey_Z) { + static char keyBuf[16]; + snprintf(keyBuf, sizeof(keyBuf), "%c", 'A' + (currentKey - ImGuiKey_A)); + keyName = keyBuf; + } else if (currentKey >= ImGuiKey_0 && currentKey <= ImGuiKey_9) { + static char keyBuf[16]; + snprintf(keyBuf, sizeof(keyBuf), "%c", '0' + (currentKey - ImGuiKey_0)); + keyName = keyBuf; + } else if (currentKey == ImGuiKey_Escape) { + keyName = "Escape"; + } else if (currentKey == ImGuiKey_Enter) { + keyName = "Enter"; + } else if (currentKey == ImGuiKey_Tab) { + keyName = "Tab"; + } else if (currentKey == ImGuiKey_Space) { + keyName = "Space"; + } else if (currentKey >= ImGuiKey_F1 && currentKey <= ImGuiKey_F12) { + static char keyBuf[16]; + snprintf(keyBuf, sizeof(keyBuf), "F%d", 1 + (currentKey - ImGuiKey_F1)); + keyName = keyBuf; + } + + ImGui::Text("[%s]", keyName); + + // Rebind button + ImGui::SameLine(350); + if (ImGui::Button(awaitingKeyPress && pendingRebindAction == i ? "Waiting..." : "Rebind", ImVec2(100, 0))) { + pendingRebindAction = i; + awaitingKeyPress = true; + } + } + + // Handle key press during rebinding + if (awaitingKeyPress && pendingRebindAction >= 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Text("Press any key to bind to this action (Esc to cancel)..."); + + // Check for any key press + bool foundKey = false; + ImGuiKey newKey = ImGuiKey_None; + for (int k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; ++k) { + if (ImGui::IsKeyPressed(static_cast(k), false)) { + if (k == ImGuiKey_Escape) { + // Cancel rebinding + awaitingKeyPress = false; + pendingRebindAction = -1; + foundKey = true; + break; + } + newKey = static_cast(k); + foundKey = true; + break; + } + } + + if (foundKey && newKey != ImGuiKey_None) { + auto action = static_cast(pendingRebindAction); + km.setKeyForAction(action, newKey); + awaitingKeyPress = false; + pendingRebindAction = -1; + saveSettings(); + } + } + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Reset to Defaults", ImVec2(-1, 0))) { + km.resetToDefaults(); + awaitingKeyPress = false; + pendingRebindAction = -1; + saveSettings(); + } + + ImGui::EndTabItem(); + } + // ============================================================ // CHAT TAB // ============================================================ @@ -8251,6 +19317,177 @@ void GameScreen::renderSettingsWindow() { ImGui::End(); } +void GameScreen::applyGraphicsPreset(GraphicsPreset preset) { + auto* renderer = core::Application::getInstance().getRenderer(); + + // Define preset values based on quality level + switch (preset) { + case GraphicsPreset::LOW: { + pendingShadows = false; + pendingShadowDistance = 100.0f; + pendingAntiAliasing = 0; // Off + pendingNormalMapping = false; + pendingPOM = false; + pendingGroundClutterDensity = 25; + if (renderer) { + renderer->setShadowsEnabled(false); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_1_BIT); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(false); + wr->setPOMEnabled(false); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(false); + cr->setPOMEnabled(false); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(0.25f); + } + } + break; + } + case GraphicsPreset::MEDIUM: { + pendingShadows = true; + pendingShadowDistance = 200.0f; + pendingAntiAliasing = 1; // 2x MSAA + pendingNormalMapping = true; + pendingNormalMapStrength = 0.6f; + pendingPOM = true; + pendingPOMQuality = 0; // Low + pendingGroundClutterDensity = 60; + if (renderer) { + renderer->setShadowsEnabled(true); + renderer->setShadowDistance(200.0f); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_2_BIT); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(true); + wr->setNormalMapStrength(0.6f); + wr->setPOMEnabled(true); + wr->setPOMQuality(0); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(true); + cr->setNormalMapStrength(0.6f); + cr->setPOMEnabled(true); + cr->setPOMQuality(0); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(0.60f); + } + } + break; + } + case GraphicsPreset::HIGH: { + pendingShadows = true; + pendingShadowDistance = 350.0f; + pendingAntiAliasing = 2; // 4x MSAA + pendingNormalMapping = true; + pendingNormalMapStrength = 0.8f; + pendingPOM = true; + pendingPOMQuality = 1; // Medium + pendingGroundClutterDensity = 100; + if (renderer) { + renderer->setShadowsEnabled(true); + renderer->setShadowDistance(350.0f); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_4_BIT); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(true); + wr->setNormalMapStrength(0.8f); + wr->setPOMEnabled(true); + wr->setPOMQuality(1); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(true); + cr->setNormalMapStrength(0.8f); + cr->setPOMEnabled(true); + cr->setPOMQuality(1); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(1.0f); + } + } + break; + } + case GraphicsPreset::ULTRA: { + pendingShadows = true; + pendingShadowDistance = 500.0f; + pendingAntiAliasing = 3; // 8x MSAA + pendingFXAA = true; // FXAA on top of MSAA for maximum smoothness + pendingNormalMapping = true; + pendingNormalMapStrength = 1.2f; + pendingPOM = true; + pendingPOMQuality = 2; // High + pendingGroundClutterDensity = 150; + if (renderer) { + renderer->setShadowsEnabled(true); + renderer->setShadowDistance(500.0f); + renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT); + renderer->setFXAAEnabled(true); + if (auto* wr = renderer->getWMORenderer()) { + wr->setNormalMappingEnabled(true); + wr->setNormalMapStrength(1.2f); + wr->setPOMEnabled(true); + wr->setPOMQuality(2); + } + if (auto* cr = renderer->getCharacterRenderer()) { + cr->setNormalMappingEnabled(true); + cr->setNormalMapStrength(1.2f); + cr->setPOMEnabled(true); + cr->setPOMQuality(2); + } + if (auto* tm = renderer->getTerrainManager()) { + tm->setGroundClutterDensityScale(1.5f); + } + } + break; + } + default: + break; + } + + currentGraphicsPreset = preset; + pendingGraphicsPreset = preset; +} + +void GameScreen::updateGraphicsPresetFromCurrentSettings() { + // Check if current settings match any preset, otherwise mark as CUSTOM + // This is a simplified check; could be enhanced with more detailed matching + + auto matchesPreset = [this](GraphicsPreset preset) -> bool { + switch (preset) { + case GraphicsPreset::LOW: + return !pendingShadows && pendingAntiAliasing == 0 && !pendingNormalMapping && !pendingPOM && + pendingGroundClutterDensity <= 30; + case GraphicsPreset::MEDIUM: + return pendingShadows && pendingShadowDistance >= 180 && pendingShadowDistance <= 220 && + pendingAntiAliasing == 1 && pendingNormalMapping && pendingPOM && + pendingGroundClutterDensity >= 50 && pendingGroundClutterDensity <= 70; + case GraphicsPreset::HIGH: + return pendingShadows && pendingShadowDistance >= 330 && pendingShadowDistance <= 370 && + pendingAntiAliasing == 2 && pendingNormalMapping && pendingPOM && + pendingGroundClutterDensity >= 90 && pendingGroundClutterDensity <= 110; + case GraphicsPreset::ULTRA: + return pendingShadows && pendingShadowDistance >= 480 && pendingAntiAliasing == 3 && + pendingFXAA && pendingNormalMapping && pendingPOM && pendingGroundClutterDensity >= 140; + default: + return false; + } + }; + + // Try to match a preset, otherwise mark as custom + if (matchesPreset(GraphicsPreset::LOW)) { + pendingGraphicsPreset = GraphicsPreset::LOW; + } else if (matchesPreset(GraphicsPreset::MEDIUM)) { + pendingGraphicsPreset = GraphicsPreset::MEDIUM; + } else if (matchesPreset(GraphicsPreset::HIGH)) { + pendingGraphicsPreset = GraphicsPreset::HIGH; + } else if (matchesPreset(GraphicsPreset::ULTRA)) { + pendingGraphicsPreset = GraphicsPreset::ULTRA; + } else { + pendingGraphicsPreset = GraphicsPreset::CUSTOM; + } +} + void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) { const auto& statuses = gameHandler.getNpcQuestStatuses(); if (statuses.empty()) return; @@ -8361,7 +19598,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sinB = 0.0f; if (minimap->isRotateWithCamera()) { glm::vec3 fwd = camera->getForward(); - bearing = std::atan2(-fwd.x, fwd.y); + // Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)). + // Clockwise bearing from North: atan2(fwd.y, -fwd.x). + bearing = std::atan2(fwd.y, -fwd.x); cosB = std::cos(bearing); sinB = std::sin(bearing); } @@ -8372,10 +19611,13 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float dx = worldRenderPos.x - playerRender.x; float dy = worldRenderPos.y - playerRender.y; - // Match minimap shader transform exactly. - // Render axes: +X=west, +Y=north. Minimap screen axes: +X=right(east), +Y=down(south). - float rx = -dx * cosB + dy * sinB; - float ry = -dx * sinB - dy * cosB; + // Exact inverse of minimap display shader: + // shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2 + // where rotated = R(bearing) * center, center in [-0.5, 0.5] + // Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2) + // With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south): + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; // Scale to minimap pixels float px = rx / viewRadius * mapRadius; @@ -8391,8 +19633,36 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { return true; }; + // Build sets of entries that are incomplete objectives for tracked quests. + // minimapQuestEntries: NPC creature entries (npcOrGoId > 0) + // minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value) + std::unordered_set minimapQuestEntries; + std::unordered_set minimapQuestGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + const auto& tq = gameHandler.getTrackedQuestIds(); + for (const auto& q : ql) { + if (q.complete || q.questId == 0) continue; + if (!tq.empty() && !tq.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.required == 0) continue; + if (obj.npcOrGoId > 0) { + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); + } else if (obj.npcOrGoId < 0) { + uint32_t goEntry = static_cast(-obj.npcOrGoId); + auto it = q.killCounts.find(goEntry); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestGoEntries.insert(goEntry); + } + } + } + } + // Optional base nearby NPC dots (independent of quest status packets). if (minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { if (!entity || entity->getType() != game::ObjectType::UNIT) continue; @@ -8403,8 +19673,186 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(npcRender, sx, sy)) continue; - ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); - drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + bool isQuestTarget = minimapQuestEntries.count(unit->getEntry()) != 0; + if (isQuestTarget) { + // Quest kill objective: larger gold dot with dark outline + drawList->AddCircleFilled(ImVec2(sx, sy), 3.5f, IM_COL32(255, 210, 30, 240)); + drawList->AddCircle(ImVec2(sx, sy), 3.5f, IM_COL32(80, 50, 0, 180), 0, 1.0f); + // Tooltip on hover showing unit name + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const std::string& nm = unit->getName(); + if (!nm.empty()) ImGui::SetTooltip("%s (quest)", nm.c_str()); + } + } else { + ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); + drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + } + } + } + + // Nearby other-player dots — shown when NPC dots are enabled. + // Party members are already drawn as squares above; other players get a small circle. + if (minimapNpcDots_) { + const uint64_t selfGuid = gameHandler.getPlayerGuid(); + const auto& partyData = gameHandler.getPartyData(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + if (entity->getGuid() == selfGuid) continue; // skip self (already drawn as arrow) + + // Skip party members (already drawn as squares above) + bool isPartyMember = false; + for (const auto& m : partyData.members) { + if (m.guid == guid) { isPartyMember = true; break; } + } + if (isPartyMember) continue; + + glm::vec3 pRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(pRender, sx, sy)) continue; + + // Blue dot for other nearby players + drawList->AddCircleFilled(ImVec2(sx, sy), 2.0f, IM_COL32(80, 160, 255, 220)); + } + } + + // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. + // Shown whenever NPC dots are enabled (or always, since they're always useful). + { + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit) continue; + // Must be dead (health == 0) and marked lootable + if (unit->getHealth() != 0) continue; + if (!(unit->getDynamicFlags() & UNIT_DYNFLAG_LOOTABLE)) continue; + + glm::vec3 npcRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(npcRender, sx, sy)) continue; + + // Draw a small diamond (rotated square) in light yellow-green + const float dr = 3.5f; + ImVec2 top (sx, sy - dr); + ImVec2 right(sx + dr, sy ); + ImVec2 bot (sx, sy + dr); + ImVec2 left (sx - dr, sy ); + drawList->AddQuadFilled(top, right, bot, left, IM_COL32(180, 230, 80, 230)); + drawList->AddQuad (top, right, bot, left, IM_COL32(60, 80, 20, 200), 1.0f); + + // Tooltip on hover + if (ImGui::IsMouseHoveringRect(ImVec2(sx - dr, sy - dr), ImVec2(sx + dr, sy + dr))) { + const std::string& nm = unit->getName(); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.3f, 1.0f), "%s", + nm.empty() ? "Lootable corpse" : nm.c_str()); + ImGui::EndTooltip(); + } + } + } + + // Interactable game object dots (chests, resource nodes) when NPC dots are enabled. + // Shown as small orange triangles to distinguish from unit dots and loot corpses. + if (minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue; + + // Only show objects that are likely interactive (chests/nodes: type 3; + // also show type 0=Door when open, but filter by dynamic-flag ACTIVATED). + // For simplicity, show all game objects that have a non-empty cached name. + auto go = std::static_pointer_cast(entity); + if (!go) continue; + + // Only show if we have name data (avoids cluttering with unknown objects) + const auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); + if (!goInfo || !goInfo->isValid()) continue; + // Skip transport objects (boats/zeppelins): type 15 = MO_TRANSPORT, 11 = TRANSPORT + if (goInfo->type == 11 || goInfo->type == 15) continue; + + glm::vec3 goRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(goRender, sx, sy)) continue; + + // Triangle size and color: bright cyan for quest objectives, amber for others + bool isQuestGO = minimapQuestGoEntries.count(go->getEntry()) != 0; + const float ts = isQuestGO ? 4.5f : 3.5f; + ImVec2 goTip (sx, sy - ts); + ImVec2 goLeft (sx - ts, sy + ts * 0.6f); + ImVec2 goRight(sx + ts, sy + ts * 0.6f); + if (isQuestGO) { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(50, 230, 255, 240)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(0, 60, 80, 200), 1.5f); + } else { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); + } + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + if (isQuestGO) + ImGui::SetTooltip("%s (quest)", goInfo->name.c_str()); + else + ImGui::SetTooltip("%s", goInfo->name.c_str()); + } + } + } + + // Party member dots on minimap — small colored squares with name tooltip on hover + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& member : partyData.members) { + if (!member.hasPartyStats) continue; + bool isOnline = (member.onlineStatus & 0x0001) != 0; + bool isDead = (member.onlineStatus & 0x0020) != 0; + bool isGhost = (member.onlineStatus & 0x0010) != 0; + if (!isOnline) continue; + if (member.posX == 0 && member.posY == 0) continue; + + // Party stat positions: posY = canonical X (north), posX = canonical Y (west) + glm::vec3 memberRender = core::coords::canonicalToRender( + glm::vec3(static_cast(member.posY), + static_cast(member.posX), 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(memberRender, sx, sy)) continue; + + // Determine dot color: class color > leader gold > light blue + ImU32 dotCol; + if (isDead || isGhost) { + dotCol = IM_COL32(140, 140, 140, 200); // gray for dead + } else { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + dotCol = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), 230); + } else if (member.guid == partyData.leaderGuid) { + dotCol = IM_COL32(255, 210, 0, 230); // gold for leader + } else { + dotCol = IM_COL32(100, 180, 255, 230); // blue for others + } + } + + // Draw a small square (WoW-style party member dot) + const float hs = 3.5f; + drawList->AddRectFilled(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), dotCol, 1.0f); + drawList->AddRect(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), + IM_COL32(0, 0, 0, 180), 1.0f, 0, 1.0f); + + // Name tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f && !member.name.empty()) { + ImGui::SetTooltip("%s", member.name.c_str()); + } } } @@ -8444,6 +19892,87 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddText(font, 11.0f, ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f), IM_COL32(0, 0, 0, 255), marker); + + // Show NPC name and quest status on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + std::string npcName; + if (entity->getType() == game::ObjectType::UNIT) { + auto npcUnit = std::static_pointer_cast(entity); + npcName = npcUnit->getName(); + } + if (!npcName.empty()) { + bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE || + status == game::QuestGiverStatus::AVAILABLE_LOW); + ImGui::SetTooltip("%s\n%s", npcName.c_str(), + hasQuest ? "Has a quest for you" : "Quest ready to turn in"); + } + } + } + } + + // Quest kill objective markers — highlight live NPCs matching active quest kill objectives + { + // Build map of NPC entry → (quest title, current, required) for tooltips + struct KillInfo { std::string questTitle; uint32_t current = 0; uint32_t required = 0; }; + std::unordered_map killInfoMap; + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& quest : gameHandler.getQuestLog()) { + if (quest.complete) continue; + if (!trackedIds.empty() && !trackedIds.count(quest.questId)) continue; + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId <= 0 || obj.required == 0) continue; + uint32_t npcEntry = static_cast(obj.npcOrGoId); + auto it = quest.killCounts.find(npcEntry); + uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; + if (current < obj.required) { + killInfoMap[npcEntry] = { quest.title, current, obj.required }; + } + } + } + + if (!killInfoMap.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit || unit->getHealth() == 0) continue; + auto infoIt = killInfoMap.find(unit->getEntry()); + if (infoIt == killInfoMap.end()) continue; + + glm::vec3 unitRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(unitRender, sx, sy)) continue; + + // Gold circle with a dark "x" mark — indicates a quest kill target + drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, IM_COL32(255, 185, 0, 240)); + drawList->AddCircle(ImVec2(sx, sy), 5.5f, IM_COL32(0, 0, 0, 180), 12, 1.0f); + drawList->AddLine(ImVec2(sx - 2.5f, sy - 2.5f), ImVec2(sx + 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const auto& ki = infoIt->second; + const std::string& npcName = unit->getName(); + if (!npcName.empty()) { + ImGui::SetTooltip("%s\n%s: %u/%u", + npcName.c_str(), + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } else { + ImGui::SetTooltip("%s: %u/%u", + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } + } + } + } } // Gossip POI markers (quest / NPC navigation targets) @@ -8509,20 +20038,417 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sx = 0.0f, sy = 0.0f; if (!projectToMinimap(memberRender, sx, sy)) continue; - ImU32 dotColor = (member.guid == leaderGuid) - ? IM_COL32(255, 210, 0, 235) - : IM_COL32(100, 180, 255, 235); + ImU32 dotColor; + { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + dotColor = (cid != 0) + ? classColorU32(cid, 235) + : (member.guid == leaderGuid) + ? IM_COL32(255, 210, 0, 235) + : IM_COL32(100, 180, 255, 235); + } drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); + // Raid mark: tiny symbol drawn above the dot + { + static const struct { const char* sym; ImU32 col; } kMMMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImFont* mmFont = ImGui::GetFont(); + ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym); + drawList->AddText(mmFont, 9.0f, + ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y), + kMMMarks[pmk].col, kMMMarks[pmk].sym); + } + } + ImVec2 cursorPos = ImGui::GetMousePos(); float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { - ImGui::SetTooltip("%s", member.name.c_str()); + uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid); + if (pmk2 < game::GameHandler::kRaidMarkCount) { + static const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", + "Moon", "Square", "Cross", "Skull" + }; + ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]); + } else { + ImGui::SetTooltip("%s", member.name.c_str()); + } } } } + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + { + const auto& bgPositions = gameHandler.getBgPlayerPositions(); + if (!bgPositions.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + // group 0 = typically ally-held flag / first list; group 1 = enemy + static const ImU32 kBgGroupColors[2] = { + IM_COL32( 80, 180, 255, 240), // group 0: blue (alliance) + IM_COL32(220, 50, 50, 240), // group 1: red (horde) + }; + for (const auto& bp : bgPositions) { + // Packet coords: wowX=canonical X (north), wowY=canonical Y (west) + glm::vec3 bpRender = core::coords::canonicalToRender(glm::vec3(bp.wowX, bp.wowY, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(bpRender, sx, sy)) continue; + + ImU32 col = kBgGroupColors[bp.group & 1]; + + // Draw a flag-like diamond icon + const float r = 5.0f; + ImVec2 top (sx, sy - r); + ImVec2 right(sx + r, sy ); + ImVec2 bot (sx, sy + r); + ImVec2 left (sx - r, sy ); + drawList->AddQuadFilled(top, right, bot, left, col); + drawList->AddQuad(top, right, bot, left, IM_COL32(255, 255, 255, 180), 1.0f); + + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + // Show entity name if available, otherwise guid + auto ent = gameHandler.getEntityManager().getEntity(bp.guid); + if (ent) { + std::string nm; + if (ent->getType() == game::ObjectType::PLAYER) { + auto pl = std::static_pointer_cast(ent); + nm = pl ? pl->getName() : ""; + } + if (!nm.empty()) + ImGui::SetTooltip("Flag carrier: %s", nm.c_str()); + else + ImGui::SetTooltip("Flag carrier"); + } else { + ImGui::SetTooltip("Flag carrier"); + } + } + } + } + } + + // Corpse direction indicator — shown when player is a ghost + if (gameHandler.isPlayerGhost()) { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) { + glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)); + float csx = 0.0f, csy = 0.0f; + bool onMap = projectToMinimap(corpseRender, csx, csy); + + if (onMap) { + // Draw a small skull-like X marker at the corpse position + const float r = 5.0f; + drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12); + drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f); + // Draw an X in the circle + drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - csx, mdy = mouse.y - csy; + if (mdx * mdx + mdy * mdy < 64.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } else { + // Corpse is outside minimap — draw an edge arrow pointing toward it + float dx = corpseRender.x - playerRender.x; + float dy = corpseRender.y - playerRender.y; + // Rotate delta into minimap frame (same as projectToMinimap) + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; + float len = std::sqrt(rx * rx + ry * ry); + if (len > 0.001f) { + float nx = rx / len; + float ny = ry / len; + // Place arrow at the minimap edge + float edgeR = mapRadius - 7.0f; + float ax = centerX + nx * edgeR; + float ay = centerY + ny * edgeR; + // Arrow pointing outward (toward corpse) + float arrowLen = 6.0f; + float arrowW = 3.5f; + ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen); + ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f, + ay + nx * arrowW - ny * arrowLen * 0.4f); + ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f, + ay - nx * arrowW - ny * arrowLen * 0.4f); + drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230)); + drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - ax, mdy = mouse.y - ay; + if (mdx * mdx + mdy * mdy < 100.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } + } + } + } + + // Player position arrow at minimap center, pointing in camera facing direction. + // On a rotating minimap the map already turns so forward = screen-up; on a fixed + // minimap we rotate the arrow to match the player's compass heading. + { + // Compute screen-space facing direction for the arrow. + // bearing = clockwise angle from screen-north (0 = facing north/up). + float arrowAngle = 0.0f; // 0 = pointing up (north) + if (!minimap->isRotateWithCamera()) { + // Fixed minimap: arrow must show actual facing relative to north. + glm::vec3 fwd = camera->getForward(); + // +render_y = north = screen-up, +render_x = west = screen-left. + // bearing from north clockwise: atan2(-fwd.x_west, fwd.y_north) + // => sin=east component, cos=north component + // In render coords west=+x, east=-x, so sin(bearing)=east=-fwd.x + arrowAngle = std::atan2(-fwd.x, fwd.y); // clockwise from north in screen space + } + // Screen direction the arrow tip points toward + float nx = std::sin(arrowAngle); // screen +X = east + float ny = -std::cos(arrowAngle); // screen -Y = north + + // Draw a chevron-style arrow: tip, two base corners, and a notch at the back + const float tipLen = 8.0f; // tip forward distance + const float baseW = 5.0f; // half-width at base + const float notchIn = 3.0f; // how far back the center notch sits + // Perpendicular direction (rotated 90°) + float px = ny; // perpendicular x + float py = -nx; // perpendicular y + + ImVec2 tip (centerX + nx * tipLen, centerY + ny * tipLen); + ImVec2 baseL(centerX - nx * baseW + px * baseW, centerY - ny * baseW + py * baseW); + ImVec2 baseR(centerX - nx * baseW - px * baseW, centerY - ny * baseW - py * baseW); + ImVec2 notch(centerX - nx * (baseW - notchIn), centerY - ny * (baseW - notchIn)); + + // Fill: bright white with slight gold tint, dark outline for readability + drawList->AddTriangleFilled(tip, baseL, notch, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangleFilled(tip, notch, baseR, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangle(tip, baseL, notch, IM_COL32(60, 40, 0, 200), 1.2f); + drawList->AddTriangle(tip, notch, baseR, IM_COL32(60, 40, 0, 200), 1.2f); + } + + // Scroll wheel over minimap → zoom in/out + { + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0.0f) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + if (wheel > 0.0f) + minimap->zoomIn(); + else + minimap->zoomOut(); + } + } + } + + // Ctrl+click on minimap → send minimap ping to party + if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + float distSq = mdx * mdx + mdy * mdy; + if (distSq <= mapRadius * mapRadius) { + // Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius + float rx = mdx * viewRadius / mapRadius; + float ry = mdy * viewRadius / mapRadius; + // rx/ry are in rotated frame; unrotate to get world dx/dy + // rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + // Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB) + float wdx = -(rx * cosB - ry * sinB); + float wdy = -(rx * sinB + ry * cosB); + // playerRender is in render coords; add delta to get render position then convert to canonical + glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f); + glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender); + gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y); + } + } + + // Persistent coordinate display below the minimap + { + glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); + char coordBuf[32]; + std::snprintf(coordBuf, sizeof(coordBuf), "%.1f, %.1f", playerCanon.x, playerCanon.y); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, coordBuf); + + float tx = centerX - textSz.x * 0.5f; + float ty = centerY + mapRadius + 3.0f; + + // Semi-transparent dark background pill + float pad = 3.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + textSz.x + pad, ty + textSz.y + pad), + IM_COL32(0, 0, 0, 140), 4.0f); + // Coordinate text in warm yellow + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); + } + + // Local time clock — displayed just below the coordinate label + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + std::tm tmLocal{}; +#if defined(_WIN32) + localtime_s(&tmLocal, &tt); +#else + localtime_r(&tt, &tmLocal); +#endif + char clockBuf[16]; + std::snprintf(clockBuf, sizeof(clockBuf), "%02d:%02d", + tmLocal.tm_hour, tmLocal.tm_min); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.9f; // slightly smaller than coords + ImVec2 clockSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, clockBuf); + + float tx = centerX - clockSz.x * 0.5f; + // Position below the coordinate line (+fontSize of coord + 2px gap) + float coordLineH = ImGui::GetFontSize(); + float ty = centerY + mapRadius + 3.0f + coordLineH + 2.0f; + + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + clockSz.x + pad, ty + clockSz.y + pad), + IM_COL32(0, 0, 0, 120), 3.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(200, 200, 220, 220), clockBuf); + } + + // Zone name display — drawn inside the top edge of the minimap circle + { + auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; + uint32_t zoneId = gameHandler.getWorldStateZoneId(); + const game::ZoneInfo* zi = (zmRenderer && zoneId != 0) ? zmRenderer->getZoneInfo(zoneId) : nullptr; + if (zi && !zi->name.empty()) { + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, zi->name.c_str()); + float tx = centerX - ts.x * 0.5f; + float ty = centerY - mapRadius + 4.0f; // just inside top edge of the circle + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + IM_COL32(0, 0, 0, 160), 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 180), zi->name.c_str()); + drawList->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 230, 150, 220), zi->name.c_str()); + } + } + + // Instance difficulty indicator — just below zone name, inside minimap top edge + if (gameHandler.isInInstance()) { + static const char* kDiffLabels[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; + uint32_t diff = gameHandler.getInstanceDifficulty(); + const char* label = (diff < 4) ? kDiffLabels[diff] : "Unknown"; + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.85f; + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label); + float tx = centerX - ts.x * 0.5f; + // Position below zone name: top edge + zone font size + small gap + float ty = centerY - mapRadius + 4.0f + ImGui::GetFontSize() + 2.0f; + float pad = 2.0f; + + // Color-code: heroic=orange, normal=light gray + ImU32 bgCol = gameHandler.isInstanceHeroic() ? IM_COL32(120, 60, 0, 180) : IM_COL32(0, 0, 0, 160); + ImU32 textCol = gameHandler.isInstanceHeroic() ? IM_COL32(255, 180, 50, 255) : IM_COL32(200, 200, 200, 220); + + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + bgCol, 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), textCol, label); + } + + // Hover tooltip and right-click context menu + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius); + + if (overMinimap) { + ImGui::BeginTooltip(); + // Compute the world coordinate under the mouse cursor + // Inverse of projectToMinimap: pixel offset → world offset in render space → canonical + float rxW = mdx / mapRadius * viewRadius; + float ryW = mdy / mapRadius * viewRadius; + // Un-rotate: [dx, dy] = R^-1 * [rxW, ryW] + // where R applied: rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + float hoverDx = -cosB * rxW + sinB * ryW; + float hoverDy = -sinB * rxW - cosB * ryW; + glm::vec3 hoverRender(playerRender.x + hoverDx, playerRender.y + hoverDy, playerRender.z); + glm::vec3 hoverCanon = core::coords::renderToCanonical(hoverRender); + ImGui::TextColored(ImVec4(0.9f, 0.85f, 0.5f, 1.0f), "%.1f, %.1f", hoverCanon.x, hoverCanon.y); + ImGui::TextColored(ImVec4(0.65f, 0.65f, 0.65f, 1.0f), "Ctrl+click to ping"); + ImGui::EndTooltip(); + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##minimapContextMenu"); + } + } + + if (ImGui::BeginPopup("##minimapContextMenu")) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Minimap"); + ImGui::Separator(); + + // Zoom controls + if (ImGui::MenuItem("Zoom In")) { + minimap->zoomIn(); + } + if (ImGui::MenuItem("Zoom Out")) { + minimap->zoomOut(); + } + + ImGui::Separator(); + + // Toggle options with checkmarks + bool rotWithCam = minimap->isRotateWithCamera(); + if (ImGui::MenuItem("Rotate with Camera", nullptr, rotWithCam)) { + minimap->setRotateWithCamera(!rotWithCam); + } + + bool squareShape = minimap->isSquareShape(); + if (ImGui::MenuItem("Square Shape", nullptr, squareShape)) { + minimap->setSquareShape(!squareShape); + } + + bool npcDots = minimapNpcDots_; + if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { + minimapNpcDots_ = !minimapNpcDots_; + } + + ImGui::EndPopup(); + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; @@ -8561,18 +20487,53 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { }; // Zone name label above the minimap (centered, WoW-style) + // Prefer the server-reported zone/area name (from SMSG_INIT_WORLD_STATES) so sub-zones + // like Ironforge or Wailing Caverns display correctly; fall back to renderer zone name. { - const std::string& zoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + std::string wsZoneName; + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0) + wsZoneName = gameHandler.getWhoAreaName(wsZoneId); + const std::string& rendererZoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + const std::string& zoneName = !wsZoneName.empty() ? wsZoneName : rendererZoneName; if (!zoneName.empty()) { auto* fgDl = ImGui::GetForegroundDrawList(); float zoneTextY = centerY - mapRadius - 16.0f; ImFont* font = ImGui::GetFont(); - ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + + // Weather icon appended to zone name when active + uint32_t wType = gameHandler.getWeatherType(); + float wIntensity = gameHandler.getWeatherIntensity(); + const char* weatherIcon = nullptr; + ImU32 weatherColor = IM_COL32(255, 255, 255, 200); + if (wType == 1 && wIntensity > 0.05f) { // Rain + weatherIcon = " \xe2\x9b\x86"; // U+26C6 ⛆ + weatherColor = IM_COL32(140, 180, 240, 220); + } else if (wType == 2 && wIntensity > 0.05f) { // Snow + weatherIcon = " \xe2\x9d\x84"; // U+2744 ❄ + weatherColor = IM_COL32(210, 230, 255, 220); + } else if (wType == 3 && wIntensity > 0.05f) { // Storm/Fog + weatherIcon = " \xe2\x98\x81"; // U+2601 ☁ + weatherColor = IM_COL32(160, 160, 190, 220); + } + + std::string displayName = zoneName; + // Build combined string if weather active + std::string fullLabel = weatherIcon ? (zoneName + weatherIcon) : zoneName; + ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, fullLabel.c_str()); float tzx = centerX - tsz.x * 0.5f; + + // Shadow pass fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), IM_COL32(0, 0, 0, 180), zoneName.c_str()); + // Zone name in gold fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), IM_COL32(255, 220, 120, 230), zoneName.c_str()); + // Weather symbol in its own color appended after + if (weatherIcon) { + ImVec2 nameSz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + fgDl->AddText(font, 12.0f, ImVec2(tzx + nameSz.x, zoneTextY), weatherColor, weatherIcon); + } } } @@ -8618,6 +20579,56 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); + // Friends button at top-left of minimap + { + const auto& contacts = gameHandler.getContacts(); + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); + ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 p = ImGui::GetCursorScreenPos(); + ImVec2 sz(20.0f, 20.0f); + if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) { + showSocialFrame_ = !showSocialFrame_; + } + bool hovered = ImGui::IsItemHovered(); + ImU32 bg = showSocialFrame_ + ? IM_COL32(42, 100, 42, 230) + : IM_COL32(38, 38, 38, 210); + if (hovered) bg = showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220); + draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f); + draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), + ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f), + IM_COL32(255, 255, 255, 42), 4.0f); + // Simple smiley-face dots as "social" icon + ImU32 fg = IM_COL32(255, 255, 255, 245); + draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f); + draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg); + draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg); + draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8); + draw->PathStroke(fg, 0, 1.2f); + // Small green dot if friends online + if (onlineCount > 0) { + draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f), + 3.5f, IM_COL32(50, 220, 50, 255)); + } + if (hovered) { + if (onlineCount > 0) + ImGui::SetTooltip("Friends (%d online)", onlineCount); + else + ImGui::SetTooltip("Friends"); + } + } + ImGui::End(); + } + // Zoom buttons at the bottom edge of the minimap ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always); @@ -8638,21 +20649,281 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); - // "New Mail" indicator below the minimap - if (gameHandler.hasNewMail()) { - float indicatorX = centerX - mapRadius; - float indicatorY = centerY + mapRadius + 4.0f; - ImGui::SetNextWindowPos(ImVec2(indicatorX, indicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(mapRadius * 2.0f, 22), ImGuiCond_Always); - ImGuiWindowFlags mailFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + // Clock display at bottom-right of minimap (local time) + { + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tmBuf{}; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockText[16]; + std::snprintf(clockText, sizeof(clockText), "%d:%02d %s", + (tmBuf.tm_hour % 12 == 0) ? 12 : tmBuf.tm_hour % 12, + tmBuf.tm_min, + tmBuf.tm_hour >= 12 ? "PM" : "AM"); + ImVec2 clockSz = ImGui::CalcTextSize(clockText); + float clockW = clockSz.x + 10.0f; + float clockH = clockSz.y + 6.0f; + ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - clockW - 2.0f, + centerY + mapRadius - clockH - 2.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(clockW, clockH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + ImGuiWindowFlags clockFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; - if (ImGui::Begin("##NewMailIndicator", nullptr, mailFlags)) { - // Pulsing effect + ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##MinimapClock", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.8f, 0.85f), "%s", clockText); + } + ImGui::End(); + } + + // Indicators below the minimap (stacked: new mail, then BG queue, then latency) + float indicatorX = centerX - mapRadius; + float nextIndicatorY = centerY + mapRadius + 4.0f; + const float indicatorW = mapRadius * 2.0f; + constexpr float kIndicatorH = 22.0f; + ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; + + // "New Mail" indicator + if (gameHandler.hasNewMail()) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!"); } ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // Unspent talent points indicator + { + uint8_t unspent = gameHandler.getUnspentTalentPoints(); + if (unspent > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TalentIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.5f); + char talentBuf[40]; + snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available", + static_cast(unspent), unspent == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f * pulse, pulse), "%s", talentBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // BG queue status indicator (when in queue but not yet invited) + for (const auto& slot : gameHandler.getBgQueues()) { + if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only + + std::string bgName; + if (slot.arenaType > 0) { + bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena"; + } else { + switch (slot.bgTypeId) { + case 1: bgName = "AV"; break; + case 2: bgName = "WSG"; break; + case 3: bgName = "AB"; break; + case 7: bgName = "EotS"; break; + case 9: bgName = "SotA"; break; + case 11: bgName = "IoC"; break; + default: bgName = "BG"; break; + } + } + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); + if (slot.avgWaitTimeSec > 0) { + int avgMin = static_cast(slot.avgWaitTimeSec) / 60; + int avgSec = static_cast(slot.avgWaitTimeSec) % 60; + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec); + } else { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "In Queue: %s", bgName.c_str()); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + break; // Show at most one queue slot indicator + } + + // LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck) + { + using LfgState = game::GameHandler::LfgState; + LfgState lfgSt = gameHandler.getLfgState(); + if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) { + if (lfgSt == LfgState::RoleCheck) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check..."); + } else { + uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); + int qMin = static_cast(qMs / 60000); + int qSec = static_cast((qMs % 60000) / 1000); + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.2f); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse), + "LFG: %d:%02d", qMin, qSec); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // Calendar pending invites indicator (WotLK only) + { + auto* expReg = core::Application::getInstance().getExpansionRegistry(); + bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk"; + if (isWotLK) { + uint32_t calPending = gameHandler.getCalendarPendingInvites(); + if (calPending > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.0f); + char calBuf[48]; + snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s", + calPending, calPending == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + } + + // Taxi flight indicator — shown while on a flight path + if (gameHandler.isOnTaxiFlight()) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) { + const std::string& dest = gameHandler.getTaxiDestName(); + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 1.0f); + if (dest.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight"); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str()); + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // Latency + FPS indicator — centered at top of screen + uint32_t latMs = gameHandler.getLatencyMs(); + if (showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { + float currentFps = ImGui::GetIO().Framerate; + ImVec4 latColor; + if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); + else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); + + ImVec4 fpsColor; + if (currentFps >= 60.0f) fpsColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (currentFps >= 30.0f) fpsColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else fpsColor = ImVec4(1.0f, 0.3f, 0.3f, 0.9f); + + char infoText[64]; + if (latMs > 0) + snprintf(infoText, sizeof(infoText), "%.0f fps | %u ms", currentFps, latMs); + else + snprintf(infoText, sizeof(infoText), "%.0f fps", currentFps); + + ImVec2 textSize = ImGui::CalcTextSize(infoText); + float latW = textSize.x + 16.0f; + float latH = textSize.y + 8.0f; + ImGuiIO& lio = ImGui::GetIO(); + float latX = (lio.DisplaySize.x - latW) * 0.5f; + ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { + // Color the FPS and latency portions differently + ImGui::TextColored(fpsColor, "%.0f fps", currentFps); + if (latMs > 0) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "|"); + ImGui::SameLine(0, 4); + ImGui::TextColored(latColor, "%u ms", latMs); + } + } + ImGui::End(); + } + + // Low durability warning — shown when any equipped item has < 20% durability + if (gameHandler.getState() == game::WorldState::IN_WORLD) { + const auto& inv = gameHandler.getInventory(); + float lowestDurPct = 1.0f; + for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty()) continue; + const auto& it = slot.item; + if (it.maxDurability > 0) { + float pct = static_cast(it.curDurability) / static_cast(it.maxDurability); + if (pct < lowestDurPct) lowestDurPct = pct; + } + } + if (lowestDurPct < 0.20f) { + bool critical = (lowestDurPct < 0.05f); + float pulse = critical + ? (0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 4.0f)) + : 1.0f; + ImVec4 durWarnColor = critical + ? ImVec4(1.0f, 0.2f, 0.2f, pulse) + : ImVec4(1.0f, 0.65f, 0.1f, 0.9f); + const char* durWarnText = critical ? "Item breaking!" : "Low durability"; + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(durWarnColor, "%s", durWarnText); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // Local time clock — always visible below minimap indicators + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + struct tm tmBuf; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockStr[16]; + snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min); + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr); + if (ImGui::IsItemHovered()) { + char fullTime[32]; + snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)", + tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec); + ImGui::SetTooltip("%s", fullTime); + } + } + ImGui::End(); } } @@ -8758,8 +21029,16 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: pos += replacement.length(); } + // Resolve class and race names for $C and $R placeholders + std::string className = "Adventurer"; + std::string raceName = "Unknown"; + if (character) { + className = game::getClassName(character->characterClass); + raceName = game::getRaceName(character->race); + } + // Replace simple placeholders. - // $n = player name + // $n/$N = player name, $c/$C = class name, $r/$R = race name // $p = subject pronoun (he/she/they) // $o = object pronoun (him/her/them) // $s = possessive adjective (his/her/their) @@ -8773,6 +21052,8 @@ std::string GameScreen::replaceGenderPlaceholders(const std::string& text, game: std::string replacement; switch (code) { case 'n': case 'N': replacement = playerName; break; + case 'c': case 'C': replacement = className; break; + case 'r': case 'R': replacement = raceName; break; case 'p': replacement = pronouns.subject; break; case 'o': replacement = pronouns.object; break; case 's': replacement = pronouns.possessive; break; @@ -8840,7 +21121,9 @@ void GameScreen::renderChatBubbles(game::GameHandler& gameHandler) { glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); float screenX = (ndc.x * 0.5f + 0.5f) * screenW; - float screenY = (1.0f - (ndc.y * 0.5f + 0.5f)) * screenH; // Flip Y + // Camera bakes the Vulkan Y-flip into the projection matrix: + // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. + float screenY = (ndc.y * 0.5f + 0.5f) * screenH; // Skip if off-screen if (screenX < -200.0f || screenX > screenW + 200.0f || @@ -8898,7 +21181,23 @@ void GameScreen::saveSettings() { out << "minimap_rotate=" << (pendingMinimapRotate ? 1 : 0) << "\n"; out << "minimap_square=" << (pendingMinimapSquare ? 1 : 0) << "\n"; out << "minimap_npc_dots=" << (pendingMinimapNpcDots ? 1 : 0) << "\n"; + out << "show_latency_meter=" << (pendingShowLatencyMeter ? 1 : 0) << "\n"; + out << "show_dps_meter=" << (showDPSMeter_ ? 1 : 0) << "\n"; + out << "show_cooldown_tracker=" << (showCooldownTracker_ ? 1 : 0) << "\n"; out << "separate_bags=" << (pendingSeparateBags ? 1 : 0) << "\n"; + out << "show_keyring=" << (pendingShowKeyring ? 1 : 0) << "\n"; + out << "action_bar_scale=" << pendingActionBarScale << "\n"; + out << "nameplate_scale=" << nameplateScale_ << "\n"; + out << "show_friendly_nameplates=" << (showFriendlyNameplates_ ? 1 : 0) << "\n"; + out << "show_action_bar2=" << (pendingShowActionBar2 ? 1 : 0) << "\n"; + out << "action_bar2_offset_x=" << pendingActionBar2OffsetX << "\n"; + out << "action_bar2_offset_y=" << pendingActionBar2OffsetY << "\n"; + out << "show_right_bar=" << (pendingShowRightBar ? 1 : 0) << "\n"; + out << "show_left_bar=" << (pendingShowLeftBar ? 1 : 0) << "\n"; + out << "right_bar_offset_y=" << pendingRightBarOffsetY << "\n"; + out << "left_bar_offset_y=" << pendingLeftBarOffsetY << "\n"; + out << "damage_flash=" << (damageFlashEnabled_ ? 1 : 0) << "\n"; + out << "low_health_vignette=" << (lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; // Audio out << "sound_muted=" << (soundMuted_ ? 1 : 0) << "\n"; @@ -8917,11 +21216,16 @@ void GameScreen::saveSettings() { // Gameplay out << "auto_loot=" << (pendingAutoLoot ? 1 : 0) << "\n"; + out << "auto_sell_grey=" << (pendingAutoSellGrey ? 1 : 0) << "\n"; + out << "auto_repair=" << (pendingAutoRepair ? 1 : 0) << "\n"; + out << "graphics_preset=" << static_cast(currentGraphicsPreset) << "\n"; out << "ground_clutter_density=" << pendingGroundClutterDensity << "\n"; out << "shadows=" << (pendingShadows ? 1 : 0) << "\n"; out << "shadow_distance=" << pendingShadowDistance << "\n"; + out << "brightness=" << pendingBrightness << "\n"; out << "water_refraction=" << (pendingWaterRefraction ? 1 : 0) << "\n"; out << "antialiasing=" << pendingAntiAliasing << "\n"; + out << "fxaa=" << (pendingFXAA ? 1 : 0) << "\n"; out << "normal_mapping=" << (pendingNormalMapping ? 1 : 0) << "\n"; out << "normal_map_strength=" << pendingNormalMapStrength << "\n"; out << "pom=" << (pendingPOM ? 1 : 0) << "\n"; @@ -8939,6 +21243,13 @@ void GameScreen::saveSettings() { out << "mouse_sensitivity=" << pendingMouseSensitivity << "\n"; out << "invert_mouse=" << (pendingInvertMouse ? 1 : 0) << "\n"; out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; + out << "fov=" << pendingFov << "\n"; + + // Quest tracker position/size + out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n"; + out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; + out << "quest_tracker_w=" << questTrackerSize_.x << "\n"; + out << "quest_tracker_h=" << questTrackerSize_.y << "\n"; // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; @@ -8950,6 +21261,11 @@ void GameScreen::saveSettings() { out << "chat_autojoin_lfg=" << (chatAutoJoinLFG_ ? 1 : 0) << "\n"; out << "chat_autojoin_local=" << (chatAutoJoinLocal_ ? 1 : 0) << "\n"; + out.close(); + + // Save keybindings to the same config file (appends [Keybindings] section) + KeybindingManager::getInstance().saveToConfigFile(path); + LOG_INFO("Settings saved to ", path); } @@ -8985,9 +21301,43 @@ void GameScreen::loadSettings() { int v = std::stoi(val); minimapNpcDots_ = (v != 0); pendingMinimapNpcDots = minimapNpcDots_; + } else if (key == "show_latency_meter") { + showLatencyMeter_ = (std::stoi(val) != 0); + pendingShowLatencyMeter = showLatencyMeter_; + } else if (key == "show_dps_meter") { + showDPSMeter_ = (std::stoi(val) != 0); + } else if (key == "show_cooldown_tracker") { + showCooldownTracker_ = (std::stoi(val) != 0); } else if (key == "separate_bags") { pendingSeparateBags = (std::stoi(val) != 0); inventoryScreen.setSeparateBags(pendingSeparateBags); + } else if (key == "show_keyring") { + pendingShowKeyring = (std::stoi(val) != 0); + inventoryScreen.setShowKeyring(pendingShowKeyring); + } else if (key == "action_bar_scale") { + pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); + } else if (key == "nameplate_scale") { + nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); + } else if (key == "show_friendly_nameplates") { + showFriendlyNameplates_ = (std::stoi(val) != 0); + } else if (key == "show_action_bar2") { + pendingShowActionBar2 = (std::stoi(val) != 0); + } else if (key == "action_bar2_offset_x") { + pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); + } else if (key == "action_bar2_offset_y") { + pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "show_right_bar") { + pendingShowRightBar = (std::stoi(val) != 0); + } else if (key == "show_left_bar") { + pendingShowLeftBar = (std::stoi(val) != 0); + } else if (key == "right_bar_offset_y") { + pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "left_bar_offset_y") { + pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "damage_flash") { + damageFlashEnabled_ = (std::stoi(val) != 0); + } else if (key == "low_health_vignette") { + lowHealthVignetteEnabled_ = (std::stoi(val) != 0); } // Audio else if (key == "sound_muted") { @@ -9011,11 +21361,24 @@ void GameScreen::loadSettings() { else if (key == "activity_volume") pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); // Gameplay else if (key == "auto_loot") pendingAutoLoot = (std::stoi(val) != 0); + else if (key == "auto_sell_grey") pendingAutoSellGrey = (std::stoi(val) != 0); + else if (key == "auto_repair") pendingAutoRepair = (std::stoi(val) != 0); + else if (key == "graphics_preset") { + int presetVal = std::clamp(std::stoi(val), 0, 4); + currentGraphicsPreset = static_cast(presetVal); + pendingGraphicsPreset = currentGraphicsPreset; + } else if (key == "ground_clutter_density") pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); else if (key == "shadows") pendingShadows = (std::stoi(val) != 0); else if (key == "shadow_distance") pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); + else if (key == "brightness") { + pendingBrightness = std::clamp(std::stoi(val), 0, 100); + if (auto* r = core::Application::getInstance().getRenderer()) + r->setBrightness(static_cast(pendingBrightness) / 50.0f); + } else if (key == "water_refraction") pendingWaterRefraction = (std::stoi(val) != 0); else if (key == "antialiasing") pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "fxaa") pendingFXAA = (std::stoi(val) != 0); else if (key == "normal_mapping") pendingNormalMapping = (std::stoi(val) != 0); else if (key == "normal_map_strength") pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); else if (key == "pom") pendingPOM = (std::stoi(val) != 0); @@ -9038,6 +21401,31 @@ void GameScreen::loadSettings() { else if (key == "mouse_sensitivity") pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); else if (key == "invert_mouse") pendingInvertMouse = (std::stoi(val) != 0); else if (key == "extended_zoom") pendingExtendedZoom = (std::stoi(val) != 0); + else if (key == "fov") { + pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f); + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); + } + } + // Quest tracker position/size + else if (key == "quest_tracker_x") { + // Legacy: ignore absolute X (right_offset supersedes it) + (void)val; + } + else if (key == "quest_tracker_right_offset") { + questTrackerRightOffset_ = std::stof(val); + questTrackerPosInit_ = true; + } + else if (key == "quest_tracker_y") { + questTrackerPos_.y = std::stof(val); + questTrackerPosInit_ = true; + } + else if (key == "quest_tracker_w") { + questTrackerSize_.x = std::max(100.0f, std::stof(val)); + } + else if (key == "quest_tracker_h") { + questTrackerSize_.y = std::max(60.0f, std::stof(val)); + } // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); @@ -9049,6 +21437,10 @@ void GameScreen::loadSettings() { else if (key == "chat_autojoin_local") chatAutoJoinLocal_ = (std::stoi(val) != 0); } catch (...) {} } + + // Load keybindings from the same config file + KeybindingManager::getInstance().loadFromConfigFile(path); + LOG_INFO("Settings loaded from ", path); } @@ -9074,7 +21466,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { uint32_t mg = static_cast(money / 10000); uint32_t ms = static_cast((money / 100) % 100); uint32_t mc = static_cast(money % 100); - ImGui::Text("Your money: %ug %us %uc", mg, ms, mc); + ImGui::TextDisabled("Your money:"); ImGui::SameLine(0, 4); + renderCoinsText(mg, ms, mc); ImGui::SameLine(ImGui::GetWindowWidth() - 100); if (ImGui::Button("Compose")) { mailRecipientBuffer_[0] = '\0'; @@ -9129,6 +21522,21 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.5f, 0.8f, 1.0f, 1.0f), " [A]"); } + // Expiry warning if within 3 days + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + if (secsLeft < 3.0f * 86400.0f && secsLeft > 0.0f) { + ImGui::SameLine(); + int daysLeft = static_cast(secsLeft / 86400.0f); + if (daysLeft == 0) { + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), " [expires today!]"); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.1f, 1.0f), + " [expires in %dd]", daysLeft); + } + } + } ImGui::PopID(); } @@ -9149,6 +21557,35 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { if (mail.messageType == 2) { ImGui::TextColored(ImVec4(0.8f, 0.6f, 0.2f, 1.0f), "[Auction House]"); } + + // Show expiry date in the detail panel + if (mail.expirationTime > 0.0f) { + auto nowSec = static_cast(std::time(nullptr)); + float secsLeft = mail.expirationTime - nowSec; + // Format absolute expiry as a date using struct tm + time_t expT = static_cast(mail.expirationTime); + struct tm* tmExp = std::localtime(&expT); + if (tmExp) { + static const char* kMon[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + const char* mname = kMon[tmExp->tm_mon]; + int daysLeft = static_cast(secsLeft / 86400.0f); + if (secsLeft <= 0.0f) { + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), + "Expired: %s %d, %d", mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } else if (secsLeft < 3.0f * 86400.0f) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), + "Expires: %s %d, %d (%d day%s!)", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year, + daysLeft, daysLeft == 1 ? "" : "s"); + } else { + ImGui::TextDisabled("Expires: %s %d, %d", + mname, tmExp->tm_mday, 1900 + tmExp->tm_year); + } + } + } ImGui::Separator(); // Body text @@ -9162,7 +21599,8 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { uint32_t g = mail.money / 10000; uint32_t s = (mail.money / 100) % 100; uint32_t c = mail.money % 100; - ImGui::Text("Money: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Money:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); ImGui::SameLine(); if (ImGui::SmallButton("Take Money")) { gameHandler.mailTakeMoney(mail.messageId); @@ -9181,24 +21619,95 @@ void GameScreen::renderMailWindow(game::GameHandler& gameHandler) { // Attachments if (!mail.attachments.empty()) { ImGui::Text("Attachments: %zu", mail.attachments.size()); + ImDrawList* mailDraw = ImGui::GetWindowDrawList(); + constexpr float MAIL_SLOT = 34.0f; for (size_t j = 0; j < mail.attachments.size(); ++j) { const auto& att = mail.attachments[j]; ImGui::PushID(static_cast(j)); auto* info = gameHandler.getItemInfo(att.itemId); + game::ItemQuality quality = game::ItemQuality::COMMON; + std::string name = "Item " + std::to_string(att.itemId); + uint32_t displayInfoId = 0; if (info && info->valid) { - ImGui::BulletText("%s x%u", info->name.c_str(), att.stackCount); + quality = static_cast(info->quality); + name = info->name; + displayInfoId = info->displayInfoId; } else { - ImGui::BulletText("Item %u x%u", att.itemId, att.stackCount); gameHandler.ensureItemInfo(att.itemId); } + ImVec4 qc = InventoryScreen::getQualityColor(quality); + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + + ImVec2 pos = ImGui::GetCursorScreenPos(); + VkDescriptorSet iconTex = displayInfoId + ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; + if (iconTex) { + mailDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT)); + mailDraw->AddRect(pos, ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + borderCol, 0.0f, 0, 1.5f); + } else { + mailDraw->AddRectFilled(pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + IM_COL32(40, 35, 30, 220)); + mailDraw->AddRect(pos, + ImVec2(pos.x + MAIL_SLOT, pos.y + MAIL_SLOT), + borderCol, 0.0f, 0, 1.5f); + } + if (att.stackCount > 1) { + char cnt[16]; + snprintf(cnt, sizeof(cnt), "%u", att.stackCount); + float cw = ImGui::CalcTextSize(cnt).x; + mailDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), + IM_COL32(0, 0, 0, 200), cnt); + mailDraw->AddText( + ImVec2(pos.x + MAIL_SLOT - cw - 2.0f, pos.y + MAIL_SLOT - 14.0f), + IM_COL32(255, 255, 255, 220), cnt); + } + + ImGui::InvisibleButton("##mailatt", ImVec2(MAIL_SLOT, MAIL_SLOT)); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } + ImGui::SameLine(); + ImGui::TextColored(qc, "%s", name.c_str()); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } ImGui::SameLine(); if (ImGui::SmallButton("Take")) { - gameHandler.mailTakeItem(mail.messageId, att.slot); + gameHandler.mailTakeItem(mail.messageId, att.itemGuidLow); } ImGui::PopID(); } + // "Take All" button when there are multiple attachments + if (mail.attachments.size() > 1) { + if (ImGui::SmallButton("Take All")) { + for (const auto& att2 : mail.attachments) { + gameHandler.mailTakeItem(mail.messageId, att2.itemGuidLow); + } + } + } } ImGui::Spacing(); @@ -9480,10 +21989,32 @@ void GameScreen::renderBankWindow(game::GameHandler& gameHandler) { // Tooltip if (ImGui::IsItemHovered() && !isHolding) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", item.name.c_str()); - if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); - ImGui::EndTooltip(); + auto* info = gameHandler.getItemInfo(item.itemId); + if (info && info->valid) + inventoryScreen.renderItemTooltip(*info); + else { + ImGui::BeginTooltip(); + ImGui::TextColored(qc, "%s", item.name.c_str()); + ImGui::EndTooltip(); + } + + // Shift-click to insert item link into chat + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift + && !item.name.empty()) { + auto* info2 = gameHandler.getItemInfo(item.itemId); + uint8_t q = (info2 && info2->valid) + ? static_cast(info2->quality) + : static_cast(item.quality); + const std::string& lname = (info2 && info2->valid && !info2->name.empty()) + ? info2->name : item.name; + std::string link = buildItemChatLink(item.itemId, q, lname); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } } }; @@ -9565,9 +22096,8 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { uint32_t gold = static_cast(data.money / 10000); uint32_t silver = static_cast((data.money / 100) % 100); uint32_t copper = static_cast(data.money % 100); - ImGui::Text("Guild Bank Money: "); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.9f, 0.8f, 0.3f, 1.0f), "%ug %us %uc", gold, silver, copper); + ImGui::TextDisabled("Guild Bank Money:"); ImGui::SameLine(0, 4); + renderCoinsText(gold, silver, copper); // Tab bar if (!data.tabs.empty()) { @@ -9594,35 +22124,81 @@ void GameScreen::renderGuildBankWindow(game::GameHandler& gameHandler) { ImGui::Separator(); // Tab items (98 slots = 14 columns × 7 rows) + constexpr float GB_SLOT = 34.0f; + ImDrawList* gbDraw = ImGui::GetWindowDrawList(); for (size_t i = 0; i < data.tabItems.size(); i++) { - if (i % 14 != 0) ImGui::SameLine(); + if (i % 14 != 0) ImGui::SameLine(0.0f, 2.0f); const auto& item = data.tabItems[i]; ImGui::PushID(static_cast(i) + 5000); + ImVec2 pos = ImGui::GetCursorScreenPos(); + if (item.itemEntry == 0) { - ImGui::Button("##gb", ImVec2(34, 34)); + gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(30, 30, 30, 200)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(60, 60, 60, 180)); + ImGui::InvisibleButton("##gbempty", ImVec2(GB_SLOT, GB_SLOT)); } else { auto* info = gameHandler.getItemInfo(item.itemEntry); game::ItemQuality quality = game::ItemQuality::COMMON; std::string name = "Item " + std::to_string(item.itemEntry); + uint32_t displayInfoId = 0; if (info) { quality = static_cast(info->quality); name = info->name; + displayInfoId = info->displayInfoId; } ImVec4 qc = InventoryScreen::getQualityColor(quality); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(qc.x * 0.3f, qc.y * 0.3f, qc.z * 0.3f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(qc.x * 0.5f, qc.y * 0.5f, qc.z * 0.5f, 0.9f)); - std::string lbl = item.stackCount > 1 ? std::to_string(item.stackCount) : ("##gi" + std::to_string(i)); - if (ImGui::Button(lbl.c_str(), ImVec2(34, 34))) { - // Withdraw: auto-store to first free bag slot + ImU32 borderCol = ImGui::ColorConvertFloat4ToU32(qc); + + VkDescriptorSet iconTex = displayInfoId ? inventoryScreen.getItemIcon(displayInfoId) : VK_NULL_HANDLE; + if (iconTex) { + gbDraw->AddImage((ImTextureID)(uintptr_t)iconTex, pos, + ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + borderCol, 0.0f, 0, 1.5f); + } else { + gbDraw->AddRectFilled(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + IM_COL32(40, 35, 30, 220)); + gbDraw->AddRect(pos, ImVec2(pos.x + GB_SLOT, pos.y + GB_SLOT), + borderCol, 0.0f, 0, 1.5f); + if (!name.empty() && name[0] != 'I') { + char abbr[3] = { name[0], name.size() > 1 ? name[1] : '\0', '\0' }; + float tw = ImGui::CalcTextSize(abbr).x; + gbDraw->AddText(ImVec2(pos.x + (GB_SLOT - tw) * 0.5f, pos.y + 2.0f), + borderCol, abbr); + } + } + + if (item.stackCount > 1) { + char cnt[16]; + snprintf(cnt, sizeof(cnt), "%u", item.stackCount); + float cw = ImGui::CalcTextSize(cnt).x; + gbDraw->AddText(ImVec2(pos.x + 1.0f, pos.y + 1.0f), IM_COL32(0, 0, 0, 200), cnt); + gbDraw->AddText(ImVec2(pos.x + GB_SLOT - cw - 2.0f, pos.y + GB_SLOT - 14.0f), + IM_COL32(255, 255, 255, 220), cnt); + } + + ImGui::InvisibleButton("##gbslot", ImVec2(GB_SLOT, GB_SLOT)); + if (ImGui::IsItemClicked(ImGuiMouseButton_Left) && !ImGui::GetIO().KeyShift) { gameHandler.guildBankWithdrawItem(activeTab, item.slotId, 0xFF, 0); } - ImGui::PopStyleColor(2); if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", name.c_str()); - if (item.stackCount > 1) ImGui::Text("Count: %u", item.stackCount); - ImGui::EndTooltip(); + if (info && info->valid) + inventoryScreen.renderItemTooltip(*info); + // Shift-click to insert item link into chat + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && ImGui::GetIO().KeyShift + && !name.empty() && item.itemEntry != 0) { + uint8_t q = static_cast(quality); + std::string link = buildItemChatLink(item.itemEntry, q, name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; + } + } } } ImGui::PopID(); @@ -9757,7 +22333,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { gameHandler.auctionSearch(auctionSearchName_, static_cast(auctionLevelMin_), static_cast(auctionLevelMax_), - q, getSearchClassId(), getSearchSubClassId(), 0, 0, offset); + q, getSearchClassId(), getSearchSubClassId(), 0, + auctionUsableOnly_ ? 1 : 0, offset); }; // Row 1: Name + Level range @@ -9803,6 +22380,8 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } + ImGui::SameLine(); + ImGui::Checkbox("Usable", &auctionUsableOnly_); ImGui::SameLine(); float delay = gameHandler.getAuctionSearchDelay(); if (delay > 0.0f) { @@ -9862,6 +22441,12 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { const auto& auction = results.auctions[i]; auto* info = gameHandler.getItemInfo(auction.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(auction.itemEntry)); + // Append random suffix name (e.g., "of the Eagle") if present + if (auction.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(auction.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; ImVec4 qc = InventoryScreen::getQualityColor(quality); @@ -9876,37 +22461,19 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { } } ImGui::TextColored(qc, "%s", name.c_str()); - // Item tooltip on hover + // Item tooltip on hover; shift-click to insert chat link if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(qc, "%s", info->name.c_str()); - if (info->inventoryType > 0) { - if (!info->subclassName.empty()) - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1), "%s", info->subclassName.c_str()); + inventoryScreen.renderItemTooltip(*info); + } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; } - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); - } - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); - std::string bonusLine; - auto appendStat = [](std::string& out, int32_t val, const char* n) { - if (val <= 0) return; - if (!out.empty()) out += " "; - out += "+" + std::to_string(val) + " " + n; - }; - appendStat(bonusLine, info->strength, "Str"); - appendStat(bonusLine, info->agility, "Agi"); - appendStat(bonusLine, info->stamina, "Sta"); - appendStat(bonusLine, info->intellect, "Int"); - appendStat(bonusLine, info->spirit, "Spi"); - if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); - if (info->sellPrice > 0) { - ImGui::TextColored(ImVec4(1, 0.84f, 0, 1), "Sell: %ug %us %uc", - info->sellPrice / 10000, (info->sellPrice / 100) % 100, info->sellPrice % 100); - } - ImGui::EndTooltip(); } ImGui::TableSetColumnIndex(1); @@ -9922,13 +22489,13 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::TableSetColumnIndex(3); { uint32_t bid = auction.currentBid > 0 ? auction.currentBid : auction.startBid; - ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100); + renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(4); if (auction.buyoutPrice > 0) { - ImGui::Text("%ug%us%uc", auction.buyoutPrice / 10000, - (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); + renderCoinsText(auction.buyoutPrice / 10000, + (auction.buyoutPrice / 100) % 100, auction.buyoutPrice % 100); } else { ImGui::TextDisabled("--"); } @@ -10073,6 +22640,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { const auto& a = results.auctions[bi]; auto* info = gameHandler.getItemInfo(a.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); + if (a.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(a.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; ImVec4 bqc = InventoryScreen::getQualityColor(quality); @@ -10085,38 +22657,36 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); } } + // High bidder indicator + bool isHighBidder = (a.bidderGuid != 0 && a.bidderGuid == gameHandler.getPlayerGuid()); + if (isHighBidder) { + ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.2f, 1.0f), "[Winning]"); + ImGui::SameLine(); + } else if (a.bidderGuid != 0) { + ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[Outbid]"); + ImGui::SameLine(); + } ImGui::TextColored(bqc, "%s", name.c_str()); - // Tooltip - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(bqc, "%s", info->name.c_str()); - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + // Tooltip and shift-click + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; } - std::string bl; - auto appS = [](std::string& o, int32_t v, const char* n) { - if (v <= 0) return; - if (!o.empty()) o += " "; - o += "+" + std::to_string(v) + " " + n; - }; - appS(bl, info->strength, "Str"); appS(bl, info->agility, "Agi"); - appS(bl, info->stamina, "Sta"); appS(bl, info->intellect, "Int"); - appS(bl, info->spirit, "Spi"); - if (!bl.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", bl.c_str()); - if (info->sellPrice > 0) - ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", - info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); - ImGui::EndTooltip(); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); - ImGui::Text("%ug%us%uc", a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); + renderCoinsText(a.currentBid / 10000, (a.currentBid / 100) % 100, a.currentBid % 100); ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) - ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); + renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); @@ -10159,6 +22729,11 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { const auto& a = results.auctions[i]; auto* info = gameHandler.getItemInfo(a.itemEntry); std::string name = info ? info->name : ("Item #" + std::to_string(a.itemEntry)); + if (a.randomPropertyId != 0) { + std::string suffix = gameHandler.getRandomPropertyName( + static_cast(a.randomPropertyId)); + if (!suffix.empty()) name += " " + suffix; + } game::ItemQuality quality = info ? static_cast(info->quality) : game::ItemQuality::COMMON; ImGui::TableNextRow(); @@ -10171,40 +22746,34 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { ImGui::SameLine(); } } + // Bid activity indicator for seller + if (a.bidderGuid != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "[Bid]"); + ImGui::SameLine(); + } ImGui::TextColored(oqc, "%s", name.c_str()); - if (ImGui::IsItemHovered() && info && info->valid) { - ImGui::BeginTooltip(); - ImGui::TextColored(oqc, "%s", info->name.c_str()); - if (info->armor > 0) ImGui::Text("%d Armor", info->armor); - if (info->damageMax > 0.0f && info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - ImGui::Text("%.0f - %.0f Damage Speed %.2f", info->damageMin, info->damageMax, speed); + if (ImGui::IsItemHovered() && info && info->valid) + inventoryScreen.renderItemTooltip(*info); + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && info && info->valid && !info->name.empty()) { + std::string link = buildItemChatLink(info->entry, info->quality, info->name); + size_t curLen = strlen(chatInputBuffer); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer)) { + strncat(chatInputBuffer, link.c_str(), sizeof(chatInputBuffer) - curLen - 1); + chatInputMoveCursorToEnd = true; + refocusChatInput = true; } - std::string ol; - auto appO = [](std::string& o, int32_t v, const char* n) { - if (v <= 0) return; - if (!o.empty()) o += " "; - o += "+" + std::to_string(v) + " " + n; - }; - appO(ol, info->strength, "Str"); appO(ol, info->agility, "Agi"); - appO(ol, info->stamina, "Sta"); appO(ol, info->intellect, "Int"); - appO(ol, info->spirit, "Spi"); - if (!ol.empty()) ImGui::TextColored(ImVec4(0,1,0,1), "%s", ol.c_str()); - if (info->sellPrice > 0) - ImGui::TextColored(ImVec4(1,0.84f,0,1), "Sell: %ug %us %uc", - info->sellPrice/10000, (info->sellPrice/100)%100, info->sellPrice%100); - ImGui::EndTooltip(); } ImGui::TableSetColumnIndex(1); ImGui::Text("%u", a.stackCount); ImGui::TableSetColumnIndex(2); { uint32_t bid = a.currentBid > 0 ? a.currentBid : a.startBid; - ImGui::Text("%ug%us%uc", bid / 10000, (bid / 100) % 100, bid % 100); + renderCoinsText(bid / 10000, (bid / 100) % 100, bid % 100); } ImGui::TableSetColumnIndex(3); if (a.buyoutPrice > 0) - ImGui::Text("%ug%us%uc", a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); + renderCoinsText(a.buyoutPrice / 10000, (a.buyoutPrice / 100) % 100, a.buyoutPrice % 100); else ImGui::TextDisabled("--"); ImGui::TableSetColumnIndex(4); @@ -10227,9 +22796,18 @@ void GameScreen::renderAuctionHouseWindow(game::GameHandler& gameHandler) { // Level-Up Ding Animation // ============================================================ -void GameScreen::triggerDing(uint32_t newLevel) { - dingTimer_ = DING_DURATION; - dingLevel_ = newLevel; +void GameScreen::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t manaDelta, + uint32_t str, uint32_t agi, uint32_t sta, + uint32_t intel, uint32_t spi) { + dingTimer_ = DING_DURATION; + dingLevel_ = newLevel; + dingHpDelta_ = hpDelta; + dingManaDelta_ = manaDelta; + dingStats_[0] = str; + dingStats_[1] = agi; + dingStats_[2] = sta; + dingStats_[3] = intel; + dingStats_[4] = spi; auto* renderer = core::Application::getInstance().getRenderer(); if (renderer) { @@ -10247,81 +22825,76 @@ void GameScreen::renderDingEffect() { dingTimer_ -= dt; if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; - float alpha = dingTimer_ < 0.8f ? (dingTimer_ / 0.8f) : 1.0f; // fade out last 0.8s - float elapsed = DING_DURATION - dingTimer_; // 0 → DING_DURATION + // Show "You have reached level X!" for the first 2.5s, fade out over last 0.5s. + // The 3D visual effect is handled by Renderer::triggerLevelUpEffect (LevelUp.m2). + constexpr float kFadeTime = 0.5f; + float alpha = dingTimer_ < kFadeTime ? (dingTimer_ / kFadeTime) : 1.0f; + if (alpha <= 0.0f) return; ImGuiIO& io = ImGui::GetIO(); float cx = io.DisplaySize.x * 0.5f; - float cy = io.DisplaySize.y * 0.5f; + float cy = io.DisplaySize.y * 0.38f; // Upper-center, like WoW ImDrawList* draw = ImGui::GetForegroundDrawList(); + ImFont* font = ImGui::GetFont(); + float baseSize = ImGui::GetFontSize(); + float fontSize = baseSize * 1.8f; - // ---- Golden radial ring burst (3 waves staggered by 0.45s) ---- - { - constexpr float kMaxRadius = 420.0f; - constexpr float kRingWidth = 18.0f; - constexpr float kWaveLen = 1.4f; // each wave lasts 1.4s - constexpr int kNumWaves = 3; - constexpr float kStagger = 0.45f; // seconds between waves + char buf[64]; + snprintf(buf, sizeof(buf), "You have reached level %u!", dingLevel_); - for (int w = 0; w < kNumWaves; ++w) { - float waveElapsed = elapsed - w * kStagger; - if (waveElapsed <= 0.0f || waveElapsed >= kWaveLen) continue; + ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); + float tx = cx - sz.x * 0.5f; + float ty = cy - sz.y * 0.5f; - float t = waveElapsed / kWaveLen; // 0 → 1 - float radius = t * kMaxRadius; - float ringAlpha = (1.0f - t) * alpha; // fades as it expands + // Slight black outline for readability + draw->AddText(font, fontSize, ImVec2(tx + 2, ty + 2), + IM_COL32(0, 0, 0, (int)(alpha * 180)), buf); + // Gold text + draw->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); - ImU32 outerCol = IM_COL32(255, 215, 60, (int)(ringAlpha * 200)); - ImU32 innerCol = IM_COL32(255, 255, 150, (int)(ringAlpha * 120)); + // Stat gains below the main text (shown only if server sent deltas) + bool hasStatGains = (dingHpDelta_ > 0 || dingManaDelta_ > 0 || + dingStats_[0] || dingStats_[1] || dingStats_[2] || + dingStats_[3] || dingStats_[4]); + if (hasStatGains) { + float smallSize = baseSize * 0.95f; + float yOff = ty + sz.y + 6.0f; - draw->AddCircle(ImVec2(cx, cy), radius, outerCol, 64, kRingWidth); - draw->AddCircle(ImVec2(cx, cy), radius * 0.92f, innerCol, 64, kRingWidth * 0.5f); + // Build stat delta string: "+150 HP +80 Mana +2 Str +2 Agi ..." + static const char* kStatLabels[] = { "Str", "Agi", "Sta", "Int", "Spi" }; + char statBuf[128]; + int written = 0; + if (dingHpDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u HP ", dingHpDelta_); + if (dingManaDelta_ > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u Mana ", dingManaDelta_); + for (int i = 0; i < 5 && written < (int)sizeof(statBuf) - 1; ++i) { + if (dingStats_[i] > 0) + written += snprintf(statBuf + written, sizeof(statBuf) - written, + "+%u %s ", dingStats_[i], kStatLabels[i]); } - } + // Trim trailing spaces + while (written > 0 && statBuf[written - 1] == ' ') --written; + statBuf[written] = '\0'; - // ---- Full-screen golden flash on first frame ---- - if (elapsed < 0.15f) { - float flashA = (1.0f - elapsed / 0.15f) * 0.45f; - draw->AddRectFilled(ImVec2(0, 0), io.DisplaySize, - IM_COL32(255, 200, 50, (int)(flashA * 255))); - } - - // "LEVEL X!" text — visible for first 2.2s - if (dingTimer_ > 0.8f) { - ImFont* font = ImGui::GetFont(); - float baseSize = ImGui::GetFontSize(); - float fontSize = baseSize * 2.8f; - - char buf[32]; - snprintf(buf, sizeof(buf), "LEVEL %u!", dingLevel_); - - ImVec2 sz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, buf); - float tx = cx - sz.x * 0.5f; - float ty = cy - sz.y * 0.5f - 20.0f; - - // Drop shadow - draw->AddText(font, fontSize, ImVec2(tx + 3, ty + 3), - IM_COL32(0, 0, 0, (int)(alpha * 200)), buf); - // Gold text - draw->AddText(font, fontSize, ImVec2(tx, ty), - IM_COL32(255, 215, 0, (int)(alpha * 255)), buf); - - // "DING!" subtitle - const char* ding = "DING!"; - float dingSize = baseSize * 1.8f; - ImVec2 dingSz = font->CalcTextSizeA(dingSize, FLT_MAX, 0.0f, ding); - float dx = cx - dingSz.x * 0.5f; - float dy = ty + sz.y + 6.0f; - draw->AddText(font, dingSize, ImVec2(dx + 2, dy + 2), - IM_COL32(0, 0, 0, (int)(alpha * 180)), ding); - draw->AddText(font, dingSize, ImVec2(dx, dy), - IM_COL32(255, 255, 150, (int)(alpha * 255)), ding); + if (written > 0) { + ImVec2 ssz = font->CalcTextSizeA(smallSize, FLT_MAX, 0.0f, statBuf); + float stx = cx - ssz.x * 0.5f; + draw->AddText(font, smallSize, ImVec2(stx + 1, yOff + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), statBuf); + draw->AddText(font, smallSize, ImVec2(stx, yOff), + IM_COL32(100, 220, 100, (int)(alpha * 230)), statBuf); + } } } -void GameScreen::triggerAchievementToast(uint32_t achievementId) { +void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { achievementToastId_ = achievementId; + achievementToastName_ = std::move(name); achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; // Play a UI sound if available @@ -10380,9 +22953,15 @@ void GameScreen::renderAchievementToast() { draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), IM_COL32(255, 215, 0, (int)(alpha * 255)), title); - // Achievement ID line (until we have Achievement.dbc name lookup) - char idBuf[64]; - std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + // Achievement name (falls back to ID if name not available) + char idBuf[256]; + const char* achText = achievementToastName_.empty() + ? nullptr : achievementToastName_.c_str(); + if (achText) { + std::snprintf(idBuf, sizeof(idBuf), "%s", achText); + } else { + std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + } float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; float idX = toastX + (TOAST_W - idW) * 0.5f; draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), @@ -10390,18 +22969,578 @@ void GameScreen::renderAchievementToast() { } // --------------------------------------------------------------------------- +// Area discovery toast — "Discovered: ! (+XP XP)" centered on screen +// --------------------------------------------------------------------------- + +void GameScreen::renderDiscoveryToast() { + if (discoveryToastTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + discoveryToastTimer_ -= dt; + if (discoveryToastTimer_ < 0.0f) discoveryToastTimer_ = 0.0f; + + // Fade: ramp up in first 0.4s, hold, fade out in last 1.0s + float alpha; + if (discoveryToastTimer_ > DISCOVERY_TOAST_DURATION - 0.4f) + alpha = 1.0f - (discoveryToastTimer_ - (DISCOVERY_TOAST_DURATION - 0.4f)) / 0.4f; + else if (discoveryToastTimer_ < 1.0f) + alpha = discoveryToastTimer_; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImFont* font = ImGui::GetFont(); + ImDrawList* draw = ImGui::GetForegroundDrawList(); + + const char* header = "Discovered!"; + float headerSize = 16.0f; + float nameSize = 28.0f; + float xpSize = 14.0f; + + ImVec2 headerDim = font->CalcTextSizeA(headerSize, FLT_MAX, 0.0f, header); + ImVec2 nameDim = font->CalcTextSizeA(nameSize, FLT_MAX, 0.0f, discoveryToastName_.c_str()); + + char xpBuf[48]; + if (discoveryToastXP_ > 0) + snprintf(xpBuf, sizeof(xpBuf), "+%u XP", discoveryToastXP_); + else + xpBuf[0] = '\0'; + ImVec2 xpDim = font->CalcTextSizeA(xpSize, FLT_MAX, 0.0f, xpBuf); + + // Position slightly below zone text (at 37% down screen) + float centreY = screenH * 0.37f; + float headerX = (screenW - headerDim.x) * 0.5f; + float nameX = (screenW - nameDim.x) * 0.5f; + float xpX = (screenW - xpDim.x) * 0.5f; + float headerY = centreY; + float nameY = centreY + headerDim.y + 4.0f; + float xpY = nameY + nameDim.y + 4.0f; + + // "Discovered!" in gold + draw->AddText(font, headerSize, ImVec2(headerX + 1, headerY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), header); + draw->AddText(font, headerSize, ImVec2(headerX, headerY), + IM_COL32(255, 215, 0, (int)(alpha * 255)), header); + + // Area name in white + draw->AddText(font, nameSize, ImVec2(nameX + 1, nameY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 160)), discoveryToastName_.c_str()); + draw->AddText(font, nameSize, ImVec2(nameX, nameY), + IM_COL32(255, 255, 255, (int)(alpha * 255)), discoveryToastName_.c_str()); + + // XP gain in light green (if any) + if (xpBuf[0] != '\0') { + draw->AddText(font, xpSize, ImVec2(xpX + 1, xpY + 1), + IM_COL32(0, 0, 0, (int)(alpha * 140)), xpBuf); + draw->AddText(font, xpSize, ImVec2(xpX, xpY), + IM_COL32(100, 220, 100, (int)(alpha * 230)), xpBuf); + } +} + +// --------------------------------------------------------------------------- +// Quest objective progress toasts — shown at screen bottom-right on kill/item updates +// --------------------------------------------------------------------------- + +void GameScreen::renderQuestProgressToasts() { + if (questToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : questToasts_) t.age += dt; + questToasts_.erase( + std::remove_if(questToasts_.begin(), questToasts_.end(), + [](const QuestProgressToastEntry& t) { return t.age >= QUEST_TOAST_DURATION; }), + questToasts_.end()); + if (questToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack at bottom-right, just above action bar area + constexpr float TOAST_W = 240.0f; + constexpr float TOAST_H = 48.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = screenW - TOAST_W - 14.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(questToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = questToasts_[i]; + + float remaining = QUEST_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark amber tint (quest color convention) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(35, 25, 5, bgA), 5.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 160, 30, static_cast(160 * alpha)), 5.0f, 0, 1.5f); + + // Quest title (gold, small) + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 5.0f), + IM_COL32(220, 180, 50, fgA), toast.questTitle.c_str()); + + // Progress bar + text: "ObjectiveName X / Y" + float barY = ty + 21.0f; + float barX0 = toastX + 8.0f; + float barX1 = toastX + TOAST_W - 8.0f; + float barH = 8.0f; + float pct = (toast.required > 0) + ? std::min(1.0f, static_cast(toast.current) / static_cast(toast.required)) + : 1.0f; + // Bar background + bgDL->AddRectFilled(ImVec2(barX0, barY), ImVec2(barX1, barY + barH), + IM_COL32(50, 40, 10, static_cast(180 * alpha)), 3.0f); + // Bar fill — green when complete, amber otherwise + ImU32 barCol = (pct >= 1.0f) ? IM_COL32(60, 220, 80, fgA) : IM_COL32(200, 160, 30, fgA); + bgDL->AddRectFilled(ImVec2(barX0, barY), + ImVec2(barX0 + (barX1 - barX0) * pct, barY + barH), + barCol, 3.0f); + + // Objective name + count + char progBuf[48]; + if (!toast.objectiveName.empty()) + snprintf(progBuf, sizeof(progBuf), "%.22s: %u/%u", + toast.objectiveName.c_str(), toast.current, toast.required); + else + snprintf(progBuf, sizeof(progBuf), "%u/%u", toast.current, toast.required); + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 32.0f), + IM_COL32(220, 220, 200, static_cast(210 * alpha)), progBuf); + } +} + +// --------------------------------------------------------------------------- +// Item loot toasts — quality-coloured strip at bottom-left when item received +// --------------------------------------------------------------------------- + +void GameScreen::renderItemLootToasts() { + if (itemLootToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : itemLootToasts_) t.age += dt; + itemLootToasts_.erase( + std::remove_if(itemLootToasts_.begin(), itemLootToasts_.end(), + [](const ItemLootToastEntry& t) { return t.age >= ITEM_LOOT_TOAST_DURATION; }), + itemLootToasts_.end()); + if (itemLootToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Quality colours (matching WoW convention) + static const ImU32 kQualityColors[] = { + IM_COL32(157, 157, 157, 255), // 0 grey (poor) + IM_COL32(255, 255, 255, 255), // 1 white (common) + IM_COL32( 30, 255, 30, 255), // 2 green (uncommon) + IM_COL32( 0, 112, 221, 255), // 3 blue (rare) + IM_COL32(163, 53, 238, 255), // 4 purple (epic) + IM_COL32(255, 128, 0, 255), // 5 orange (legendary) + IM_COL32(230, 204, 128, 255), // 6 light gold (artifact) + IM_COL32(230, 204, 128, 255), // 7 light gold (heirloom) + }; + + // Stack at bottom-left above action bars; each item is 24 px tall + constexpr float TOAST_W = 260.0f; + constexpr float TOAST_H = 24.0f; + constexpr float TOAST_GAP = 2.0f; + constexpr float TOAST_X = 14.0f; + float baseY = screenH * 0.68f; // slightly above the whisper toasts + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(itemLootToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = itemLootToasts_[i]; + + float remaining = ITEM_LOOT_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.7f) + alpha = remaining / 0.7f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left + float slideX = (toast.age < 0.15f) ? (TOAST_W * (1.0f - toast.age / 0.15f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(180 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: very dark with quality-tinted left border accent + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(12, 12, 12, bgA), 3.0f); + + // Quality colour accent bar on left edge (3px wide) + ImU32 qualCol = kQualityColors[std::min(static_cast(7u), toast.quality)]; + ImU32 qualColA = (qualCol & 0x00FFFFFFu) | (static_cast(fgA) << 24u); + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + 3.0f, ty + TOAST_H), qualColA, 3.0f); + + // "Loot:" label in dim white + bgDL->AddText(ImVec2(tx + 7.0f, ty + 5.0f), + IM_COL32(160, 160, 160, static_cast(200 * alpha)), "Loot:"); + + // Item name in quality colour + std::string displayName = toast.name.empty() ? ("Item #" + std::to_string(toast.itemId)) : toast.name; + if (displayName.size() > 26) { displayName.resize(23); displayName += "..."; } + bgDL->AddText(ImVec2(tx + 42.0f, ty + 5.0f), qualColA, displayName.c_str()); + + // Count (if > 1) + if (toast.count > 1) { + char countBuf[12]; + snprintf(countBuf, sizeof(countBuf), "x%u", toast.count); + bgDL->AddText(ImVec2(tx + TOAST_W - 34.0f, ty + 5.0f), + IM_COL32(200, 200, 200, static_cast(200 * alpha)), countBuf); + } + } +} + +// --------------------------------------------------------------------------- +// PvP honor credit toasts — shown at screen top-right on honorable kill +// --------------------------------------------------------------------------- + +void GameScreen::renderPvpHonorToasts() { + if (pvpHonorToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : pvpHonorToasts_) t.age += dt; + pvpHonorToasts_.erase( + std::remove_if(pvpHonorToasts_.begin(), pvpHonorToasts_.end(), + [](const PvpHonorToastEntry& t) { return t.age >= PVP_HONOR_TOAST_DURATION; }), + pvpHonorToasts_.end()); + if (pvpHonorToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + + // Stack toasts at top-right, below any minimap area + constexpr float TOAST_W = 180.0f; + constexpr float TOAST_H = 30.0f; + constexpr float TOAST_GAP = 3.0f; + constexpr float TOAST_TOP = 10.0f; + float toastX = screenW - TOAST_W - 10.0f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(pvpHonorToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = pvpHonorToasts_[i]; + + float remaining = PVP_HONOR_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.15f) + alpha = toast.age / 0.15f; + else if (remaining < 0.8f) + alpha = remaining / 0.8f; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + float ty = TOAST_TOP + i * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(190 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark red (PvP theme) + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(28, 5, 5, bgA), 4.0f); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(200, 50, 50, static_cast(160 * alpha)), 4.0f, 0, 1.2f); + + // Sword ⚔ icon (U+2694, UTF-8: e2 9a 94) + bgDL->AddText(ImVec2(toastX + 7.0f, ty + 7.0f), + IM_COL32(220, 80, 80, fgA), "\xe2\x9a\x94"); + + // "+N Honor" text in gold + char buf[40]; + snprintf(buf, sizeof(buf), "+%u Honor", toast.honor); + bgDL->AddText(ImVec2(toastX + 24.0f, ty + 8.0f), + IM_COL32(255, 210, 50, fgA), buf); + } +} + +// --------------------------------------------------------------------------- +// Nearby player level-up toasts — shown at screen bottom-centre +// --------------------------------------------------------------------------- + +void GameScreen::renderPlayerLevelUpToasts(game::GameHandler& gameHandler) { + if (playerLevelUpToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + for (auto& t : playerLevelUpToasts_) { + t.age += dt; + // Lazy name resolution — fill in once the name cache has it + if (t.playerName.empty() && t.guid != 0) { + t.playerName = gameHandler.lookupName(t.guid); + } + } + playerLevelUpToasts_.erase( + std::remove_if(playerLevelUpToasts_.begin(), playerLevelUpToasts_.end(), + [](const PlayerLevelUpToastEntry& t) { + return t.age >= PLAYER_LEVELUP_TOAST_DURATION; + }), + playerLevelUpToasts_.end()); + if (playerLevelUpToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at screen bottom-centre, above action bars + constexpr float TOAST_W = 230.0f; + constexpr float TOAST_H = 38.0f; + constexpr float TOAST_GAP = 4.0f; + float baseY = screenH * 0.72f; + float toastX = (screenW - TOAST_W) * 0.5f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + const int count = static_cast(playerLevelUpToasts_.size()); + + for (int i = 0; i < count; ++i) { + const auto& toast = playerLevelUpToasts_[i]; + + float remaining = PLAYER_LEVELUP_TOAST_DURATION - toast.age; + float alpha; + if (toast.age < 0.2f) + alpha = toast.age / 0.2f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Subtle pop-up from below during first 0.2s + float slideY = (toast.age < 0.2f) ? (TOAST_H * (1.0f - toast.age / 0.2f)) : 0.0f; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP) + slideY; + + uint8_t bgA = static_cast(200 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background: dark gold tint + bgDL->AddRectFilled(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(30, 22, 5, bgA), 5.0f); + // Gold border with glow at peak + float glowStr = (toast.age < 0.5f) ? (1.0f - toast.age / 0.5f) : 0.0f; + uint8_t borderA = static_cast((160 + 80 * glowStr) * alpha); + bgDL->AddRect(ImVec2(toastX, ty), ImVec2(toastX + TOAST_W, ty + TOAST_H), + IM_COL32(255, 210, 50, borderA), 5.0f, 0, 1.5f + glowStr * 1.5f); + + // Star ★ icon on left + bgDL->AddText(ImVec2(toastX + 8.0f, ty + 10.0f), + IM_COL32(255, 220, 60, fgA), "\xe2\x98\x85"); // UTF-8 ★ + + // " is now level X!" text + const char* displayName = toast.playerName.empty() ? "A player" : toast.playerName.c_str(); + char buf[64]; + snprintf(buf, sizeof(buf), "%.18s is now level %u!", displayName, toast.newLevel); + bgDL->AddText(ImVec2(toastX + 26.0f, ty + 11.0f), + IM_COL32(255, 230, 100, fgA), buf); + } +} + +// --------------------------------------------------------------------------- +// Resurrection flash — brief screen brightening + "You have been resurrected!" +// banner when the player transitions from ghost back to alive. +// --------------------------------------------------------------------------- + +void GameScreen::renderResurrectFlash() { + if (resurrectFlashTimer_ <= 0.0f) return; + + float dt = ImGui::GetIO().DeltaTime; + resurrectFlashTimer_ -= dt; + if (resurrectFlashTimer_ <= 0.0f) { + resurrectFlashTimer_ = 0.0f; + return; + } + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Normalised age in [0, 1] (0 = just fired, 1 = fully elapsed) + float t = 1.0f - resurrectFlashTimer_ / kResurrectFlashDuration; + + // Alpha envelope: fast fade-in (first 0.15s), hold, then fade-out (last 0.8s) + float alpha; + const float fadeIn = 0.15f / kResurrectFlashDuration; // ~5% of lifetime + const float fadeOut = 0.8f / kResurrectFlashDuration; // ~27% of lifetime + if (t < fadeIn) + alpha = t / fadeIn; + else if (t < 1.0f - fadeOut) + alpha = 1.0f; + else + alpha = (1.0f - t) / fadeOut; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + ImDrawList* bg = ImGui::GetBackgroundDrawList(); + + // Soft golden/white vignette — brightening instead of darkening + uint8_t vigA = static_cast(50 * alpha); + bg->AddRectFilled(ImVec2(0, 0), ImVec2(screenW, screenH), + IM_COL32(200, 230, 255, vigA)); + + // Centered banner panel + constexpr float PANEL_W = 360.0f; + constexpr float PANEL_H = 52.0f; + float px = (screenW - PANEL_W) * 0.5f; + float py = screenH * 0.34f; + + uint8_t bgA = static_cast(210 * alpha); + uint8_t borderA = static_cast(255 * alpha); + uint8_t textA = static_cast(255 * alpha); + + // Background: deep blue-black + bg->AddRectFilled(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(10, 18, 40, bgA), 8.0f); + + // Border glow: bright holy gold + bg->AddRect(ImVec2(px, py), ImVec2(px + PANEL_W, py + PANEL_H), + IM_COL32(200, 230, 100, borderA), 8.0f, 0, 2.0f); + // Inner halo line + bg->AddRect(ImVec2(px + 3.0f, py + 3.0f), ImVec2(px + PANEL_W - 3.0f, py + PANEL_H - 3.0f), + IM_COL32(255, 255, 180, static_cast(80 * alpha)), 6.0f, 0, 1.0f); + + // "✦ You have been resurrected! ✦" centered + // UTF-8 heavy four-pointed star U+2726: \xe2\x9c\xa6 + const char* banner = "\xe2\x9c\xa6 You have been resurrected! \xe2\x9c\xa6"; + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, banner); + float tx = px + (PANEL_W - textSz.x) * 0.5f; + float ty = py + (PANEL_H - textSz.y) * 0.5f; + + // Drop shadow + bg->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, static_cast(180 * alpha)), banner); + // Main text in warm gold + bg->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 240, 120, textA), banner); +} + +// --------------------------------------------------------------------------- +// Whisper toast notifications — brief overlay when a player whispers you +// --------------------------------------------------------------------------- + +void GameScreen::renderWhisperToasts() { + if (whisperToasts_.empty()) return; + + float dt = ImGui::GetIO().DeltaTime; + + // Age and prune expired toasts + for (auto& t : whisperToasts_) t.age += dt; + whisperToasts_.erase( + std::remove_if(whisperToasts_.begin(), whisperToasts_.end(), + [](const WhisperToastEntry& t) { return t.age >= WHISPER_TOAST_DURATION; }), + whisperToasts_.end()); + if (whisperToasts_.empty()) return; + + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + // Stack toasts at bottom-left, above the action bars (y ≈ screenH * 0.72) + // Each toast is ~56px tall with a 4px gap between them. + constexpr float TOAST_W = 280.0f; + constexpr float TOAST_H = 56.0f; + constexpr float TOAST_GAP = 4.0f; + constexpr float TOAST_X = 14.0f; // left edge (won't cover action bars) + float baseY = screenH * 0.72f; + + ImDrawList* bgDL = ImGui::GetBackgroundDrawList(); + + const int count = static_cast(whisperToasts_.size()); + for (int i = 0; i < count; ++i) { + auto& toast = whisperToasts_[i]; + + // Fade in over 0.25s; fade out in last 1.0s + float alpha; + float remaining = WHISPER_TOAST_DURATION - toast.age; + if (toast.age < 0.25f) + alpha = toast.age / 0.25f; + else if (remaining < 1.0f) + alpha = remaining; + else + alpha = 1.0f; + alpha = std::clamp(alpha, 0.0f, 1.0f); + + // Slide-in from left: offset 0→0 after 0.25s + float slideX = (toast.age < 0.25f) ? (TOAST_W * (1.0f - toast.age / 0.25f)) : 0.0f; + float tx = TOAST_X - slideX; + float ty = baseY - (count - i) * (TOAST_H + TOAST_GAP); + + uint8_t bgA = static_cast(210 * alpha); + uint8_t fgA = static_cast(255 * alpha); + + // Background panel — dark purple tint (whisper color convention) + bgDL->AddRectFilled(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(25, 10, 40, bgA), 6.0f); + // Purple border + bgDL->AddRect(ImVec2(tx, ty), ImVec2(tx + TOAST_W, ty + TOAST_H), + IM_COL32(160, 80, 220, static_cast(180 * alpha)), 6.0f, 0, 1.5f); + + // "Whisper" label (small, purple-ish) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 6.0f), + IM_COL32(190, 110, 255, fgA), "Whisper from:"); + + // Sender name (gold) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 20.0f), + IM_COL32(255, 210, 50, fgA), toast.sender.c_str()); + + // Message preview (white, dimmer) + bgDL->AddText(ImVec2(tx + 10.0f, ty + 36.0f), + IM_COL32(220, 220, 220, static_cast(200 * alpha)), + toast.preview.c_str()); + } +} + // Zone discovery text — "Entering: " fades in/out at screen centre // --------------------------------------------------------------------------- -void GameScreen::renderZoneText() { - // Poll the renderer for zone name changes +void GameScreen::renderZoneText(game::GameHandler& gameHandler) { + // Poll worldStateZoneId for server-driven zone changes (fires on every zone crossing, + // including sub-zones like Ironforge within Dun Morogh). + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0 && wsZoneId != lastKnownWorldStateZoneId_) { + lastKnownWorldStateZoneId_ = wsZoneId; + std::string wsName = gameHandler.getWhoAreaName(wsZoneId); + if (!wsName.empty()) { + zoneTextName_ = wsName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } + } + + // Also poll the renderer for zone name changes (covers map-level transitions + // where worldStateZoneId may not change immediately). auto* appRenderer = core::Application::getInstance().getRenderer(); if (appRenderer) { const std::string& zoneName = appRenderer->getCurrentZoneName(); if (!zoneName.empty() && zoneName != lastKnownZoneName_) { lastKnownZoneName_ = zoneName; - zoneTextName_ = zoneName; - zoneTextTimer_ = ZONE_TEXT_DURATION; + // Only override if the worldState hasn't already queued this zone + if (zoneTextName_ != zoneName) { + zoneTextName_ = zoneName; + zoneTextTimer_ = ZONE_TEXT_DURATION; + } } } @@ -10456,12 +23595,136 @@ void GameScreen::renderZoneText() { IM_COL32(255, 255, 255, (int)(alpha * 255)), zoneTextName_.c_str()); } +// --------------------------------------------------------------------------- +// Screen-space weather overlay (rain / snow / storm) +// --------------------------------------------------------------------------- +void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { + uint32_t wType = gameHandler.getWeatherType(); + float intensity = gameHandler.getWeatherIntensity(); + if (wType == 0 || intensity < 0.05f) return; + + const ImGuiIO& io = ImGui::GetIO(); + float sw = io.DisplaySize.x; + float sh = io.DisplaySize.y; + if (sw <= 0.0f || sh <= 0.0f) return; + + ImDrawList* dl = ImGui::GetForegroundDrawList(); + const float dt = std::min(io.DeltaTime, 0.05f); // cap delta at 50ms to avoid teleporting particles + + if (wType == 1 || wType == 3) { + // ── Rain / Storm ───────────────────────────────────────────────────── + constexpr int MAX_DROPS = 300; + struct RainState { + float x[MAX_DROPS], y[MAX_DROPS]; + bool initialized = false; + uint32_t lastType = 0; + float lastW = 0.0f, lastH = 0.0f; + }; + static RainState rs; + + // Re-seed if weather type or screen size changed + if (!rs.initialized || rs.lastType != wType || + rs.lastW != sw || rs.lastH != sh) { + for (int i = 0; i < MAX_DROPS; ++i) { + rs.x[i] = static_cast(std::rand() % (static_cast(sw) + 200)) - 100.0f; + rs.y[i] = static_cast(std::rand() % static_cast(sh)); + } + rs.initialized = true; + rs.lastType = wType; + rs.lastW = sw; + rs.lastH = sh; + } + + const float fallSpeed = (wType == 3) ? 680.0f : 440.0f; + const float windSpeed = (wType == 3) ? 110.0f : 65.0f; + const int numDrops = static_cast(MAX_DROPS * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.28f + intensity * 0.38f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const ImU32 dropCol = IM_COL32(175, 195, 225, alphaU8); + const float dropLen = 7.0f + intensity * 7.0f; + // Normalised wind direction for the trail endpoint + const float invSpeed = 1.0f / std::sqrt(fallSpeed * fallSpeed + windSpeed * windSpeed); + const float trailDx = -windSpeed * invSpeed * dropLen; + const float trailDy = -fallSpeed * invSpeed * dropLen; + + for (int i = 0; i < numDrops; ++i) { + rs.x[i] += windSpeed * dt; + rs.y[i] += fallSpeed * dt; + if (rs.y[i] > sh + 10.0f) { + rs.y[i] = -10.0f; + rs.x[i] = static_cast(std::rand() % (static_cast(sw) + 200)) - 100.0f; + } + if (rs.x[i] > sw + 100.0f) rs.x[i] -= sw + 200.0f; + dl->AddLine(ImVec2(rs.x[i], rs.y[i]), + ImVec2(rs.x[i] + trailDx, rs.y[i] + trailDy), + dropCol, 1.0f); + } + + // Storm: dark fog-vignette at screen edges + if (wType == 3) { + const float vigAlpha = std::min(1.0f, 0.12f + intensity * 0.18f); + const ImU32 vigCol = IM_COL32(60, 65, 80, static_cast(vigAlpha * 255.0f)); + const float vigW = sw * 0.22f; + const float vigH = sh * 0.22f; + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vigW, sh), vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol); + dl->AddRectFilledMultiColor(ImVec2(sw-vigW, 0), ImVec2(sw, sh), IM_COL32_BLACK_TRANS, vigCol, vigCol, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(sw, vigH), vigCol, vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, sh-vigH),ImVec2(sw, sh), IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol, vigCol); + } + + } else if (wType == 2) { + // ── Snow ───────────────────────────────────────────────────────────── + constexpr int MAX_FLAKES = 120; + struct SnowState { + float x[MAX_FLAKES], y[MAX_FLAKES], phase[MAX_FLAKES]; + bool initialized = false; + float lastW = 0.0f, lastH = 0.0f; + }; + static SnowState ss; + + if (!ss.initialized || ss.lastW != sw || ss.lastH != sh) { + for (int i = 0; i < MAX_FLAKES; ++i) { + ss.x[i] = static_cast(std::rand() % static_cast(sw)); + ss.y[i] = static_cast(std::rand() % static_cast(sh)); + ss.phase[i] = static_cast(std::rand() % 628) * 0.01f; + } + ss.initialized = true; + ss.lastW = sw; + ss.lastH = sh; + } + + const float fallSpeed = 45.0f + intensity * 45.0f; + const int numFlakes = static_cast(MAX_FLAKES * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.5f + intensity * 0.3f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const float radius = 1.5f + intensity * 1.5f; + const float time = static_cast(ImGui::GetTime()); + + for (int i = 0; i < numFlakes; ++i) { + float sway = std::sin(time * 0.7f + ss.phase[i]) * 18.0f; + ss.x[i] += sway * dt; + ss.y[i] += fallSpeed * dt; + ss.phase[i] += dt * 0.25f; + if (ss.y[i] > sh + 5.0f) { + ss.y[i] = -5.0f; + ss.x[i] = static_cast(std::rand() % static_cast(sw)); + } + if (ss.x[i] < -5.0f) ss.x[i] += sw + 10.0f; + if (ss.x[i] > sw + 5.0f) ss.x[i] -= sw + 10.0f; + // Two-tone: bright centre dot + transparent outer ring for depth + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius, IM_COL32(220, 235, 255, alphaU8)); + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius * 0.45f, IM_COL32(245, 250, 255, std::min(255, alphaU8 + 30))); + } + } +} + // --------------------------------------------------------------------------- // Dungeon Finder window (toggle with hotkey or bag-bar button) // --------------------------------------------------------------------------- void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { - // Toggle on I key when not typing - if (!chatInputActive && ImGui::IsKeyPressed(ImGuiKey_I, false)) { + // Toggle Dungeon Finder (customizable keybind) + if (!chatInputActive && !ImGui::GetIO().WantTextInput && + KeybindingManager::getInstance().isActionPressed(KeybindingManager::Action::TOGGLE_DUNGEON_FINDER)) { showDungeonFinder_ = !showDungeonFinder_; } @@ -10504,7 +23767,12 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); int qMin = static_cast(qMs / 60000); int qSec = static_cast((qMs % 60000) / 1000); - ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), + "Status: In queue for %s (%d:%02d)", dName.c_str(), qMin, qSec); + else + ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.3f, 1.0f), "Status: In queue (%d:%02d)", qMin, qSec); if (avgSec >= 0) { int aMin = avgSec / 60; int aSec = avgSec % 60; @@ -10513,18 +23781,33 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { } break; } - case LfgState::Proposal: - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); + case LfgState::Proposal: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.1f, 1.0f), "Status: Group found!"); break; + } case LfgState::Boot: ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Status: Vote kick in progress"); break; - case LfgState::InDungeon: - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); + case LfgState::InDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon (%s)", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Status: In dungeon"); break; - case LfgState::FinishedDungeon: - ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); + } + case LfgState::FinishedDungeon: { + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: %s complete", dName.c_str()); + else + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), "Status: Dungeon complete"); break; + } case LfgState::RaidBrowser: ImGui::TextColored(ImVec4(0.8f, 0.6f, 1.0f, 1.0f), "Status: Raid browser"); break; @@ -10534,8 +23817,13 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { // ---- Proposal accept/decline ---- if (state == LfgState::Proposal) { - ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), - "A group has been found for your dungeon!"); + std::string dName = gameHandler.getCurrentLfgDungeonName(); + if (!dName.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for %s!", dName.c_str()); + else + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.3f, 1.0f), + "A group has been found for your dungeon!"); ImGui::Spacing(); if (ImGui::Button("Accept", ImVec2(120, 0))) { gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); @@ -10547,6 +23835,40 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::Separator(); } + // ---- Vote-to-kick buttons ---- + if (state == LfgState::Boot) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "Vote to kick in progress:"); + const std::string& bootTarget = gameHandler.getLfgBootTargetName(); + const std::string& bootReason = gameHandler.getLfgBootReason(); + if (!bootTarget.empty()) { + ImGui::Text("Player: "); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", bootTarget.c_str()); + } + if (!bootReason.empty()) { + ImGui::Text("Reason: "); + ImGui::SameLine(); + ImGui::TextWrapped("%s", bootReason.c_str()); + } + uint32_t bootVotes = gameHandler.getLfgBootVotes(); + uint32_t bootTotal = gameHandler.getLfgBootTotal(); + uint32_t bootNeeded = gameHandler.getLfgBootNeeded(); + uint32_t bootTimeLeft= gameHandler.getLfgBootTimeLeft(); + if (bootNeeded > 0) { + ImGui::Text("Votes: %u / %u (need %u) %us left", + bootVotes, bootTotal, bootNeeded, bootTimeLeft); + } + ImGui::Spacing(); + if (ImGui::Button("Vote Yes (kick)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(true); + } + ImGui::SameLine(); + if (ImGui::Button("Vote No (keep)", ImVec2(140, 0))) { + gameHandler.lfgSetBootVote(false); + } + ImGui::Separator(); + } + // ---- Teleport button (in dungeon) ---- if (state == LfgState::InDungeon) { if (ImGui::Button("Teleport to Dungeon", ImVec2(-1, 0))) { @@ -10576,36 +23898,36 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::Text("Dungeon:"); struct DungeonEntry { uint32_t id; const char* name; }; - static const DungeonEntry kDungeons[] = { - { 861, "Random Dungeon" }, - { 862, "Random Heroic" }, - // Vanilla classics - { 36, "Deadmines" }, - { 43, "Ragefire Chasm" }, - { 47, "Razorfen Kraul" }, - { 48, "Blackfathom Deeps" }, - { 52, "Uldaman" }, - { 57, "Dire Maul: East" }, - { 70, "Onyxia's Lair" }, - // TBC heroics - { 264, "The Blood Furnace" }, - { 269, "The Shattered Halls" }, - // WotLK normals/heroics - { 576, "The Nexus" }, - { 578, "The Oculus" }, - { 595, "The Culling of Stratholme" }, - { 599, "Halls of Stone" }, - { 600, "Drak'Tharon Keep" }, - { 601, "Azjol-Nerub" }, - { 604, "Gundrak" }, - { 608, "Violet Hold" }, - { 619, "Ahn'kahet: Old Kingdom" }, - { 623, "Halls of Lightning" }, - { 632, "The Forge of Souls" }, - { 650, "Trial of the Champion" }, - { 658, "Pit of Saron" }, - { 668, "Halls of Reflection" }, + // Category 0=Random, 1=Classic, 2=TBC, 3=WotLK + struct DungeonEntryEx { uint32_t id; const char* name; uint8_t cat; }; + static const DungeonEntryEx kDungeons[] = { + { 861, "Random Dungeon", 0 }, + { 862, "Random Heroic", 0 }, + { 36, "Deadmines", 1 }, + { 43, "Ragefire Chasm", 1 }, + { 47, "Razorfen Kraul", 1 }, + { 48, "Blackfathom Deeps", 1 }, + { 52, "Uldaman", 1 }, + { 57, "Dire Maul: East", 1 }, + { 70, "Onyxia's Lair", 1 }, + { 264, "The Blood Furnace", 2 }, + { 269, "The Shattered Halls", 2 }, + { 576, "The Nexus", 3 }, + { 578, "The Oculus", 3 }, + { 595, "The Culling of Stratholme", 3 }, + { 599, "Halls of Stone", 3 }, + { 600, "Drak'Tharon Keep", 3 }, + { 601, "Azjol-Nerub", 3 }, + { 604, "Gundrak", 3 }, + { 608, "Violet Hold", 3 }, + { 619, "Ahn'kahet: Old Kingdom", 3 }, + { 623, "Halls of Lightning", 3 }, + { 632, "The Forge of Souls", 3 }, + { 650, "Trial of the Champion", 3 }, + { 658, "Pit of Saron", 3 }, + { 668, "Halls of Reflection", 3 }, }; + static const char* kCatHeaders[] = { nullptr, "-- Classic --", "-- TBC --", "-- WotLK --" }; // Find current index int curIdx = 0; @@ -10615,7 +23937,15 @@ void GameScreen::renderDungeonFinderWindow(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(-1); if (ImGui::BeginCombo("##dungeon", kDungeons[curIdx].name)) { + uint8_t lastCat = 255; for (int i = 0; i < (int)(sizeof(kDungeons)/sizeof(kDungeons[0])); ++i) { + if (kDungeons[i].cat != lastCat && kCatHeaders[kDungeons[i].cat]) { + if (lastCat != 255) ImGui::Separator(); + ImGui::TextDisabled("%s", kCatHeaders[kDungeons[i].cat]); + lastCat = kDungeons[i].cat; + } else if (kDungeons[i].cat != lastCat) { + lastCat = kDungeons[i].cat; + } bool selected = (kDungeons[i].id == lfgSelectedDungeon_); if (ImGui::Selectable(kDungeons[i].name, selected)) lfgSelectedDungeon_ = kDungeons[i].id; @@ -10672,24 +24002,6 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { if (lockouts.empty()) { ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 1.0f), "No active instance lockouts."); } else { - // Build map name lookup from Map.dbc (cached after first call) - static std::unordered_map sMapNames; - static bool sMapNamesLoaded = false; - if (!sMapNamesLoaded) { - sMapNamesLoaded = true; - if (auto* am = core::Application::getInstance().getAssetManager()) { - if (auto dbc = am->loadDBC("Map.dbc"); dbc && dbc->isLoaded()) { - for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { - uint32_t id = dbc->getUInt32(i, 0); - // Field 2 = MapName_enUS (first localized), field 1 = InternalName - std::string name = dbc->getString(i, 2); - if (name.empty()) name = dbc->getString(i, 1); - if (!name.empty()) sMapNames[id] = std::move(name); - } - } - } - } - auto difficultyLabel = [](uint32_t diff) -> const char* { switch (diff) { case 0: return "Normal"; @@ -10715,11 +24027,11 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { for (const auto& lo : lockouts) { ImGui::TableNextRow(); - // Instance name + // Instance name — use GameHandler's Map.dbc cache (avoids duplicate DBC load) ImGui::TableSetColumnIndex(0); - auto it = sMapNames.find(lo.mapId); - if (it != sMapNames.end()) { - ImGui::TextUnformatted(it->second.c_str()); + std::string mapName = gameHandler.getMapName(lo.mapId); + if (!mapName.empty()) { + ImGui::TextUnformatted(mapName.c_str()); } else { ImGui::Text("Map %u", lo.mapId); } @@ -10805,6 +24117,8 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { { 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" }, // Strand of the Ancients (WotLK) { 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" }, + // Isle of Conquest (WotLK): reinforcements (300 default) + { 628, "Isle of Conquest", 4221, 4222, 0, 300, "reinforcements" }, }; const BgScoreDef* def = nullptr; @@ -10884,4 +24198,1535 @@ void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { ImGui::PopStyleVar(2); } +// ─── Who Results Window ─────────────────────────────────────────────────────── +void GameScreen::renderWhoWindow(game::GameHandler& gameHandler) { + if (!showWhoWindow_) return; + + const auto& results = gameHandler.getWhoResults(); + + ImGui::SetNextWindowSize(ImVec2(500, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 180), ImGuiCond_FirstUseEver); + + char title[64]; + uint32_t onlineCount = gameHandler.getWhoOnlineCount(); + if (onlineCount > 0) + snprintf(title, sizeof(title), "Players Online: %u###WhoWindow", onlineCount); + else + snprintf(title, sizeof(title), "Who###WhoWindow"); + + if (!ImGui::Begin(title, &showWhoWindow_)) { + ImGui::End(); + return; + } + + // Search bar with Send button + static char whoSearchBuf[64] = {}; + bool doSearch = false; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 60.0f); + if (ImGui::InputTextWithHint("##whosearch", "Search players...", whoSearchBuf, sizeof(whoSearchBuf), + ImGuiInputTextFlags_EnterReturnsTrue)) + doSearch = true; + ImGui::SameLine(); + if (ImGui::Button("Search", ImVec2(-1, 0))) + doSearch = true; + if (doSearch) { + gameHandler.queryWho(std::string(whoSearchBuf)); + } + ImGui::Separator(); + + if (results.empty()) { + ImGui::TextDisabled("No results. Type a filter above or use /who [filter]."); + ImGui::End(); + return; + } + + // Table: Name | Guild | Level | Class | Zone + if (ImGui::BeginTable("##WhoTable", 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_ScrollY | ImGuiTableFlags_SizingStretchProp, + ImVec2(0, 0))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch, 0.22f); + ImGui::TableSetupColumn("Guild", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Level", ImGuiTableColumnFlags_WidthFixed, 40.0f); + ImGui::TableSetupColumn("Class", ImGuiTableColumnFlags_WidthStretch, 0.20f); + ImGui::TableSetupColumn("Zone", ImGuiTableColumnFlags_WidthStretch, 0.28f); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < results.size(); ++i) { + const auto& e = results[i]; + ImGui::TableNextRow(); + ImGui::PushID(static_cast(i)); + + // Name (class-colored if class is known) + ImGui::TableSetColumnIndex(0); + uint8_t cid = static_cast(e.classId); + ImVec4 nameCol = classColorVec4(cid); + ImGui::TextColored(nameCol, "%s", e.name.c_str()); + + // Right-click context menu on the name + if (ImGui::BeginPopupContextItem("##WhoCtx")) { + ImGui::TextDisabled("%s", e.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; + strncpy(whisperTargetBuffer, e.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(e.name); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(e.name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(e.name); + ImGui::EndPopup(); + } + + // Guild + ImGui::TableSetColumnIndex(1); + if (!e.guildName.empty()) + ImGui::TextDisabled("<%s>", e.guildName.c_str()); + + // Level + ImGui::TableSetColumnIndex(2); + ImGui::Text("%u", e.level); + + // Class + ImGui::TableSetColumnIndex(3); + const char* className = game::getClassName(static_cast(e.classId)); + ImGui::TextColored(nameCol, "%s", className); + + // Zone + ImGui::TableSetColumnIndex(4); + if (e.zoneId != 0) { + std::string zoneName = gameHandler.getWhoAreaName(e.zoneId); + ImGui::TextUnformatted(zoneName.empty() ? "Unknown" : zoneName.c_str()); + } + + ImGui::PopID(); + } + + ImGui::EndTable(); + } + + ImGui::End(); +} + +// ─── Combat Log Window ──────────────────────────────────────────────────────── +void GameScreen::renderCombatLog(game::GameHandler& gameHandler) { + if (!showCombatLog_) return; + + const auto& log = gameHandler.getCombatLog(); + + ImGui::SetNextWindowSize(ImVec2(520, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(160, 200), ImGuiCond_FirstUseEver); + + char title[64]; + snprintf(title, sizeof(title), "Combat Log (%zu)###CombatLog", log.size()); + if (!ImGui::Begin(title, &showCombatLog_)) { + ImGui::End(); + return; + } + + // Filter toggles + static bool filterDamage = true; + static bool filterHeal = true; + static bool filterMisc = true; + static bool autoScroll = true; + + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 2)); + ImGui::Checkbox("Damage", &filterDamage); ImGui::SameLine(); + ImGui::Checkbox("Healing", &filterHeal); ImGui::SameLine(); + ImGui::Checkbox("Misc", &filterMisc); ImGui::SameLine(); + ImGui::Checkbox("Auto-scroll", &autoScroll); + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 40.0f); + if (ImGui::SmallButton("Clear")) + gameHandler.clearCombatLog(); + ImGui::PopStyleVar(); + ImGui::Separator(); + + // Helper: categorize entry + auto isDamageType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::MELEE_DAMAGE || t == T::SPELL_DAMAGE || + t == T::CRIT_DAMAGE || t == T::PERIODIC_DAMAGE || + t == T::ENVIRONMENTAL || t == T::GLANCING || t == T::CRUSHING; + }; + auto isHealType = [](game::CombatTextEntry::Type t) { + using T = game::CombatTextEntry; + return t == T::HEAL || t == T::CRIT_HEAL || t == T::PERIODIC_HEAL; + }; + + // Two-column table: Time | Event description + ImGuiTableFlags tableFlags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingFixedFit; + float availH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##CombatLogTable", 2, tableFlags, ImVec2(0.0f, availH))) { + ImGui::TableSetupScrollFreeze(0, 0); + ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 62.0f); + ImGui::TableSetupColumn("Event", ImGuiTableColumnFlags_WidthStretch); + + for (const auto& e : log) { + // Apply filters + bool isDmg = isDamageType(e.type); + bool isHeal = isHealType(e.type); + bool isMisc = !isDmg && !isHeal; + if (isDmg && !filterDamage) continue; + if (isHeal && !filterHeal) continue; + if (isMisc && !filterMisc) continue; + + // Format timestamp as HH:MM:SS + char timeBuf[10]; + { + struct tm* tm_info = std::localtime(&e.timestamp); + if (tm_info) + snprintf(timeBuf, sizeof(timeBuf), "%02d:%02d:%02d", + tm_info->tm_hour, tm_info->tm_min, tm_info->tm_sec); + else + snprintf(timeBuf, sizeof(timeBuf), "--:--:--"); + } + + // Build event description and choose color + char desc[256]; + ImVec4 color; + using T = game::CombatTextEntry; + const char* src = e.sourceName.empty() ? (e.isPlayerSource ? "You" : "?") : e.sourceName.c_str(); + const char* tgt = e.targetName.empty() ? "?" : e.targetName.c_str(); + const std::string& spellName = (e.spellId != 0) ? gameHandler.getSpellName(e.spellId) : std::string(); + const char* spell = spellName.empty() ? nullptr : spellName.c_str(); + + switch (e.type) { + case T::MELEE_DAMAGE: + snprintf(desc, sizeof(desc), "%s hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + break; + case T::CRIT_DAMAGE: + snprintf(desc, sizeof(desc), "%s crits %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 1.0f, 0.0f, 1.0f) : ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; + case T::SPELL_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s hits %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's spell hits %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.9f, 0.3f, 1.0f) : ImVec4(1.0f, 0.4f, 0.4f, 1.0f); + break; + case T::PERIODIC_DAMAGE: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s ticks %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's DoT ticks %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.9f, 0.7f, 0.3f, 1.0f) : ImVec4(0.9f, 0.3f, 0.3f, 1.0f); + break; + case T::HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s heals %s for %d (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 1.0f, 0.4f, 1.0f); + break; + case T::CRIT_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s critically heals %s for %d! (%s)", src, tgt, e.amount, spell); + else + snprintf(desc, sizeof(desc), "%s critically heals %s for %d!", src, tgt, e.amount); + color = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + break; + case T::PERIODIC_HEAL: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s heals %s for %d", src, spell, tgt, e.amount); + else + snprintf(desc, sizeof(desc), "%s's HoT heals %s for %d", src, tgt, e.amount); + color = ImVec4(0.4f, 0.9f, 0.4f, 1.0f); + break; + case T::MISS: + if (spell) + snprintf(desc, sizeof(desc), "%s's %s misses %s", src, spell, tgt); + else + snprintf(desc, sizeof(desc), "%s misses %s", src, tgt); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::DODGE: + if (spell) + snprintf(desc, sizeof(desc), "%s dodges %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s dodges %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::PARRY: + if (spell) + snprintf(desc, sizeof(desc), "%s parries %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s parries %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::BLOCK: + if (spell) + snprintf(desc, sizeof(desc), "%s blocks %s's %s (%d blocked)", tgt, src, spell, e.amount); + else + snprintf(desc, sizeof(desc), "%s blocks %s's attack (%d blocked)", tgt, src, e.amount); + color = ImVec4(0.65f, 0.75f, 0.65f, 1.0f); + break; + case T::EVADE: + if (spell) + snprintf(desc, sizeof(desc), "%s evades %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s evades %s's attack", tgt, src); + color = ImVec4(0.65f, 0.65f, 0.65f, 1.0f); + break; + case T::IMMUNE: + if (spell) + snprintf(desc, sizeof(desc), "%s is immune to %s", tgt, spell); + else + snprintf(desc, sizeof(desc), "%s is immune", tgt); + color = ImVec4(0.8f, 0.8f, 0.8f, 1.0f); + break; + case T::ABSORB: + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s's %s absorbs %d", src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s absorbs %s", tgt, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d absorbed", e.amount); + else + snprintf(desc, sizeof(desc), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, 1.0f); + break; + case T::RESIST: + if (spell && e.amount > 0) + snprintf(desc, sizeof(desc), "%s resists %s's %s (%d resisted)", tgt, src, spell, e.amount); + else if (spell) + snprintf(desc, sizeof(desc), "%s resists %s's %s", tgt, src, spell); + else if (e.amount > 0) + snprintf(desc, sizeof(desc), "%d resisted", e.amount); + else + snprintf(desc, sizeof(desc), "Resisted"); + color = ImVec4(0.6f, 0.6f, 0.9f, 1.0f); + break; + case T::DEFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s deflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s deflects %s's attack", tgt, src); + color = ImVec4(0.65f, 0.8f, 0.95f, 1.0f); + break; + case T::REFLECT: + if (spell) + snprintf(desc, sizeof(desc), "%s reflects %s's %s", tgt, src, spell); + else + snprintf(desc, sizeof(desc), "%s reflects %s's attack", tgt, src); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; + case T::ENVIRONMENTAL: { + const char* envName = "Environmental"; + switch (e.powerType) { + case 0: envName = "Fatigue"; break; + case 1: envName = "Drowning"; break; + case 2: envName = "Falling"; break; + case 3: envName = "Lava"; break; + case 4: envName = "Slime"; break; + case 5: envName = "Fire"; break; + } + snprintf(desc, sizeof(desc), "%s damage: %d", envName, e.amount); + color = ImVec4(1.0f, 0.5f, 0.2f, 1.0f); + break; + } + case T::ENERGIZE: { + const char* pwrName = "power"; + switch (e.powerType) { + case 0: pwrName = "Mana"; break; + case 1: pwrName = "Rage"; break; + case 2: pwrName = "Focus"; break; + case 3: pwrName = "Energy"; break; + case 4: pwrName = "Happiness"; break; + case 6: pwrName = "Runic Power"; break; + } + if (spell) + snprintf(desc, sizeof(desc), "%s gains %d %s (%s)", tgt, e.amount, pwrName, spell); + else + snprintf(desc, sizeof(desc), "%s gains %d %s", tgt, e.amount, pwrName); + color = ImVec4(0.4f, 0.6f, 1.0f, 1.0f); + break; + } + case T::POWER_DRAIN: { + const char* drainName = "power"; + switch (e.powerType) { + case 0: drainName = "Mana"; break; + case 1: drainName = "Rage"; break; + case 2: drainName = "Focus"; break; + case 3: drainName = "Energy"; break; + case 4: drainName = "Happiness"; break; + case 6: drainName = "Runic Power"; break; + } + if (spell) + snprintf(desc, sizeof(desc), "%s loses %d %s to %s's %s", tgt, e.amount, drainName, src, spell); + else + snprintf(desc, sizeof(desc), "%s loses %d %s", tgt, e.amount, drainName); + color = ImVec4(0.45f, 0.75f, 1.0f, 1.0f); + break; + } + case T::XP_GAIN: + snprintf(desc, sizeof(desc), "You gain %d experience", e.amount); + color = ImVec4(0.8f, 0.6f, 1.0f, 1.0f); + break; + case T::PROC_TRIGGER: + if (spell) + snprintf(desc, sizeof(desc), "%s procs!", spell); + else + snprintf(desc, sizeof(desc), "Proc triggered"); + color = ImVec4(1.0f, 0.85f, 0.3f, 1.0f); + break; + case T::DISPEL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s dispels %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You dispel from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s dispels from %s", src, tgt); + color = ImVec4(0.6f, 0.9f, 1.0f, 1.0f); + break; + case T::STEAL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal %s from %s", spell, tgt); + else if (spell) + snprintf(desc, sizeof(desc), "%s steals %s from %s", src, spell, tgt); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You steal from %s", tgt); + else + snprintf(desc, sizeof(desc), "%s steals from %s", src, tgt); + color = ImVec4(0.8f, 0.7f, 1.0f, 1.0f); + break; + case T::INTERRUPT: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s's %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s interrupts %s's %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You interrupt %s", tgt); + else + snprintf(desc, sizeof(desc), "%s interrupted", tgt); + color = ImVec4(1.0f, 0.6f, 0.9f, 1.0f); + break; + case T::INSTAKILL: + if (spell && e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s with %s", tgt, spell); + else if (spell) + snprintf(desc, sizeof(desc), "%s instantly kills %s with %s", src, tgt, spell); + else if (e.isPlayerSource) + snprintf(desc, sizeof(desc), "You instantly kill %s", tgt); + else + snprintf(desc, sizeof(desc), "%s instantly kills %s", src, tgt); + color = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); + break; + case T::HONOR_GAIN: + snprintf(desc, sizeof(desc), "You gain %d honor", e.amount); + color = ImVec4(1.0f, 0.85f, 0.0f, 1.0f); + break; + case T::GLANCING: + snprintf(desc, sizeof(desc), "%s glances %s for %d", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(0.75f, 0.75f, 0.5f, 1.0f) + : ImVec4(0.75f, 0.4f, 0.4f, 1.0f); + break; + case T::CRUSHING: + snprintf(desc, sizeof(desc), "%s crushes %s for %d!", src, tgt, e.amount); + color = e.isPlayerSource ? ImVec4(1.0f, 0.55f, 0.1f, 1.0f) + : ImVec4(1.0f, 0.15f, 0.15f, 1.0f); + break; + default: + snprintf(desc, sizeof(desc), "Combat event (type %d, amount %d)", (int)e.type, e.amount); + color = ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + break; + } + + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("%s", timeBuf); + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(color, "%s", desc); + // Hover tooltip: show rich spell info for entries with a known spell + if (e.spellId != 0 && ImGui::IsItemHovered()) { + auto* assetMgrLog = core::Application::getInstance().getAssetManager(); + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(e.spellId, gameHandler, assetMgrLog); + if (!richOk) { + ImGui::Text("%s", spellName.c_str()); + } + ImGui::EndTooltip(); + } + } + + // Auto-scroll to bottom + if (autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) + ImGui::SetScrollHereY(1.0f); + + ImGui::EndTable(); + } + + ImGui::End(); +} + +// ─── Achievement Window ─────────────────────────────────────────────────────── +void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { + if (!showAchievementWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(420, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(200, 150), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Achievements", &showAchievementWindow_)) { + ImGui::End(); + return; + } + + const auto& earned = gameHandler.getEarnedAchievements(); + const auto& criteria = gameHandler.getCriteriaProgress(); + + ImGui::SetNextItemWidth(180.0f); + ImGui::InputText("##achsearch", achievementSearchBuf_, sizeof(achievementSearchBuf_)); + ImGui::SameLine(); + if (ImGui::Button("Clear")) achievementSearchBuf_[0] = '\0'; + ImGui::Separator(); + + std::string filter(achievementSearchBuf_); + for (char& c : filter) c = static_cast(tolower(static_cast(c))); + + if (ImGui::BeginTabBar("##achtabs")) { + // --- Earned tab --- + char earnedLabel[32]; + snprintf(earnedLabel, sizeof(earnedLabel), "Earned (%u)###earned", (unsigned)earned.size()); + if (ImGui::BeginTabItem(earnedLabel)) { + if (earned.empty()) { + ImGui::TextDisabled("No achievements earned yet."); + } else { + ImGui::BeginChild("##achlist", ImVec2(0, 0), false); + std::vector ids(earned.begin(), earned.end()); + std::sort(ids.begin(), ids.end()); + for (uint32_t id : ids) { + const std::string& name = gameHandler.getAchievementName(id); + const std::string& display = name.empty() ? std::to_string(id) : name; + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + if (lower.find(filter) == std::string::npos) continue; + } + ImGui::PushID(static_cast(id)); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "\xE2\x98\x85"); + ImGui::SameLine(); + ImGui::TextUnformatted(display.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + // Points badge + uint32_t pts = gameHandler.getAchievementPoints(id); + if (pts > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), + "%u Achievement Point%s", pts, pts == 1 ? "" : "s"); + ImGui::Separator(); + } + // Description + const std::string& desc = gameHandler.getAchievementDescription(id); + if (!desc.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); + ImGui::TextUnformatted(desc.c_str()); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + } + // Earn date + uint32_t packed = gameHandler.getAchievementDate(id); + if (packed != 0) { + // WoW PackedTime: year[31:25] month[24:21] day[20:17] weekday[16:14] hour[13:9] minute[8:3] + int minute = (packed >> 3) & 0x3F; + int hour = (packed >> 9) & 0x1F; + int day = (packed >> 17) & 0x1F; + int month = (packed >> 21) & 0x0F; + int year = ((packed >> 25) & 0x7F) + 2000; + static const char* kMonths[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + const char* mname = (month >= 1 && month <= 12) ? kMonths[month - 1] : "?"; + ImGui::TextDisabled("Earned: %s %d, %d %02d:%02d", mname, day, year, hour, minute); + } + ImGui::EndTooltip(); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + + // --- Criteria progress tab --- + char critLabel[32]; + snprintf(critLabel, sizeof(critLabel), "Criteria (%u)###crit", (unsigned)criteria.size()); + if (ImGui::BeginTabItem(critLabel)) { + // Lazy-load AchievementCriteria.dbc for descriptions + struct CriteriaEntry { uint32_t achievementId; uint64_t quantity; std::string description; }; + static std::unordered_map s_criteriaData; + static bool s_criteriaDataLoaded = false; + if (!s_criteriaDataLoaded) { + s_criteriaDataLoaded = true; + auto* am = core::Application::getInstance().getAssetManager(); + if (am && am->isInitialized()) { + auto dbc = am->loadDBC("AchievementCriteria.dbc"); + if (dbc && dbc->isLoaded() && dbc->getFieldCount() >= 10) { + const auto* acL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("AchievementCriteria") : nullptr; + uint32_t achField = acL ? acL->field("AchievementID") : 1u; + uint32_t qtyField = acL ? acL->field("Quantity") : 4u; + uint32_t descField = acL ? acL->field("Description") : 9u; + if (achField == 0xFFFFFFFF) achField = 1; + if (qtyField == 0xFFFFFFFF) qtyField = 4; + if (descField == 0xFFFFFFFF) descField = 9; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t cid = dbc->getUInt32(r, 0); + if (cid == 0) continue; + CriteriaEntry ce; + ce.achievementId = (achField < fc) ? dbc->getUInt32(r, achField) : 0; + ce.quantity = (qtyField < fc) ? dbc->getUInt32(r, qtyField) : 0; + ce.description = (descField < fc) ? dbc->getString(r, descField) : std::string{}; + s_criteriaData[cid] = std::move(ce); + } + } + } + } + + if (criteria.empty()) { + ImGui::TextDisabled("No criteria progress received yet."); + } else { + ImGui::BeginChild("##critlist", ImVec2(0, 0), false); + std::vector> clist(criteria.begin(), criteria.end()); + std::sort(clist.begin(), clist.end()); + for (const auto& [cid, cval] : clist) { + auto ceIt = s_criteriaData.find(cid); + + // Build display text for filtering + std::string display; + if (ceIt != s_criteriaData.end() && !ceIt->second.description.empty()) { + display = ceIt->second.description; + } else { + display = std::to_string(cid); + } + if (!filter.empty()) { + std::string lower = display; + for (char& c : lower) c = static_cast(tolower(static_cast(c))); + // Also allow filtering by achievement name + if (lower.find(filter) == std::string::npos && ceIt != s_criteriaData.end()) { + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + std::string achLower = achName; + for (char& c : achLower) c = static_cast(tolower(static_cast(c))); + if (achLower.find(filter) == std::string::npos) continue; + } else if (lower.find(filter) == std::string::npos) { + continue; + } + } + + ImGui::PushID(static_cast(cid)); + if (ceIt != s_criteriaData.end()) { + // Show achievement name as header (dim) + const std::string& achName = gameHandler.getAchievementName(ceIt->second.achievementId); + if (!achName.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 0.8f), "%s", achName.c_str()); + ImGui::SameLine(); + ImGui::TextDisabled(">"); + ImGui::SameLine(); + } + if (!ceIt->second.description.empty()) { + ImGui::TextUnformatted(ceIt->second.description.c_str()); + } else { + ImGui::TextDisabled("Criteria %u", cid); + } + ImGui::SameLine(); + if (ceIt->second.quantity > 0) { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), + "%llu/%llu", + static_cast(cval), + static_cast(ceIt->second.quantity)); + } else { + ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.6f, 1.0f), + "%llu", static_cast(cval)); + } + } else { + ImGui::TextDisabled("Criteria %u:", cid); + ImGui::SameLine(); + ImGui::Text("%llu", static_cast(cval)); + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + + ImGui::End(); +} + +// ─── GM Ticket Window ───────────────────────────────────────────────────────── +void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { + // Fire a one-shot query when the window first becomes visible + if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) { + gameHandler.requestGmTicket(); + } + gmTicketWindowWasOpen_ = showGmTicketWindow_; + + if (!showGmTicketWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + // Show GM support availability + if (!gameHandler.isGmSupportAvailable()) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "GM support is currently unavailable."); + ImGui::Spacing(); + } + + // Show existing open ticket if any + if (gameHandler.hasActiveGmTicket()) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "You have an open GM ticket."); + const std::string& existingText = gameHandler.getGmTicketText(); + if (!existingText.empty()) { + ImGui::TextWrapped("Current ticket: %s", existingText.c_str()); + } + float waitHours = gameHandler.getGmTicketWaitHours(); + if (waitHours > 0.0f) { + char waitBuf[64]; + std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf); + } + ImGui::Separator(); + ImGui::Spacing(); + } + + ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); + ImGui::Spacing(); + ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), + ImVec2(-1, 120)); + ImGui::Spacing(); + + bool hasText = (gmTicketBuf_[0] != '\0'); + if (!hasText) ImGui::BeginDisabled(); + if (ImGui::Button("Submit Ticket", ImVec2(160, 0))) { + gameHandler.submitGmTicket(gmTicketBuf_); + gmTicketBuf_[0] = '\0'; + showGmTicketWindow_ = false; + } + if (!hasText) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + showGmTicketWindow_ = false; + } + ImGui::SameLine(); + if (gameHandler.hasActiveGmTicket()) { + if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) { + gameHandler.deleteGmTicket(); + showGmTicketWindow_ = false; + } + } + + ImGui::End(); +} + +// ─── Threat Window ──────────────────────────────────────────────────────────── +void GameScreen::renderThreatWindow(game::GameHandler& gameHandler) { + if (!showThreatWindow_) return; + + const auto* list = gameHandler.getTargetThreatList(); + + ImGui::SetNextWindowSize(ImVec2(280, 220), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(10, 300), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowBgAlpha(0.85f); + + if (!ImGui::Begin("Threat###ThreatWin", &showThreatWindow_, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::End(); + return; + } + + if (!list || list->empty()) { + ImGui::TextDisabled("No threat data for current target."); + ImGui::End(); + return; + } + + uint32_t maxThreat = list->front().threat; + + // Pre-scan to find the player's rank and threat percentage + uint64_t playerGuid = gameHandler.getPlayerGuid(); + int playerRank = 0; + float playerPct = 0.0f; + { + int scan = 0; + for (const auto& e : *list) { + ++scan; + if (e.victimGuid == playerGuid) { + playerRank = scan; + playerPct = (maxThreat > 0) ? static_cast(e.threat) / static_cast(maxThreat) : 0.0f; + break; + } + if (scan >= 10) break; + } + } + + // Status bar: aggro alert or rank summary + if (playerRank == 1) { + // Player has aggro — persistent red warning + ImGui::TextColored(ImVec4(1.0f, 0.25f, 0.25f, 1.0f), "!! YOU HAVE AGGRO !!"); + } else if (playerRank > 1 && playerPct >= 0.8f) { + // Close to pulling — pulsing warning + float pulse = 0.55f + 0.45f * sinf(static_cast(ImGui::GetTime()) * 5.0f); + ImGui::TextColored(ImVec4(1.0f, 0.55f, 0.1f, pulse), "! PULLING AGGRO (%.0f%%) !", playerPct * 100.0f); + } else if (playerRank > 0) { + ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "You: #%d %.0f%% threat", playerRank, playerPct * 100.0f); + } + + ImGui::TextDisabled("%-19s Threat", "Player"); + ImGui::Separator(); + + int rank = 0; + for (const auto& entry : *list) { + ++rank; + bool isPlayer = (entry.victimGuid == playerGuid); + + // Resolve name + std::string victimName; + auto entity = gameHandler.getEntityManager().getEntity(entry.victimGuid); + if (entity) { + if (entity->getType() == game::ObjectType::PLAYER) { + auto p = std::static_pointer_cast(entity); + victimName = p->getName().empty() ? "Player" : p->getName(); + } else if (entity->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(entity); + victimName = u->getName().empty() ? "NPC" : u->getName(); + } + } + if (victimName.empty()) + victimName = "0x" + [&](){ + char buf[20]; snprintf(buf, sizeof(buf), "%llX", + static_cast(entry.victimGuid)); return std::string(buf); }(); + + // Colour: gold for #1 (tank), red if player is highest, white otherwise + ImVec4 col = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (rank == 1) col = ImVec4(1.0f, 0.82f, 0.0f, 1.0f); // gold + if (isPlayer && rank == 1) col = ImVec4(1.0f, 0.3f, 0.3f, 1.0f); // red — you have aggro + + // Threat bar + float pct = (maxThreat > 0) ? (float)entry.threat / (float)maxThreat : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + isPlayer ? ImVec4(0.8f, 0.2f, 0.2f, 0.7f) : ImVec4(0.2f, 0.5f, 0.8f, 0.5f)); + char barLabel[48]; + snprintf(barLabel, sizeof(barLabel), "%.0f%%", pct * 100.0f); + ImGui::ProgressBar(pct, ImVec2(60, 14), barLabel); + ImGui::PopStyleColor(); + ImGui::SameLine(); + + ImGui::TextColored(col, "%-18s %u", victimName.c_str(), entry.threat); + + if (rank >= 10) break; // cap display at 10 entries + } + + ImGui::End(); +} + +// ─── BG Scoreboard ──────────────────────────────────────────────────────────── +void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { + if (!showBgScoreboard_) return; + + const game::GameHandler::BgScoreboardData* data = gameHandler.getBgScoreboard(); + + ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); + + const char* title = data && data->isArena ? "Arena Score###BgScore" + : "Battleground Score###BgScore"; + if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!data) { + ImGui::TextDisabled("No score data yet."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena."); + ImGui::End(); + return; + } + + // Arena team rating banner (shown only for arenas) + if (data->isArena) { + for (int t = 0; t < 2; ++t) { + const auto& at = data->arenaTeams[t]; + if (at.teamName.empty()) continue; + int32_t ratingDelta = static_cast(at.ratingChange); + ImVec4 teamCol = (t == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) // team 0: red + : ImVec4(0.4f, 0.6f, 1.0f, 1.0f); // team 1: blue + ImGui::TextColored(teamCol, "%s", at.teamName.c_str()); + ImGui::SameLine(); + char ratingBuf[32]; + if (ratingDelta >= 0) + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta); + else + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta); + ImGui::TextDisabled("%s", ratingBuf); + } + ImGui::Separator(); + } + + // Winner banner + if (data->hasWinner) { + const char* winnerStr; + ImVec4 winnerColor; + if (data->isArena) { + // For arenas, winner byte 0/1 refers to team index in arenaTeams[] + const auto& winTeam = data->arenaTeams[data->winner & 1]; + winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str(); + winnerColor = (data->winner == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) + : ImVec4(0.4f, 0.6f, 1.0f, 1.0f); + } else { + winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) + : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); + } + float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); + ImGui::TextColored(winnerColor, "%s", winnerStr); + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Victory!"); + ImGui::Separator(); + } + + // Refresh button + if (ImGui::SmallButton("Refresh")) { + gameHandler.requestPvpLog(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu players", data->players.size()); + + // Score table + constexpr ImGuiTableFlags kTableFlags = + ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_BordersOuter | ImGuiTableFlags_BordersV | + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_Sortable; + + // Build dynamic column count based on what BG-specific stats are present + int numBgCols = 0; + std::vector bgColNames; + for (const auto& ps : data->players) { + for (const auto& [fieldName, val] : ps.bgStats) { + // Extract short name after last '.' (e.g. "BattlegroundAB.AbFlagCaptures" → "Caps") + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + bool found = false; + for (const auto& n : bgColNames) { if (n == shortName) { found = true; break; } } + if (!found) bgColNames.push_back(shortName); + } + } + numBgCols = static_cast(bgColNames.size()); + + // Fixed cols: Team | Name | KB | Deaths | HKs | Honor; then BG-specific + int totalCols = 6 + numBgCols; + float tableH = ImGui::GetContentRegionAvail().y; + if (ImGui::BeginTable("##BgScoreTable", totalCols, kTableFlags, ImVec2(0.0f, tableH))) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Team", ImGuiTableColumnFlags_WidthFixed, 56.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("KB", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Deaths", ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableSetupColumn("HKs", ImGuiTableColumnFlags_WidthFixed, 38.0f); + ImGui::TableSetupColumn("Honor", ImGuiTableColumnFlags_WidthFixed, 52.0f); + for (const auto& col : bgColNames) + ImGui::TableSetupColumn(col.c_str(), ImGuiTableColumnFlags_WidthFixed, 52.0f); + ImGui::TableHeadersRow(); + + // Sort: Alliance first, then Horde; within each team by KB desc + std::vector sorted; + sorted.reserve(data->players.size()); + for (const auto& ps : data->players) sorted.push_back(&ps); + std::stable_sort(sorted.begin(), sorted.end(), + [](const game::GameHandler::BgPlayerScore* a, + const game::GameHandler::BgPlayerScore* b) { + if (a->team != b->team) return a->team > b->team; // Alliance(1) first + return a->killingBlows > b->killingBlows; + }); + + uint64_t playerGuid = gameHandler.getPlayerGuid(); + for (const auto* ps : sorted) { + ImGui::TableNextRow(); + + // Team + ImGui::TableNextColumn(); + if (ps->team == 1) + ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "Alliance"); + else + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "Horde"); + + // Name (highlight player's own row) + ImGui::TableNextColumn(); + bool isSelf = (ps->guid == playerGuid); + if (isSelf) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + const char* nameStr = ps->name.empty() ? "Unknown" : ps->name.c_str(); + ImGui::TextUnformatted(nameStr); + if (isSelf) ImGui::PopStyleColor(); + + ImGui::TableNextColumn(); ImGui::Text("%u", ps->killingBlows); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->deaths); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->honorableKills); + ImGui::TableNextColumn(); ImGui::Text("%u", ps->bonusHonor); + + for (const auto& col : bgColNames) { + ImGui::TableNextColumn(); + uint32_t val = 0; + for (const auto& [fieldName, fval] : ps->bgStats) { + std::string shortName = fieldName; + auto dotPos = fieldName.rfind('.'); + if (dotPos != std::string::npos) shortName = fieldName.substr(dotPos + 1); + if (shortName == col) { val = fval; break; } + } + if (val > 0) ImGui::Text("%u", val); + else ImGui::TextDisabled("-"); + } + } + ImGui::EndTable(); + } + + ImGui::End(); +} + + + +// ─── Book / Scroll / Note Window ────────────────────────────────────────────── +void GameScreen::renderBookWindow(game::GameHandler& gameHandler) { + // Auto-open when new pages arrive + if (gameHandler.hasBookOpen() && !showBookWindow_) { + showBookWindow_ = true; + bookCurrentPage_ = 0; + } + if (!showBookWindow_) return; + + const auto& pages = gameHandler.getBookPages(); + if (pages.empty()) { showBookWindow_ = false; return; } + + // Clamp page index + if (bookCurrentPage_ < 0) bookCurrentPage_ = 0; + if (bookCurrentPage_ >= static_cast(pages.size())) + bookCurrentPage_ = static_cast(pages.size()) - 1; + + ImGui::SetNextWindowSize(ImVec2(420, 340), ImGuiCond_Appearing); + ImGui::SetNextWindowPos(ImVec2(400, 180), ImGuiCond_Appearing); + + bool open = showBookWindow_; + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.12f, 0.09f, 0.06f, 0.98f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.25f, 0.18f, 0.08f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.37f, 0.18f, 1.0f)); + + char title[64]; + if (pages.size() > 1) + snprintf(title, sizeof(title), "Page %d / %d###BookWin", + bookCurrentPage_ + 1, static_cast(pages.size())); + else + snprintf(title, sizeof(title), "###BookWin"); + + if (ImGui::Begin(title, &open, ImGuiWindowFlags_NoCollapse)) { + // Parchment text colour + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.85f, 0.78f, 0.62f, 1.0f)); + + const std::string& text = pages[bookCurrentPage_].text; + // Use a child region with word-wrap + ImGui::SetNextWindowContentSize(ImVec2(ImGui::GetContentRegionAvail().x, 0)); + if (ImGui::BeginChild("##BookText", + ImVec2(0, ImGui::GetContentRegionAvail().y - 34), + false, ImGuiWindowFlags_HorizontalScrollbar)) { + ImGui::SetNextItemWidth(-1); + ImGui::TextWrapped("%s", text.c_str()); + } + ImGui::EndChild(); + ImGui::PopStyleColor(); + + // Navigation row + ImGui::Separator(); + bool canPrev = (bookCurrentPage_ > 0); + bool canNext = (bookCurrentPage_ < static_cast(pages.size()) - 1); + + if (!canPrev) ImGui::BeginDisabled(); + if (ImGui::Button("< Prev", ImVec2(80, 0))) bookCurrentPage_--; + if (!canPrev) ImGui::EndDisabled(); + + ImGui::SameLine(); + if (!canNext) ImGui::BeginDisabled(); + if (ImGui::Button("Next >", ImVec2(80, 0))) bookCurrentPage_++; + if (!canNext) ImGui::EndDisabled(); + + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 60); + if (ImGui::Button("Close", ImVec2(60, 0))) { + open = false; + } + } + ImGui::End(); + ImGui::PopStyleColor(3); + + if (!open) { + showBookWindow_ = false; + gameHandler.clearBook(); + } +} + +// ─── Inspect Window ─────────────────────────────────────────────────────────── +void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { + if (!showInspectWindow_) return; + + // Lazy-load SpellItemEnchantment.dbc for enchant name lookup + static std::unordered_map s_enchantNames; + static bool s_enchantDbLoaded = false; + auto* assetMgrEnchant = core::Application::getInstance().getAssetManager(); + if (!s_enchantDbLoaded && assetMgrEnchant && assetMgrEnchant->isInitialized()) { + s_enchantDbLoaded = true; + auto dbc = assetMgrEnchant->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") + : nullptr; + uint32_t idField = layout ? (*layout)["ID"] : 0; + uint32_t nameField = layout ? (*layout)["Name"] : 8; + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, idField); + if (id == 0) continue; + std::string nm = dbc->getString(i, nameField); + if (!nm.empty()) s_enchantNames[id] = std::move(nm); + } + } + } + + // Slot index 0..18 maps to equipment slots 1..19 (WoW convention: slot 0 unused on server) + static const char* kSlotNames[19] = { + "Head", "Neck", "Shoulder", "Shirt", "Chest", + "Waist", "Legs", "Feet", "Wrist", "Hands", + "Finger 1", "Finger 2", "Trinket 1", "Trinket 2", "Back", + "Main Hand", "Off Hand", "Ranged", "Tabard" + }; + + ImGui::SetNextWindowSize(ImVec2(360, 440), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(350, 120), ImGuiCond_FirstUseEver); + + const game::GameHandler::InspectResult* result = gameHandler.getInspectResult(); + + std::string title = result ? ("Inspect: " + result->playerName + "###InspectWin") + : "Inspect###InspectWin"; + if (!ImGui::Begin(title.c_str(), &showInspectWindow_, ImGuiWindowFlags_NoCollapse)) { + ImGui::End(); + return; + } + + if (!result) { + ImGui::TextDisabled("No inspect data yet. Target a player and use Inspect."); + ImGui::End(); + return; + } + + // Player name — class-colored if entity is loaded, else gold + { + auto ent = gameHandler.getEntityManager().getEntity(result->guid); + uint8_t cid = entityClassId(ent.get()); + ImVec4 nameColor = (cid != 0) ? classColorVec4(cid) : ImVec4(1.0f, 0.82f, 0.0f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::Text("%s", result->playerName.c_str()); + ImGui::PopStyleColor(); + if (cid != 0) { + ImGui::SameLine(); + ImGui::TextColored(classColorVec4(cid), "(%s)", classNameStr(cid)); + } + } + ImGui::SameLine(); + ImGui::TextDisabled(" %u talent pts", result->totalTalents); + if (result->unspentTalents > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "(%u unspent)", result->unspentTalents); + } + if (result->talentGroups > 1) { + ImGui::SameLine(); + ImGui::TextDisabled(" Dual spec (active %u)", (unsigned)result->activeTalentGroup + 1); + } + + ImGui::Separator(); + + // Equipment list + bool hasAnyGear = false; + for (int s = 0; s < 19; ++s) { + if (result->itemEntries[s] != 0) { hasAnyGear = true; break; } + } + + if (!hasAnyGear) { + ImGui::TextDisabled("Equipment data not yet available."); + ImGui::TextDisabled("(Gear loads after the player is inspected in-range)"); + } else { + // Average item level (only slots that have loaded info and are not shirt/tabard) + // Shirt=slot3, Tabard=slot18 — excluded from gear score by WoW convention + uint32_t iLevelSum = 0; + int iLevelCount = 0; + for (int s = 0; s < 19; ++s) { + if (s == 3 || s == 18) continue; // shirt, tabard + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (info && info->valid && info->itemLevel > 0) { + iLevelSum += info->itemLevel; + ++iLevelCount; + } + } + if (iLevelCount > 0) { + float avgIlvl = static_cast(iLevelSum) / static_cast(iLevelCount); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Avg iLvl: %.1f", avgIlvl); + ImGui::SameLine(); + ImGui::TextDisabled("(%d/%d slots loaded)", iLevelCount, + [&]{ int c=0; for(int s=0;s<19;++s){if(s==3||s==18)continue;if(result->itemEntries[s])++c;} return c; }()); + } + if (ImGui::BeginChild("##inspect_gear", ImVec2(0, 0), false)) { + constexpr float kIconSz = 28.0f; + for (int s = 0; s < 19; ++s) { + uint32_t entry = result->itemEntries[s]; + if (entry == 0) continue; + + const game::ItemQueryResponseData* info = gameHandler.getItemInfo(entry); + if (!info) { + gameHandler.ensureItemInfo(entry); + ImGui::PushID(s); + ImGui::TextDisabled("[%s] (loading…)", kSlotNames[s]); + ImGui::PopID(); + continue; + } + + ImGui::PushID(s); + auto qColor = InventoryScreen::getQualityColor( + static_cast(info->quality)); + uint16_t enchantId = result->enchantIds[s]; + + // Item icon + VkDescriptorSet iconTex = inventoryScreen.getItemIcon(info->displayInfoId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(kIconSz, kIconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(1,1,1,1), qColor); + } else { + ImGui::GetWindowDrawList()->AddRectFilled( + ImGui::GetCursorScreenPos(), + ImVec2(ImGui::GetCursorScreenPos().x + kIconSz, + ImGui::GetCursorScreenPos().y + kIconSz), + IM_COL32(40, 40, 50, 200)); + ImGui::Dummy(ImVec2(kIconSz, kIconSz)); + } + bool hovered = ImGui::IsItemHovered(); + + ImGui::SameLine(); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + (kIconSz - ImGui::GetTextLineHeight()) * 0.5f); + ImGui::BeginGroup(); + ImGui::TextDisabled("%s", kSlotNames[s]); + ImGui::TextColored(qColor, "%s", info->name.c_str()); + // Enchant indicator on the same row as the name + if (enchantId != 0) { + auto enchIt = s_enchantNames.find(enchantId); + const std::string& enchName = (enchIt != s_enchantNames.end()) + ? enchIt->second : std::string{}; + ImGui::SameLine(); + if (!enchName.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), + "\xe2\x9c\xa6 %s", enchName.c_str()); // UTF-8 ✦ + } else { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, 1.0f), "\xe2\x9c\xa6"); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Enchanted (ID %u)", static_cast(enchantId)); + } + } + ImGui::EndGroup(); + hovered = hovered || ImGui::IsItemHovered(); + + if (hovered && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered) { + ImGui::SetTooltip("%s", info->name.c_str()); + } + + ImGui::PopID(); + ImGui::Spacing(); + } + } + ImGui::EndChild(); + } + + // Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS) + if (!result->arenaTeams.empty()) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams"); + ImGui::Spacing(); + for (const auto& team : result->arenaTeams) { + const char* bracket = (team.type == 2) ? "2v2" + : (team.type == 3) ? "3v3" + : (team.type == 5) ? "5v5" : "?v?"; + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), + "[%s] %s", bracket, team.name.c_str()); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f), + " Rating: %u", team.personalRating); + if (team.weekGames > 0 || team.seasonGames > 0) { + ImGui::TextDisabled(" Week: %u/%u Season: %u/%u", + team.weekWins, team.weekGames, + team.seasonWins, team.seasonGames); + } + } + } + + ImGui::End(); +} + +// ─── Titles Window ──────────────────────────────────────────────────────────── +void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) { + if (!showTitlesWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Titles", &showTitlesWindow_)) { + ImGui::End(); + return; + } + + const auto& knownBits = gameHandler.getKnownTitleBits(); + const int32_t chosen = gameHandler.getChosenTitleBit(); + + if (knownBits.empty()) { + ImGui::TextDisabled("No titles earned yet."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Select a title to display:"); + ImGui::Separator(); + + // "No Title" option + bool noTitle = (chosen < 0); + if (ImGui::Selectable("(No Title)", noTitle)) { + if (!noTitle) gameHandler.sendSetTitle(-1); + } + if (noTitle) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "<-- active"); + } + + ImGui::Separator(); + + // Sort known bits for stable display order + std::vector sortedBits(knownBits.begin(), knownBits.end()); + std::sort(sortedBits.begin(), sortedBits.end()); + + ImGui::BeginChild("##titlelist", ImVec2(0, 0), false); + for (uint32_t bit : sortedBits) { + const std::string title = gameHandler.getFormattedTitle(bit); + const std::string display = title.empty() + ? ("Title #" + std::to_string(bit)) : title; + + bool isActive = (chosen >= 0 && static_cast(chosen) == bit); + ImGui::PushID(static_cast(bit)); + + if (isActive) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + } + if (ImGui::Selectable(display.c_str(), isActive)) { + if (!isActive) gameHandler.sendSetTitle(static_cast(bit)); + } + if (isActive) { + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("<-- active"); + } + + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + +// ─── Equipment Set Manager Window ───────────────────────────────────────────── +void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) { + if (!showEquipSetWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) { + ImGui::End(); + return; + } + + const auto& sets = gameHandler.getEquipmentSets(); + + if (sets.empty()) { + ImGui::TextDisabled("No equipment sets saved."); + ImGui::Spacing(); + ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button)."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Click a set to equip it:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false); + for (const auto& set : sets) { + ImGui::PushID(static_cast(set.setId)); + + // Icon placeholder (use a coloured square if no icon texture available) + ImVec2 iconSize(32.0f, 32.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f)); + if (ImGui::Button("##icon", iconSize)) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(3); + + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Equip set: %s", set.name.c_str()); + } + + ImGui::SameLine(); + + // Name and equip button + ImGui::BeginGroup(); + ImGui::TextUnformatted(set.name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f)); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(2); + ImGui::EndGroup(); + + ImGui::Spacing(); + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + +void GameScreen::renderSkillsWindow(game::GameHandler& gameHandler) { + if (!showSkillsWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(380, 480), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(220, 130), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Skills & Professions", &showSkillsWindow_)) { + ImGui::End(); + return; + } + + const auto& skills = gameHandler.getPlayerSkills(); + if (skills.empty()) { + ImGui::TextDisabled("No skill data received yet."); + ImGui::End(); + return; + } + + // Organise skills by category + // WoW SkillLine.dbc categories: 6=Weapon, 7=Class, 8=Armor, 9=Secondary, 11=Professions, others=Misc + struct SkillEntry { + uint32_t skillId; + const game::PlayerSkill* skill; + }; + std::map> byCategory; + for (const auto& [id, sk] : skills) { + uint32_t cat = gameHandler.getSkillCategory(id); + byCategory[cat].push_back({id, &sk}); + } + + static const struct { uint32_t cat; const char* label; } kCatOrder[] = { + {11, "Professions"}, + { 9, "Secondary Skills"}, + { 7, "Class Skills"}, + { 6, "Weapon Skills"}, + { 8, "Armor"}, + { 5, "Languages"}, + { 0, "Other"}, + }; + + // Collect handled categories to fall back to "Other" for unknowns + static const uint32_t kKnownCats[] = {11, 9, 7, 6, 8, 5}; + + // Redirect unknown categories into bucket 0 + for (auto& [cat, vec] : byCategory) { + bool known = false; + for (uint32_t kc : kKnownCats) if (cat == kc) { known = true; break; } + if (!known && cat != 0) { + auto& other = byCategory[0]; + other.insert(other.end(), vec.begin(), vec.end()); + vec.clear(); + } + } + + ImGui::BeginChild("##skillscroll", ImVec2(0, 0), false); + + for (const auto& [cat, label] : kCatOrder) { + auto it = byCategory.find(cat); + if (it == byCategory.end() || it->second.empty()) continue; + + auto& entries = it->second; + // Sort alphabetically within each category + std::sort(entries.begin(), entries.end(), [&](const SkillEntry& a, const SkillEntry& b) { + return gameHandler.getSkillName(a.skillId) < gameHandler.getSkillName(b.skillId); + }); + + if (ImGui::CollapsingHeader(label, ImGuiTreeNodeFlags_DefaultOpen)) { + for (const auto& e : entries) { + const std::string& name = gameHandler.getSkillName(e.skillId); + const char* displayName = name.empty() ? "Unknown" : name.c_str(); + uint16_t val = e.skill->effectiveValue(); + uint16_t maxVal = e.skill->maxValue; + + ImGui::PushID(static_cast(e.skillId)); + + // Name column + ImGui::TextUnformatted(displayName); + ImGui::SameLine(170.0f); + + // Progress bar + float fraction = (maxVal > 0) ? static_cast(val) / static_cast(maxVal) : 0.0f; + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", val, maxVal); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.20f, 0.55f, 0.20f, 1.0f)); + ImGui::ProgressBar(fraction, ImVec2(160.0f, 14.0f), overlay); + ImGui::PopStyleColor(); + + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::Text("%s", displayName); + ImGui::Separator(); + ImGui::Text("Base: %u", e.skill->value); + if (e.skill->bonusPerm > 0) + ImGui::Text("Permanent bonus: +%u", e.skill->bonusPerm); + if (e.skill->bonusTemp > 0) + ImGui::Text("Temporary bonus: +%u", e.skill->bonusTemp); + ImGui::Text("Max: %u", maxVal); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + } + ImGui::Spacing(); + } + } + + ImGui::EndChild(); + ImGui::End(); +} + }} // namespace wowee::ui diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 320fc316..2ea91c10 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1,4 +1,5 @@ #include "ui/inventory_screen.hpp" +#include "ui/keybinding_manager.hpp" #include "game/game_handler.hpp" #include "core/application.hpp" #include "rendering/vk_context.hpp" @@ -16,6 +17,7 @@ #include #include #include +#include #include namespace wowee { @@ -71,6 +73,21 @@ const game::ItemSlot* findComparableEquipped(const game::Inventory& inventory, u default: return nullptr; } } + +void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); +} } // namespace InventoryScreen::~InventoryScreen() { @@ -86,6 +103,8 @@ ImVec4 InventoryScreen::getQualityColor(game::ItemQuality quality) { case game::ItemQuality::RARE: return ImVec4(0.0f, 0.44f, 0.87f, 1.0f); // Blue case game::ItemQuality::EPIC: return ImVec4(0.64f, 0.21f, 0.93f, 1.0f); // Purple case game::ItemQuality::LEGENDARY: return ImVec4(1.0f, 0.50f, 0.0f, 1.0f); // Orange + case game::ItemQuality::ARTIFACT: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold + case game::ItemQuality::HEIRLOOM: return ImVec4(0.90f, 0.80f, 0.50f, 1.0f); // Light gold default: return ImVec4(1.0f, 1.0f, 1.0f, 1.0f); } } @@ -100,6 +119,14 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { auto it = iconCache_.find(displayInfoId); if (it != iconCache_.end()) return it->second; + // Rate-limit GPU uploads per frame to avoid stalling when many items appear at once + // (e.g., opening a full bag, vendor window, or loot from a boss with many drops). + static int iiLoadsThisFrame = 0; + static int iiLastImGuiFrame = -1; + int iiCurFrame = ImGui::GetFrameCount(); + if (iiCurFrame != iiLastImGuiFrame) { iiLoadsThisFrame = 0; iiLastImGuiFrame = iiCurFrame; } + if (iiLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + // Load ItemDisplayInfo.dbc auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc) { @@ -142,6 +169,7 @@ VkDescriptorSet InventoryScreen::getItemIcon(uint32_t displayInfoId) { return VK_NULL_HANDLE; } + ++iiLoadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); iconCache_[displayInfoId] = ds; return ds; @@ -709,18 +737,13 @@ bool InventoryScreen::bagHasAnyItems(const game::Inventory& inventory, int bagIn } void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { - // B key toggle (edge-triggered) - bool wantsTextInput = ImGui::GetIO().WantTextInput; - bool bDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_B); - bool bToggled = bDown && !bKeyWasDown; - bKeyWasDown = bDown; + // Bags toggle (B key, edge-triggered) + bool bagsDown = KeybindingManager::getInstance().isActionPressed( + KeybindingManager::Action::TOGGLE_BAGS, false); + bool bToggled = bagsDown && !bKeyWasDown; + bKeyWasDown = bagsDown; - // C key toggle for character screen (edge-triggered) - bool cDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_C); - if (cDown && !cKeyWasDown) { - characterOpen = !characterOpen; - } - cKeyWasDown = cDown; + bool wantsTextInput = ImGui::GetIO().WantTextInput; if (separateBags_) { if (bToggled) { @@ -821,6 +844,62 @@ void InventoryScreen::render(game::Inventory& inventory, uint64_t moneyCopper) { ImGui::EndPopup(); } + // Shift+right-click destroy confirmation popup + if (destroyConfirmOpen_) { + ImVec2 mousePos = ImGui::GetIO().MousePos; + ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always); + ImGui::OpenPopup("##DestroyItem"); + destroyConfirmOpen_ = false; + } + if (ImGui::BeginPopup("##DestroyItem", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Destroy"); + ImGui::TextUnformatted(destroyItemName_.c_str()); + ImGui::Spacing(); + if (ImGui::Button("Yes, Destroy", ImVec2(110, 0))) { + if (gameHandler_) { + gameHandler_->destroyItem(destroyBag_, destroySlot_, destroyCount_); + } + destroyItemName_.clear(); + inventoryDirty = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(70, 0))) { + destroyItemName_.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + + // Stack split popup + if (splitConfirmOpen_) { + ImVec2 mousePos = ImGui::GetIO().MousePos; + ImGui::SetNextWindowPos(ImVec2(mousePos.x - 80.0f, mousePos.y - 20.0f), ImGuiCond_Always); + ImGui::OpenPopup("##SplitStack"); + splitConfirmOpen_ = false; + } + if (ImGui::BeginPopup("##SplitStack", ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + ImGui::Text("Split %s", splitItemName_.c_str()); + ImGui::Spacing(); + ImGui::SetNextItemWidth(120.0f); + ImGui::SliderInt("##splitcount", &splitCount_, 1, splitMax_ - 1); + ImGui::Spacing(); + if (ImGui::Button("OK", ImVec2(55, 0))) { + if (gameHandler_ && splitCount_ > 0 && splitCount_ < splitMax_) { + gameHandler_->splitItem(splitBag_, splitSlot_, static_cast(splitCount_)); + } + splitItemName_.clear(); + inventoryDirty = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(55, 0))) { + splitItemName_.clear(); + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + // Draw held item at cursor renderHeldItem(); } @@ -853,10 +932,10 @@ void InventoryScreen::renderAggregateBags(game::Inventory& inventory, uint64_t m float posX = screenW - windowW - 10.0f; float posY = screenH - windowH - 60.0f; - ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove; + ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; if (!ImGui::Begin("Bags", &open, flags)) { ImGui::End(); return; @@ -967,7 +1046,11 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, int rows = (numSlots + columns - 1) / columns; float contentH = rows * (slotSize + 4.0f) + 10.0f; - if (bagIndex < 0) contentH += 25.0f; // money display for backpack + if (bagIndex < 0) { + int keyringRows = (inventory.getKeyringSize() + columns - 1) / columns; + contentH += 36.0f; // separator + sort button + money display + contentH += 30.0f + keyringRows * (slotSize + 4.0f); // keyring header + slots + } float gridW = columns * (slotSize + 4.0f) + 30.0f; // Ensure window is wide enough for the title + close button const char* displayTitle = title; @@ -976,8 +1059,7 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, float windowW = std::max(gridW, titleW); float windowH = contentH + 40.0f; // title bar + padding - // Keep separate bag windows anchored to the bag-bar stack. - ImGui::SetNextWindowPos(ImVec2(defaultX, defaultY), ImGuiCond_Always); + ImGui::SetNextWindowPos(ImVec2(defaultX, defaultY), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(windowW, windowH), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; @@ -1015,16 +1097,55 @@ void InventoryScreen::renderBagWindow(const char* title, bool& isOpen, ImGui::PopID(); } - // Money display at bottom of backpack - if (bagIndex < 0 && moneyCopper > 0) { + if (bagIndex < 0 && showKeyring_) { + constexpr float keySlotSize = 24.0f; + constexpr int keyCols = 8; + // Only show rows that contain items (round up to full row) + int lastOccupied = -1; + for (int i = inventory.getKeyringSize() - 1; i >= 0; --i) { + if (!inventory.getKeyringSlot(i).empty()) { lastOccupied = i; break; } + } + int visibleSlots = (lastOccupied < 0) ? 0 : ((lastOccupied / keyCols) + 1) * keyCols; + if (visibleSlots > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); + for (int i = 0; i < visibleSlots; ++i) { + if (i % keyCols != 0) ImGui::SameLine(); + const auto& slot = inventory.getKeyringSlot(i); + char id[32]; + snprintf(id, sizeof(id), "##skr_%d", i); + ImGui::PushID(id); + renderItemSlot(inventory, slot, keySlotSize, nullptr, + SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); + ImGui::PopID(); + } + } + } + + // Footer for backpack: sort button + money display + if (bagIndex < 0) { ImGui::Spacing(); - uint64_t gold = moneyCopper / 10000; - uint64_t silver = (moneyCopper / 100) % 100; - uint64_t copper = moneyCopper % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", - static_cast(gold), - static_cast(silver), - static_cast(copper)); + ImGui::Separator(); + + // Sort Bags button — client-side reorder by quality/type + if (ImGui::SmallButton("Sort Bags")) { + inventory.sortBags(); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Sort all bag slots by quality (highest first),\nthen by item ID, then by stack size."); + } + + if (moneyCopper > 0) { + ImGui::SameLine(); + uint64_t gold = moneyCopper / 10000; + uint64_t silver = (moneyCopper / 100) % 100; + uint64_t copper = moneyCopper % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "%llug %llus %lluc", + static_cast(gold), + static_cast(silver), + static_cast(copper)); + } } ImGui::End(); @@ -1081,12 +1202,69 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (ImGui::BeginTabBar("##CharacterTabs")) { if (ImGui::BeginTabItem("Equipment")) { renderEquipmentPanel(inventory); + ImGui::Spacing(); + ImGui::Separator(); + // Appearance visibility toggles + bool helmVis = gameHandler.isHelmVisible(); + bool cloakVis = gameHandler.isCloakVisible(); + if (ImGui::Checkbox("Show Helm", &helmVis)) { + gameHandler.toggleHelm(); + } + ImGui::SameLine(); + if (ImGui::Checkbox("Show Cloak", &cloakVis)) { + gameHandler.toggleCloak(); + } ImGui::EndTabItem(); } if (ImGui::BeginTabItem("Stats")) { ImGui::Spacing(); - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating()); + int32_t stats[5]; + for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); + const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; + int32_t resists[6]; + for (int i = 0; i < 6; ++i) resists[i] = gameHandler.getResistance(i + 1); + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats, resists, &gameHandler); + + // Played time (shown if available, fetched on character screen open) + uint32_t totalSec = gameHandler.getTotalTimePlayed(); + uint32_t levelSec = gameHandler.getLevelTimePlayed(); + if (totalSec > 0 || levelSec > 0) { + ImGui::Separator(); + // Helper lambda to format seconds as "Xd Xh Xm" + auto fmtTime = [](uint32_t sec) -> std::string { + uint32_t d = sec / 86400, h = (sec % 86400) / 3600, m = (sec % 3600) / 60; + char buf[48]; + if (d > 0) snprintf(buf, sizeof(buf), "%ud %uh %um", d, h, m); + else if (h > 0) snprintf(buf, sizeof(buf), "%uh %um", h, m); + else snprintf(buf, sizeof(buf), "%um", m); + return buf; + }; + ImGui::TextDisabled("Time Played"); + ImGui::Columns(2, "##playtime", false); + ImGui::SetColumnWidth(0, 130); + ImGui::Text("Total:"); ImGui::NextColumn(); + ImGui::Text("%s", fmtTime(totalSec).c_str()); ImGui::NextColumn(); + ImGui::Text("This level:"); ImGui::NextColumn(); + ImGui::Text("%s", fmtTime(levelSec).c_str()); ImGui::NextColumn(); + ImGui::Columns(1); + } + + // PvP Currency (TBC/WotLK only) + uint32_t honor = gameHandler.getHonorPoints(); + uint32_t arena = gameHandler.getArenaPoints(); + if (honor > 0 || arena > 0) { + ImGui::Separator(); + ImGui::TextDisabled("PvP Currency"); + ImGui::Columns(2, "##pvpcurrency", false); + ImGui::SetColumnWidth(0, 130); + ImGui::Text("Honor Points:"); ImGui::NextColumn(); + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.2f, 1.0f), "%u", honor); ImGui::NextColumn(); + ImGui::Text("Arena Points:"); ImGui::NextColumn(); + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.2f, 1.0f), "%u", arena); ImGui::NextColumn(); + ImGui::Columns(1); + } + ImGui::EndTabItem(); } @@ -1143,18 +1321,35 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { snprintf(label, sizeof(label), "%s", name.c_str()); } - // Show progress bar with value/max overlay + // Effective value includes temporary and permanent bonuses + uint16_t effective = skill->effectiveValue(); + uint16_t bonus = skill->bonusTemp + skill->bonusPerm; + + // Progress bar reflects effective / max; cap visual fill at 1.0 float ratio = (skill->maxValue > 0) - ? static_cast(skill->value) / static_cast(skill->maxValue) + ? std::min(1.0f, static_cast(effective) / static_cast(skill->maxValue)) : 0.0f; char overlay[64]; - snprintf(overlay, sizeof(overlay), "%u / %u", skill->value, skill->maxValue); + if (bonus > 0) + snprintf(overlay, sizeof(overlay), "%u / %u (+%u)", effective, skill->maxValue, bonus); + else + snprintf(overlay, sizeof(overlay), "%u / %u", effective, skill->maxValue); - ImGui::Text("%s", label); + // Gold name when maxed out, cyan when buffed above base, default otherwise + bool isMaxed = (effective >= skill->maxValue && skill->maxValue > 0); + bool isBuffed = (bonus > 0); + ImVec4 nameColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) + : isBuffed ? ImVec4(0.4f, 0.9f, 1.0f, 1.0f) + : ImVec4(0.85f, 0.85f, 0.85f, 1.0f); + ImGui::TextColored(nameColor, "%s", label); ImGui::SameLine(180.0f); ImGui::SetNextItemWidth(-1.0f); + // Bar color: gold when maxed, green otherwise + ImVec4 barColor = isMaxed ? ImVec4(1.0f, 0.82f, 0.0f, 1.0f) : ImVec4(0.2f, 0.7f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); ImGui::ProgressBar(ratio, ImVec2(0, 14.0f), overlay); + ImGui::PopStyleColor(); } } } @@ -1164,6 +1359,135 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Achievements")) { + const auto& earned = gameHandler.getEarnedAchievements(); + if (earned.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("No achievements earned yet."); + } else { + static char achieveFilter[128] = {}; + ImGui::SetNextItemWidth(-1.0f); + ImGui::InputTextWithHint("##achsearch", "Search achievements...", + achieveFilter, sizeof(achieveFilter)); + ImGui::Separator(); + + char filterLower[128]; + for (size_t i = 0; i < sizeof(achieveFilter); ++i) + filterLower[i] = static_cast(tolower(static_cast(achieveFilter[i]))); + + ImGui::BeginChild("##AchList", ImVec2(0, 0), false); + // Sort by ID for stable ordering + std::vector sortedIds(earned.begin(), earned.end()); + std::sort(sortedIds.begin(), sortedIds.end()); + int shown = 0; + for (uint32_t id : sortedIds) { + const std::string& name = gameHandler.getAchievementName(id); + const char* displayName = name.empty() ? nullptr : name.c_str(); + if (displayName == nullptr) continue; // skip unknown achievements + + // Apply filter + if (filterLower[0] != '\0') { + // simple case-insensitive substring match + std::string lower; + lower.reserve(name.size()); + for (char c : name) lower += static_cast(tolower(static_cast(c))); + if (lower.find(filterLower) == std::string::npos) continue; + } + + ImGui::PushID(static_cast(id)); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "[Achievement]"); + ImGui::SameLine(); + ImGui::Text("%s", displayName); + ImGui::PopID(); + ++shown; + } + if (shown == 0 && filterLower[0] != '\0') { + ImGui::TextDisabled("No achievements match the filter."); + } + ImGui::Text("Total: %d", static_cast(earned.size())); + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + + if (ImGui::BeginTabItem("PvP")) { + const auto& arenaStats = gameHandler.getArenaTeamStats(); + if (arenaStats.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("Not a member of any Arena team."); + } else { + for (const auto& team : arenaStats) { + ImGui::PushID(static_cast(team.teamId)); + char header[64]; + snprintf(header, sizeof(header), "Team ID %u (Rating: %u)", team.teamId, team.rating); + if (ImGui::CollapsingHeader(header, ImGuiTreeNodeFlags_DefaultOpen)) { + ImGui::Columns(2, "##arenacols", false); + ImGui::Text("Rating:"); ImGui::NextColumn(); + ImGui::Text("%u", team.rating); ImGui::NextColumn(); + ImGui::Text("Rank:"); ImGui::NextColumn(); + ImGui::Text("#%u", team.rank); ImGui::NextColumn(); + ImGui::Text("This week:"); ImGui::NextColumn(); + ImGui::Text("%u / %u (W/G)", team.weekWins, team.weekGames); ImGui::NextColumn(); + ImGui::Text("Season:"); ImGui::NextColumn(); + ImGui::Text("%u / %u (W/G)", team.seasonWins, team.seasonGames); ImGui::NextColumn(); + ImGui::Columns(1); + } + ImGui::PopID(); + } + } + ImGui::EndTabItem(); + } + + // Equipment Sets tab (WotLK only — requires server support) + if (gameHandler.supportsEquipmentSets() && ImGui::BeginTabItem("Outfits")) { + ImGui::Spacing(); + + // Save current gear as new set + static char newSetName[64] = {}; + ImGui::SetNextItemWidth(160.0f); + ImGui::InputTextWithHint("##newsetname", "New set name...", newSetName, sizeof(newSetName)); + ImGui::SameLine(); + bool canSave = (newSetName[0] != '\0'); + if (!canSave) ImGui::BeginDisabled(); + if (ImGui::SmallButton("Save Current Gear")) { + gameHandler.saveEquipmentSet(newSetName); + newSetName[0] = '\0'; + } + if (!canSave) ImGui::EndDisabled(); + + ImGui::Separator(); + + const auto& eqSets = gameHandler.getEquipmentSets(); + if (eqSets.empty()) { + ImGui::TextDisabled("No saved equipment sets."); + } else { + ImGui::BeginChild("##EqSetsList", ImVec2(0, 0), false); + for (const auto& es : eqSets) { + ImGui::PushID(static_cast(es.setId)); + const char* displayName = es.name.empty() ? "(Unnamed)" : es.name.c_str(); + ImGui::Text("%s", displayName); + float btnAreaW = 150.0f; + ImGui::SameLine(ImGui::GetContentRegionAvail().x - btnAreaW + ImGui::GetCursorPosX()); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(es.setId); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Update")) { + gameHandler.saveEquipmentSet(es.name, es.iconName, es.setGuid, es.setId); + } + ImGui::SameLine(); + if (ImGui::SmallButton("Delete")) { + gameHandler.deleteEquipmentSet(es.setGuid); + ImGui::PopID(); + break; // Iterator invalidated + } + ImGui::PopID(); + } + ImGui::EndChild(); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } @@ -1204,8 +1528,9 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { { "Exalted", 42000, 42000, ImVec4(1.0f, 0.84f, 0.0f, 1.0f) }, }; + constexpr int kNumTiers = static_cast(sizeof(tiers) / sizeof(tiers[0])); auto getTier = [&](int32_t val) -> const RepTier& { - for (int i = 6; i >= 0; --i) { + for (int i = kNumTiers - 1; i >= 0; --i) { if (val >= tiers[i].floor) return tiers[i]; } return tiers[0]; @@ -1213,10 +1538,13 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { ImGui::BeginChild("##ReputationList", ImVec2(0, 0), true); - // Sort factions alphabetically by name + // Sort: watched faction first, then alphabetically by name + uint32_t watchedFactionId = gameHandler.getWatchedFactionId(); std::vector> sortedFactions(standings.begin(), standings.end()); std::sort(sortedFactions.begin(), sortedFactions.end(), [&](const auto& a, const auto& b) { + if (a.first == watchedFactionId) return true; + if (b.first == watchedFactionId) return false; const std::string& na = gameHandler.getFactionNamePublic(a.first); const std::string& nb = gameHandler.getFactionNamePublic(b.first); return na < nb; @@ -1228,10 +1556,27 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { const std::string& factionName = gameHandler.getFactionNamePublic(factionId); const char* displayName = factionName.empty() ? "Unknown Faction" : factionName.c_str(); - // Faction name + tier label on same line + // Determine at-war status via repListId lookup + uint32_t repListId = gameHandler.getRepListIdByFactionId(factionId); + bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId); + bool isWatched = (factionId == watchedFactionId); + + ImGui::PushID(static_cast(factionId)); + + // Faction name + tier label on same line; mark at-war and watched factions ImGui::TextColored(tier.color, "[%s]", tier.name); ImGui::SameLine(90.0f); - ImGui::Text("%s", displayName); + if (atWar) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", displayName); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "(At War)"); + } else if (isWatched) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "%s", displayName); + ImGui::SameLine(); + ImGui::TextDisabled("(Tracked)"); + } else { + ImGui::Text("%s", displayName); + } // Progress bar showing position within current tier float ratio = 0.0f; @@ -1253,7 +1598,23 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { ImGui::SetNextItemWidth(-1.0f); ImGui::ProgressBar(ratio, ImVec2(0, 12.0f), overlay); ImGui::PopStyleColor(); + + // Right-click context menu on the progress bar + if (ImGui::BeginPopupContextItem("##RepCtx")) { + ImGui::TextDisabled("%s", displayName); + ImGui::Separator(); + if (isWatched) { + if (ImGui::MenuItem("Untrack")) + gameHandler.setWatchedFactionId(0); + } else { + if (ImGui::MenuItem("Track on Rep Bar")) + gameHandler.setWatchedFactionId(factionId); + } + ImGui::EndPopup(); + } + ImGui::Spacing(); + ImGui::PopID(); } ImGui::EndChild(); @@ -1350,7 +1711,7 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { } } - // Weapon row + // Weapon row - positioned to the right of left column to avoid crowding main equipment ImGui::Spacing(); ImGui::Separator(); @@ -1359,6 +1720,9 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { game::EquipSlot::OFF_HAND, game::EquipSlot::RANGED, }; + + // Position weapons in center column area (after left column, 3D preview renders on top) + ImGui::SetCursorPosX(contentStartX + slotSize + 8.0f); for (int i = 0; i < 3; i++) { if (i > 0) ImGui::SameLine(); const auto& slot = inventory.getEquipSlot(weaponSlots[i]); @@ -1376,18 +1740,46 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // Stats Panel // ============================================================ -void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor) { - // Sum equipment stats - int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0; - +void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, + int32_t serverArmor, const int32_t* serverStats, + const int32_t* serverResists, + const game::GameHandler* gh) { + // Sum equipment stats for item-query bonus display + int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; + // Secondary stat sums from extraStats + int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0; + int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0; + int32_t itemDefense = 0, itemDodge = 0, itemParry = 0, itemBlock = 0, itemBlockVal = 0; + int32_t itemArmorPen = 0, itemSpellPen = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; - totalStr += slot.item.strength; - totalAgi += slot.item.agility; - totalSta += slot.item.stamina; - totalInt += slot.item.intellect; - totalSpi += slot.item.spirit; + itemStr += slot.item.strength; + itemAgi += slot.item.agility; + itemSta += slot.item.stamina; + itemInt += slot.item.intellect; + itemSpi += slot.item.spirit; + for (const auto& es : slot.item.extraStats) { + switch (es.statType) { + case 12: itemDefense += es.statValue; break; + case 13: itemDodge += es.statValue; break; + case 14: itemParry += es.statValue; break; + case 15: itemBlock += es.statValue; break; + case 16: case 17: case 18: case 31: itemHit += es.statValue; break; + case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; + case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; + case 35: itemResil += es.statValue; break; + case 37: itemExpertise += es.statValue; break; + case 38: case 39: itemAP += es.statValue; break; + case 41: case 42: case 45: itemSP += es.statValue; break; + case 43: itemMp5 += es.statValue; break; + case 44: itemArmorPen += es.statValue; break; + case 46: itemHp5 += es.statValue; break; + case 47: itemSpellPen += es.statValue; break; + case 48: itemBlockVal += es.statValue; break; + default: break; + } + } } // Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available. @@ -1399,38 +1791,332 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor; - // Base stats: 20 + level - int32_t baseStat = 20 + static_cast(playerLevel); + // Average item level (exclude shirt/tabard as WoW convention) + { + uint32_t iLvlSum = 0; + int iLvlCount = 0; + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + auto eslot = static_cast(s); + if (eslot == game::EquipSlot::SHIRT || eslot == game::EquipSlot::TABARD) continue; + const auto& slot = inventory.getEquipSlot(eslot); + if (!slot.empty() && slot.item.itemLevel > 0) { + iLvlSum += slot.item.itemLevel; + ++iLvlCount; + } + } + if (iLvlCount > 0) { + float avg = static_cast(iLvlSum) / static_cast(iLvlCount); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 1.0f, 1.0f), + "Average Item Level: %.1f (%d/%d slots)", avg, iLvlCount, + game::Inventory::NUM_EQUIP_SLOTS - 2); // -2 for shirt/tabard + } + ImGui::Separator(); + } ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); ImVec4 gray(0.6f, 0.6f, 0.6f, 1.0f); + static const char* kStatTooltips[5] = { + "Increases your melee attack power by 2.\nIncreases your block value.", + "Increases your Armor.\nIncreases ranged attack power by 2.\nIncreases your chance to dodge attacks and score critical strikes.", + "Increases Health by 10 per point.", + "Increases your Mana pool.\nIncreases your chance to score a critical strike with spells.", + "Increases Health and Mana regeneration." + }; + // Armor (no base) + ImGui::BeginGroup(); if (totalArmor > 0) { ImGui::TextColored(gold, "Armor: %d", totalArmor); } else { ImGui::TextColored(gray, "Armor: 0"); } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("Reduces damage taken from physical attacks."); + ImGui::EndTooltip(); + } - // Helper to render a stat line - auto renderStat = [&](const char* name, int32_t equipBonus) { - int32_t total = baseStat + equipBonus; - if (equipBonus > 0) { - ImGui::TextColored(white, "%s: %d", name, total); - ImGui::SameLine(); - ImGui::TextColored(green, "(+%d)", equipBonus); - } else { - ImGui::TextColored(gray, "%s: %d", name, total); + if (serverStats) { + // Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus. + // serverStats[i] is the server's effective base stat (items included, buffs excluded). + const char* statNames[5] = {"Strength", "Agility", "Stamina", "Intellect", "Spirit"}; + const int32_t itemBonuses[5] = {itemStr, itemAgi, itemSta, itemInt, itemSpi}; + for (int i = 0; i < 5; ++i) { + int32_t total = serverStats[i]; + int32_t bonus = itemBonuses[i]; + ImGui::BeginGroup(); + if (bonus > 0) { + ImGui::TextColored(white, "%s: %d", statNames[i], total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", bonus); + } else { + ImGui::TextColored(gray, "%s: %d", statNames[i], total); + } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", kStatTooltips[i]); + ImGui::EndTooltip(); + } } - }; + } else { + // Fallback: estimated base (20 + level) plus item query bonuses. + int32_t baseStat = 20 + static_cast(playerLevel); + auto renderStat = [&](const char* name, int32_t equipBonus, const char* tooltip) { + int32_t total = baseStat + equipBonus; + ImGui::BeginGroup(); + if (equipBonus > 0) { + ImGui::TextColored(white, "%s: %d", name, total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", equipBonus); + } else { + ImGui::TextColored(gray, "%s: %d", name, total); + } + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", tooltip); + ImGui::EndTooltip(); + } + }; + renderStat("Strength", itemStr, kStatTooltips[0]); + renderStat("Agility", itemAgi, kStatTooltips[1]); + renderStat("Stamina", itemSta, kStatTooltips[2]); + renderStat("Intellect", itemInt, kStatTooltips[3]); + renderStat("Spirit", itemSpi, kStatTooltips[4]); + } - renderStat("Strength", totalStr); - renderStat("Agility", totalAgi); - renderStat("Stamina", totalSta); - renderStat("Intellect", totalInt); - renderStat("Spirit", totalSpi); + // Secondary stats from equipped items + bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste || + itemResil || itemExpertise || itemMp5 || itemHp5 || + itemDefense || itemDodge || itemParry || itemBlock || itemBlockVal || + itemArmorPen || itemSpellPen; + if (hasSecondary) { + ImGui::Spacing(); + ImGui::Separator(); + auto renderSecondary = [&](const char* name, int32_t val, const char* tooltip) { + if (val > 0) { + ImGui::BeginGroup(); + ImGui::TextColored(green, "+%d %s", val, name); + ImGui::EndGroup(); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::TextWrapped("%s", tooltip); + ImGui::EndTooltip(); + } + } + }; + renderSecondary("Attack Power", itemAP, "Increases the damage of your melee and ranged attacks."); + renderSecondary("Spell Power", itemSP, "Increases the damage and healing of your spells."); + renderSecondary("Hit Rating", itemHit, "Reduces the chance your attacks will miss."); + renderSecondary("Crit Rating", itemCrit, "Increases your critical strike chance."); + renderSecondary("Haste Rating", itemHaste, "Increases attack speed and spell casting speed."); + renderSecondary("Resilience", itemResil, "Reduces the chance you will be critically hit.\nReduces damage taken from critical hits."); + renderSecondary("Expertise", itemExpertise,"Reduces the chance your attacks will be dodged or parried."); + renderSecondary("Defense Rating", itemDefense, "Reduces the chance enemies will critically hit you."); + renderSecondary("Dodge Rating", itemDodge, "Increases your chance to dodge attacks."); + renderSecondary("Parry Rating", itemParry, "Increases your chance to parry attacks."); + renderSecondary("Block Rating", itemBlock, "Increases your chance to block attacks with your shield."); + renderSecondary("Block Value", itemBlockVal, "Increases the amount of damage your shield blocks."); + renderSecondary("Armor Penetration",itemArmorPen, "Reduces the armor of your target."); + renderSecondary("Spell Penetration",itemSpellPen, "Reduces your target's resistance to your spells."); + renderSecondary("Mana per 5 sec", itemMp5, "Restores mana every 5 seconds, even while casting."); + renderSecondary("Health per 5 sec", itemHp5, "Restores health every 5 seconds."); + } + + // Elemental resistances from server update fields + if (serverResists) { + static const char* kResistNames[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + bool hasResist = false; + for (int i = 0; i < 6; ++i) { + if (serverResists[i] > 0) { hasResist = true; break; } + } + if (hasResist) { + ImGui::Spacing(); + ImGui::Separator(); + for (int i = 0; i < 6; ++i) { + if (serverResists[i] > 0) { + ImGui::TextColored(ImVec4(0.7f, 0.85f, 1.0f, 1.0f), + "%s: %d", kResistNames[i], serverResists[i]); + } + } + } + } + + // Server-authoritative combat stats (WotLK update fields — only shown when received) + if (gh) { + int32_t meleeAP = gh->getMeleeAttackPower(); + int32_t rangedAP = gh->getRangedAttackPower(); + int32_t spellPow = gh->getSpellPower(); + int32_t healPow = gh->getHealingPower(); + float dodgePct = gh->getDodgePct(); + float parryPct = gh->getParryPct(); + float blockPct = gh->getBlockPct(); + float critPct = gh->getCritPct(); + float rCritPct = gh->getRangedCritPct(); + float sCritPct = gh->getSpellCritPct(1); // Holy school (avg proxy for spell crit) + // Hit rating: CR_HIT_MELEE=5, CR_HIT_RANGED=6, CR_HIT_SPELL=7 + // Haste rating: CR_HASTE_MELEE=17, CR_HASTE_RANGED=18, CR_HASTE_SPELL=19 + // Other: CR_EXPERTISE=23, CR_ARMOR_PENETRATION=24, CR_CRIT_TAKEN_MELEE=14 + int32_t hitRating = gh->getCombatRating(5); + int32_t hitRangedR = gh->getCombatRating(6); + int32_t hitSpellR = gh->getCombatRating(7); + int32_t expertiseR = gh->getCombatRating(23); + int32_t hasteR = gh->getCombatRating(17); + int32_t hasteRangedR = gh->getCombatRating(18); + int32_t hasteSpellR = gh->getCombatRating(19); + int32_t armorPenR = gh->getCombatRating(24); + int32_t resilR = gh->getCombatRating(14); // CR_CRIT_TAKEN_MELEE = Resilience + + bool hasAny = (meleeAP >= 0 || spellPow >= 0 || dodgePct >= 0.0f || parryPct >= 0.0f || + blockPct >= 0.0f || critPct >= 0.0f || hitRating >= 0); + if (hasAny) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Combat"); + ImVec4 cyan(0.5f, 0.9f, 1.0f, 1.0f); + if (meleeAP >= 0) ImGui::TextColored(cyan, "Attack Power: %d", meleeAP); + if (rangedAP >= 0 && rangedAP != meleeAP) + ImGui::TextColored(cyan, "Ranged Attack Power: %d", rangedAP); + if (spellPow >= 0) ImGui::TextColored(cyan, "Spell Power: %d", spellPow); + if (healPow >= 0 && healPow != spellPow) + ImGui::TextColored(cyan, "Healing Power: %d", healPow); + if (dodgePct >= 0.0f) ImGui::TextColored(cyan, "Dodge: %.2f%%", dodgePct); + if (parryPct >= 0.0f) ImGui::TextColored(cyan, "Parry: %.2f%%", parryPct); + if (blockPct >= 0.0f) ImGui::TextColored(cyan, "Block: %.2f%%", blockPct); + if (critPct >= 0.0f) ImGui::TextColored(cyan, "Melee Crit: %.2f%%", critPct); + if (rCritPct >= 0.0f) ImGui::TextColored(cyan, "Ranged Crit: %.2f%%", rCritPct); + if (sCritPct >= 0.0f) ImGui::TextColored(cyan, "Spell Crit: %.2f%%", sCritPct); + + // Combat ratings with percentage conversion (WotLK level-80 divisors scaled by level). + // Formula: pct = rating / (divisorAt80 * pow(level/80.0, 0.93)) + // Level-80 divisors derived from gtCombatRatings.dbc (well-known WotLK constants): + // Hit: 26.23, Expertise: 8.19/expertise (0.25% each), + // Haste: 32.79, ArmorPen: 13.99, Resilience: 94.27 + uint32_t level = playerLevel > 0 ? playerLevel : gh->getPlayerLevel(); + if (level == 0) level = 80; + double lvlScale = level <= 80 + ? std::pow(static_cast(level) / 80.0, 0.93) + : 1.0; + + auto ratingPct = [&](int32_t rating, double divisorAt80) -> float { + if (rating < 0 || divisorAt80 <= 0.0) return -1.0f; + double d = divisorAt80 * lvlScale; + return static_cast(rating / d); + }; + + if (hitRating >= 0) { + float pct = ratingPct(hitRating, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Hit Rating: %d (%.2f%%)", hitRating, pct); + else + ImGui::TextColored(cyan, "Hit Rating: %d", hitRating); + } + // Show ranged/spell hit only when they differ from melee hit + if (hitRangedR >= 0 && hitRangedR != hitRating) { + float pct = ratingPct(hitRangedR, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Ranged Hit Rating: %d (%.2f%%)", hitRangedR, pct); + else + ImGui::TextColored(cyan, "Ranged Hit Rating: %d", hitRangedR); + } + if (hitSpellR >= 0 && hitSpellR != hitRating) { + // Spell hit cap at 17% (446 rating at 80); divisor same as melee hit + float pct = ratingPct(hitSpellR, 26.23); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Spell Hit Rating: %d (%.2f%%)", hitSpellR, pct); + else + ImGui::TextColored(cyan, "Spell Hit Rating: %d", hitSpellR); + } + if (expertiseR >= 0) { + // Each expertise point reduces dodge and parry chance by 0.25% + // expertise_points = rating / 8.19 + float exp_pts = ratingPct(expertiseR, 8.19); + if (exp_pts >= 0.0f) { + float exp_pct = exp_pts * 0.25f; // % dodge/parry reduction + ImGui::TextColored(cyan, "Expertise: %d (%.1f / %.2f%%)", + expertiseR, exp_pts, exp_pct); + } else { + ImGui::TextColored(cyan, "Expertise Rating: %d", expertiseR); + } + } + if (hasteR >= 0) { + float pct = ratingPct(hasteR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Haste Rating: %d (%.2f%%)", hasteR, pct); + else + ImGui::TextColored(cyan, "Haste Rating: %d", hasteR); + } + if (hasteRangedR >= 0 && hasteRangedR != hasteR) { + float pct = ratingPct(hasteRangedR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Ranged Haste Rating: %d (%.2f%%)", hasteRangedR, pct); + else + ImGui::TextColored(cyan, "Ranged Haste Rating: %d", hasteRangedR); + } + if (hasteSpellR >= 0 && hasteSpellR != hasteR) { + float pct = ratingPct(hasteSpellR, 32.79); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Spell Haste Rating: %d (%.2f%%)", hasteSpellR, pct); + else + ImGui::TextColored(cyan, "Spell Haste Rating: %d", hasteSpellR); + } + if (armorPenR >= 0) { + float pct = ratingPct(armorPenR, 13.99); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Armor Pen: %d (%.2f%%)", armorPenR, pct); + else + ImGui::TextColored(cyan, "Armor Penetration: %d", armorPenR); + } + if (resilR >= 0) { + // Resilience: reduces crit chance against you by pct%, and crit damage by 2*pct% + float pct = ratingPct(resilR, 94.27); + if (pct >= 0.0f) + ImGui::TextColored(cyan, "Resilience: %d (%.2f%%)", resilR, pct); + else + ImGui::TextColored(cyan, "Resilience: %d", resilR); + } + } + + // Movement speeds (always show when non-default) + { + constexpr float kBaseRun = 7.0f; + constexpr float kBaseFlight = 7.0f; + float runSpeed = gh->getServerRunSpeed(); + float flightSpeed = gh->getServerFlightSpeed(); + float swimSpeed = gh->getServerSwimSpeed(); + + bool showRun = runSpeed > 0.0f && std::fabs(runSpeed - kBaseRun) > 0.05f; + bool showFlight = flightSpeed > 0.0f && std::fabs(flightSpeed - kBaseFlight) > 0.05f; + bool showSwim = swimSpeed > 0.0f && std::fabs(swimSpeed - 4.722f) > 0.05f; + + if (showRun || showFlight || showSwim) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Movement"); + ImVec4 speedColor(0.6f, 1.0f, 0.8f, 1.0f); + if (showRun) { + float pct = (runSpeed / kBaseRun) * 100.0f; + ImGui::TextColored(speedColor, "Run Speed: %.1f%%", pct); + } + if (showFlight) { + float pct = (flightSpeed / kBaseFlight) * 100.0f; + ImGui::TextColored(speedColor, "Flight Speed: %.1f%%", pct); + } + if (showSwim) { + float pct = (swimSpeed / 4.722f) * 100.0f; + ImGui::TextColored(speedColor, "Swim Speed: %.1f%%", pct); + } + } + } + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { @@ -1479,6 +2165,31 @@ void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool colla ImGui::PopID(); } } + + if (showKeyring_) { + constexpr float keySlotSize = 24.0f; + constexpr int keyCols = 8; + int lastOccupied = -1; + for (int i = inventory.getKeyringSize() - 1; i >= 0; --i) { + if (!inventory.getKeyringSlot(i).empty()) { lastOccupied = i; break; } + } + int visibleSlots = (lastOccupied < 0) ? 0 : ((lastOccupied / keyCols) + 1) * keyCols; + if (visibleSlots > 0) { + ImGui::Spacing(); + ImGui::Separator(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.0f, 1.0f), "Keyring"); + for (int i = 0; i < visibleSlots; ++i) { + if (i % keyCols != 0) ImGui::SameLine(); + const auto& slot = inventory.getKeyringSlot(i); + char sid[32]; + snprintf(sid, sizeof(sid), "##keyring_%d", i); + ImGui::PushID(sid); + renderItemSlot(inventory, slot, keySlotSize, nullptr, + SlotKind::BACKPACK, -1, game::EquipSlot::NUM_SLOTS); + ImGui::PopID(); + } + } + } } void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, @@ -1594,6 +2305,20 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite IM_COL32(255, 255, 255, 220), countStr); } + // Durability bar on equipment slots (3px strip at bottom of slot icon) + if (kind == SlotKind::EQUIPMENT && item.maxDurability > 0) { + float durPct = static_cast(item.curDurability) / + static_cast(item.maxDurability); + ImU32 durCol; + if (durPct > 0.5f) durCol = IM_COL32(0, 200, 0, 220); + else if (durPct > 0.25f) durCol = IM_COL32(220, 220, 0, 220); + else durCol = IM_COL32(220, 40, 40, 220); + float barW = size * durPct; + drawList->AddRectFilled(ImVec2(pos.x, pos.y + size - 3.0f), + ImVec2(pos.x + barW, pos.y + size), + durCol); + } + ImGui::InvisibleButton("slot", ImVec2(size, size)); // Left mouse: hold to pick up, release to drop/swap @@ -1644,9 +2369,45 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } } + // Shift+right-click: split stack (if stackable >1) or destroy confirmation + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && + !holdingItem && ImGui::GetIO().KeyShift && item.itemId != 0) { + if (item.stackCount > 1 && item.maxStack > 1) { + // Open split popup for stackable items + splitConfirmOpen_ = true; + splitItemName_ = item.name; + splitMax_ = static_cast(item.stackCount); + splitCount_ = splitMax_ / 2; + if (splitCount_ < 1) splitCount_ = 1; + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + splitBag_ = 0xFF; + splitSlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + splitBag_ = static_cast(19 + bagIndex); + splitSlot_ = static_cast(bagSlotIndex); + } + } else if (item.bindType != 4) { + // Destroy confirmation for non-quest, non-stackable items + destroyConfirmOpen_ = true; + destroyItemName_ = item.name; + destroyCount_ = static_cast(std::clamp( + std::max(1u, item.stackCount), 1u, 255u)); + if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(23 + backpackIndex); + } else if (kind == SlotKind::BACKPACK && isBagSlot) { + destroyBag_ = static_cast(19 + bagIndex); + destroySlot_ = static_cast(bagSlotIndex); + } else if (kind == SlotKind::EQUIPMENT) { + destroyBag_ = 0xFF; + destroySlot_ = static_cast(equipSlot); + } + } + } + // Right-click: bank deposit (if bank open), vendor sell (if vendor mode), or auto-equip/use // Note: InvisibleButton only tracks left-click by default, so use IsItemHovered+IsMouseClicked - if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && gameHandler_) { + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right) && !holdingItem && !ImGui::GetIO().KeyShift && gameHandler_) { LOG_WARNING("Right-click slot: kind=", (int)kind, " backpackIndex=", backpackIndex, " bagIndex=", bagIndex, " bagSlotIndex=", bagSlotIndex, @@ -1671,50 +2432,157 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } else if (kind == SlotKind::BACKPACK && backpackIndex >= 0) { LOG_INFO("Right-click backpack item: name='", item.name, "' inventoryType=", (int)item.inventoryType, - " itemId=", item.itemId); - if (item.inventoryType > 0) { + " itemId=", item.itemId, + " startQuestId=", item.startQuestId); + if (item.startQuestId != 0) { + uint64_t iGuid = gameHandler_->getBackpackItemGuid(backpackIndex); + gameHandler_->offerQuestFromItem(iGuid, item.startQuestId); + } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemBySlot(backpackIndex); } else { - gameHandler_->useItemBySlot(backpackIndex); + // itemClass==1 (Container) with inventoryType==0 means a lockbox; + // use CMSG_OPEN_ITEM so the server checks keyring automatically. + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info && info->valid && info->itemClass == 1) { + gameHandler_->openItemBySlot(backpackIndex); + } else { + gameHandler_->useItemBySlot(backpackIndex); + } } } else if (kind == SlotKind::BACKPACK && isBagSlot) { LOG_INFO("Right-click bag item: name='", item.name, "' inventoryType=", (int)item.inventoryType, - " bagIndex=", bagIndex, " slotIndex=", bagSlotIndex); - if (item.inventoryType > 0) { + " bagIndex=", bagIndex, " slotIndex=", bagSlotIndex, + " startQuestId=", item.startQuestId); + if (item.startQuestId != 0) { + uint64_t iGuid = gameHandler_->getBagItemGuid(bagIndex, bagSlotIndex); + gameHandler_->offerQuestFromItem(iGuid, item.startQuestId); + } else if (item.inventoryType > 0) { gameHandler_->autoEquipItemInBag(bagIndex, bagSlotIndex); } else { - gameHandler_->useItemInBag(bagIndex, bagSlotIndex); + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info && info->valid && info->itemClass == 1) { + gameHandler_->openItemInBag(bagIndex, bagSlotIndex); + } else { + gameHandler_->useItemInBag(bagIndex, bagSlotIndex); + } } } } + // Shift+left-click: insert item link into chat input + if (ImGui::IsItemHovered() && !holdingItem && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + ImGui::GetIO().KeyShift && + item.itemId != 0 && !item.name.empty()) { + // Build WoW item link: |cff|Hitem::0:0:0:0:0:0:0:0|h[]|h|r + const char* qualHex = "9d9d9d"; + switch (item.quality) { + case game::ItemQuality::COMMON: qualHex = "ffffff"; break; + case game::ItemQuality::UNCOMMON: qualHex = "1eff00"; break; + case game::ItemQuality::RARE: qualHex = "0070dd"; break; + case game::ItemQuality::EPIC: qualHex = "a335ee"; break; + case game::ItemQuality::LEGENDARY: qualHex = "ff8000"; break; + case game::ItemQuality::ARTIFACT: qualHex = "e6cc80"; break; + case game::ItemQuality::HEIRLOOM: qualHex = "e6cc80"; break; + default: break; + } + char linkBuf[512]; + snprintf(linkBuf, sizeof(linkBuf), + "|cff%s|Hitem:%u:0:0:0:0:0:0:0:0|h[%s]|h|r", + qualHex, item.itemId, item.name.c_str()); + pendingChatItemLink_ = linkBuf; + } + if (ImGui::IsItemHovered() && !holdingItem) { - renderItemTooltip(item, &inventory); + // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise + const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; + uint64_t slotGuid = 0; + if (kind == SlotKind::EQUIPMENT && gameHandler_) + slotGuid = gameHandler_->getEquipSlotGuid(static_cast(equipSlot)); + renderItemTooltip(item, tooltipInv, slotGuid); } } } -void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory) { +void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::Inventory* inventory, uint64_t itemGuid) { + // Shared SpellItemEnchantment name lookup — used for socket gems, permanent and temp enchants. + static std::unordered_map s_enchLookupB; + static bool s_enchLookupLoadedB = false; + if (!s_enchLookupLoadedB && assetManager_) { + s_enchLookupLoadedB = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchLookupB[eid] = std::move(en); + } + } + } + ImGui::BeginTooltip(); ImVec4 qColor = getQualityColor(item.quality); ImGui::TextColored(qColor, "%s", item.name.c_str()); + if (item.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", item.itemLevel); + } + + // Heroic / Unique / Unique-Equipped indicators + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + constexpr uint32_t kFlagHeroic = 0x8; + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; + if (qi->itemFlags & kFlagHeroic) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + } + if (qi->maxCount == 1) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + } else if (qi->itemFlags & kFlagUniqueEquipped) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + } + } + } + + // Binding type + switch (item.bindType) { + case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; + case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break; + case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break; + case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break; + default: break; + } if (item.itemId == 6948 && gameHandler_) { uint32_t mapId = 0; glm::vec3 pos; if (gameHandler_->getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; - case 13: mapName = "Test"; break; - case 169: mapName = "Emerald Dream"; break; + std::string homeLocation; + // Prefer the specific zone name from the bind-point zone ID + uint32_t zoneId = gameHandler_->getHomeBindZoneId(); + if (zoneId != 0) + homeLocation = gameHandler_->getWhoAreaName(zoneId); + // Fall back to continent name if zone unavailable + if (homeLocation.empty()) { + switch (mapId) { + case 0: homeLocation = "Eastern Kingdoms"; break; + case 1: homeLocation = "Kalimdor"; break; + case 530: homeLocation = "Outland"; break; + case 571: homeLocation = "Northrend"; break; + case 13: homeLocation = "Test"; break; + case 169: homeLocation = "Emerald Dream"; break; + default: homeLocation = "Unknown"; break; + } } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", homeLocation.c_str()); } else { ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Home: not set"); } @@ -1759,6 +2627,20 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); } } + + // Show red warning if player lacks proficiency for this weapon/armor type + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + bool canUse = true; + if (qi->itemClass == 2) // Weapon + canUse = gameHandler_->canUseWeaponSubclass(qi->subClass); + else if (qi->itemClass == 4 && qi->subClass > 0) // Armor (skip subclass 0 = misc) + canUse = gameHandler_->canUseArmorSubclass(qi->subClass); + if (!canUse) + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "You can't use this type of item."); + } + } } auto isWeaponInventoryType = [](uint32_t invType) { @@ -1776,13 +2658,15 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I }; const bool isWeapon = isWeaponInventoryType(item.inventoryType); - // Compact stats view for weapons: DPS + condensed stat bonuses. - // Non-weapons keep armor/sell info visible. + // Compact stats view for weapons: damage range + speed + DPS ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); if (isWeapon && item.damageMax > 0.0f && item.delayMs > 0) { float speed = static_cast(item.delayMs) / 1000.0f; float dps = ((item.damageMin + item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + ImGui::Text("%.0f - %.0f Damage", item.damageMin, item.damageMax); + ImGui::SameLine(160.0f); + ImGui::TextDisabled("Speed %.2f", speed); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps); } // Armor appears before stat bonuses — matches WoW tooltip order @@ -1790,6 +2674,21 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::Text("%d Armor", item.armor); } + // Elemental resistances from item query cache (fire resist gear, nature resist gear, etc.) + if (gameHandler_) { + const auto* qi = gameHandler_->getItemInfo(item.itemId); + if (qi && qi->valid) { + const int32_t resValsI[6] = { qi->holyRes, qi->fireRes, qi->natureRes, + qi->frostRes, qi->shadowRes, qi->arcaneRes }; + static const char* resLabelsI[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int i = 0; i < 6; ++i) + if (resValsI[i] > 0) ImGui::Text("+%d %s", resValsI[i], resLabelsI[i]); + } + } + auto appendBonus = [](std::string& out, int32_t val, const char* shortName) { if (val <= 0) return; if (!out.empty()) out += " "; @@ -1805,11 +2704,376 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I if (!bonusLine.empty()) { ImGui::TextColored(green, "%s", bonusLine.c_str()); } + + // Extra stats (hit, crit, haste, AP, SP, etc.) — one line each + for (const auto& es : item.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 0: statName = "Mana"; break; + case 1: statName = "Health"; break; + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 15: statName = "Block Rating"; break; + case 16: statName = "Hit Rating"; break; + case 17: statName = "Hit Rating"; break; + case 18: statName = "Hit Rating"; break; + case 19: statName = "Crit Rating"; break; + case 20: statName = "Crit Rating"; break; + case 21: statName = "Crit Rating"; break; + case 28: statName = "Haste Rating"; break; + case 29: statName = "Haste Rating"; break; + case 30: statName = "Haste Rating"; break; + case 31: statName = "Hit Rating"; break; + case 32: statName = "Crit Rating"; break; + case 35: statName = "Resilience"; break; + case 36: statName = "Haste Rating"; break; + case 37: statName = "Expertise Rating"; break; + case 38: statName = "Attack Power"; break; + case 39: statName = "Ranged Attack Power"; break; + case 41: statName = "Healing Power"; break; + case 42: statName = "Spell Damage"; break; + case 43: statName = "Mana per 5 sec"; break; + case 44: statName = "Armor Penetration"; break; + case 45: statName = "Spell Power"; break; + case 46: statName = "Health per 5 sec"; break; + case 47: statName = "Spell Penetration"; break; + case 48: statName = "Block Value"; break; + default: statName = nullptr; break; + } + char buf[64]; + if (statName) { + std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName); + } else { + std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType); + } + ImGui::TextColored(green, "%s", buf); + } + + if (item.requiredLevel > 1) { + uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0; + bool meetsReq = (playerLvl >= item.requiredLevel); + ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(reqColor, "Requires Level %u", item.requiredLevel); + } + if (item.maxDurability > 0) { + float durPct = static_cast(item.curDurability) / static_cast(item.maxDurability); + ImVec4 durColor; + if (durPct > 0.5f) durColor = ImVec4(0.1f, 1.0f, 0.1f, 1.0f); // green + else if (durPct > 0.25f) durColor = ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // yellow + else durColor = ImVec4(1.0f, 0.2f, 0.2f, 1.0f); // red + ImGui::TextColored(durColor, "Durability %u / %u", + item.curDurability, item.maxDurability); + } + // Item spell effects (Use/Equip/Chance on Hit) + if (gameHandler_) { + auto* info = gameHandler_->getItemInfo(item.itemId); + if (info) { + for (const auto& sp : info->spells) { + if (sp.spellId == 0) continue; + const char* trigger = nullptr; + switch (sp.spellTrigger) { + case 0: trigger = "Use"; break; // on use + case 1: trigger = "Equip"; break; // on equip + case 2: trigger = "Chance on Hit"; break; // proc on melee hit + case 4: trigger = "Use"; break; // soulstone (still shows as Use) + case 5: trigger = "Use"; break; // on use, no delay + case 6: trigger = "Use"; break; // learn spell (recipe/pattern) + default: break; + } + if (!trigger) continue; + const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId); + const std::string& spText = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc; + if (!spText.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: %s", trigger, spText.c_str()); + ImGui::PopTextWrapPos(); + } else { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), + "%s: Spell #%u", trigger, sp.spellId); + } + } + } + } + + // Skill / reputation requirements from item query cache + if (gameHandler_) { + const auto* qInfo = gameHandler_->getItemInfo(item.itemId); + if (qInfo && qInfo->valid) { + if (qInfo->requiredSkill != 0 && qInfo->requiredSkillRank > 0) { + static std::unordered_map s_skillNamesB; + static bool s_skillNamesLoadedB = false; + if (!s_skillNamesLoadedB && assetManager_) { + s_skillNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 2; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNamesB[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + const auto& skills = gameHandler_->getPlayerSkills(); + auto skPit = skills.find(qInfo->requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= qInfo->requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNamesB.find(qInfo->requiredSkill); + if (skIt != s_skillNamesB.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), qInfo->requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", qInfo->requiredSkill, qInfo->requiredSkillRank); + } + if (qInfo->requiredReputationFaction != 0 && qInfo->requiredReputationRank > 0) { + static std::unordered_map s_factionNamesB; + static bool s_factionNamesLoadedB = false; + if (!s_factionNamesLoadedB && assetManager_) { + s_factionNamesLoadedB = true; + auto dbc = assetManager_->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 20; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNamesB[fid] = std::move(fname); + } + } + } + static const char* kRepRankNamesB[] = { + "Hated","Hostile","Unfriendly","Neutral","Friendly","Honored","Revered","Exalted" + }; + const char* rankName = (qInfo->requiredReputationRank < 8) + ? kRepRankNamesB[qInfo->requiredReputationRank] : "Unknown"; + auto fIt = s_factionNamesB.find(qInfo->requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNamesB.end() ? fIt->second.c_str() : "Unknown Faction"); + } + // Class restriction + if (qInfo->allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClassesB[] = { + { 1,"Warrior" },{ 2,"Paladin" },{ 4,"Hunter" },{ 8,"Rogue" }, + { 16,"Priest" },{ 32,"Death Knight" },{ 64,"Shaman" }, + { 128,"Mage" },{ 256,"Warlock" },{ 1024,"Druid" }, + }; + int mc = 0; + for (const auto& kc : kClassesB) if (qInfo->allowableClass & kc.mask) ++mc; + if (mc > 0 && mc < 10) { + char buf[128] = "Classes: "; bool first = true; + for (const auto& kc : kClassesB) { + if (!(qInfo->allowableClass & kc.mask)) continue; + if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1); + strncat(buf, kc.name, sizeof(buf)-strlen(buf)-1); + first = false; + } + uint8_t pc = gameHandler_->getPlayerClass(); + uint32_t pm = (pc > 0 && pc <= 10) ? (1u << (pc-1)) : 0; + bool ok = (pm == 0 || (qInfo->allowableClass & pm)); + ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf); + } + } + // Race restriction + if (qInfo->allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRacesB[] = { + { 1,"Human" },{ 2,"Orc" },{ 4,"Dwarf" },{ 8,"Night Elf" }, + { 16,"Undead" },{ 32,"Tauren" },{ 64,"Gnome" },{ 128,"Troll" }, + { 512,"Blood Elf" },{ 1024,"Draenei" }, + }; + constexpr uint32_t kAll = 1|2|4|8|16|32|64|128|512|1024; + if ((qInfo->allowableRace & kAll) != kAll) { + int mc = 0; + for (const auto& kr : kRacesB) if (qInfo->allowableRace & kr.mask) ++mc; + if (mc > 0) { + char buf[160] = "Races: "; bool first = true; + for (const auto& kr : kRacesB) { + if (!(qInfo->allowableRace & kr.mask)) continue; + if (!first) strncat(buf, ", ", sizeof(buf)-strlen(buf)-1); + strncat(buf, kr.name, sizeof(buf)-strlen(buf)-1); + first = false; + } + uint8_t pr = gameHandler_->getPlayerRace(); + uint32_t pm = (pr > 0 && pr <= 11) ? (1u << (pr-1)) : 0; + bool ok = (pm == 0 || (qInfo->allowableRace & pm)); + ImGui::TextColored(ok ? ImVec4(1,1,1,0.75f) : ImVec4(1,0.5f,0.5f,1), "%s", buf); + } + } + } + } + } + + // Gem socket slots and item set — look up from query cache + if (gameHandler_) { + const auto* qi2 = gameHandler_->getItemInfo(item.itemId); + if (qi2 && qi2->valid) { + // Gem sockets + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + // Get socket gem enchant IDs for this item (filled from item update fields) + std::array sockGems{}; + if (itemGuid != 0 && gameHandler_) + sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid); + + bool hasSocket = false; + for (int i = 0; i < 3; ++i) { + if (qi2->socketColor[i] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (qi2->socketColor[i] & st.mask) { + if (sockGems[i] != 0) { + auto git = s_enchLookupB.find(sockGems[i]); + if (git != s_enchLookupB.end()) + ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str()); + else + ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]); + } else { + ImGui::TextColored(st.col, "%s", st.label); + } + break; + } + } + } + if (hasSocket && qi2->socketBonus != 0) { + auto enchIt = s_enchLookupB.find(qi2->socketBonus); + if (enchIt != s_enchLookupB.end()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", qi2->socketBonus); + } + } + // Item set membership + if (qi2->itemSetId != 0) { + struct SetEntryD { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setDataD; + static bool s_setDataLoadedD = false; + if (!s_setDataLoadedD && assetManager_) { + s_setDataLoadedD = true; + auto dbc = assetManager_->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = { + "Item0","Item1","Item2","Item3","Item4", + "Item5","Item6","Item7","Item8","Item9" }; + static const char* spellKeys[10] = { + "Spell0","Spell1","Spell2","Spell3","Spell4", + "Spell5","Spell6","Spell7","Spell8","Spell9" }; + static const char* thrKeys[10] = { + "Threshold0","Threshold1","Threshold2","Threshold3","Threshold4", + "Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" }; + uint32_t itemFB[10], spellFB[10], thrFB[10]; + for (int i = 0; i < 10; ++i) { + itemFB[i] = 18+i; spellFB[i] = 28+i; thrFB[i] = 38+i; + } + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntryD e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFB[i]); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFB[i]); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFB[i]); + } + s_setDataD[id] = std::move(e); + } + } + } + auto setIt = s_setDataD.find(qi2->itemSetId); + ImGui::Spacing(); + if (setIt != s_setDataD.end()) { + const SetEntryD& se = setIt->second; + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + if (inventory) { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& eSlot = inventory->getEquipSlot(static_cast(s)); + if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + } + if (total > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + } else if (!se.name.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + } + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler_->getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", qi2->itemSetId); + } + } + } + } + + // Weapon/armor enchant display for equipped items (reads from item update fields) + if (itemGuid != 0 && gameHandler_) { + auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); + if (permId != 0) { + auto it2 = s_enchLookupB.find(permId); + const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchLookupB.find(tempId); + const char* ename = (it2 != s_enchLookupB.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); + } + } + + // "Begins a Quest" line (shown in yellow-green like the game) + if (item.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + + // Flavor / lore text (italic yellow in WoW, just yellow here) + if (!item.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", item.description.c_str()); + } + if (item.sellPrice > 0) { uint32_t g = item.sellPrice / 10000; uint32_t s = (item.sellPrice / 100) % 100; uint32_t c = item.sellPrice % 100; - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); } // Shift-hover comparison with currently equipped equivalent. @@ -1824,25 +3088,746 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); - if (isWeaponInventoryType(eq->item.inventoryType) && - eq->item.damageMax > 0.0f && eq->item.delayMs > 0) { - float speed = static_cast(eq->item.delayMs) / 1000.0f; - float dps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / speed; - ImGui::Text("%.1f DPS", dps); + // Item level comparison (always shown when different) + if (eq->item.itemLevel > 0 || item.itemLevel > 0) { + char ilvlBuf[64]; + float diff = static_cast(item.itemLevel) - static_cast(eq->item.itemLevel); + if (diff > 0.0f) + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", item.itemLevel, diff); + else if (diff < 0.0f) + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", item.itemLevel, -diff); + else + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", item.itemLevel); + ImVec4 ilvlColor = (diff > 0.0f) ? ImVec4(0.0f, 1.0f, 0.0f, 1.0f) + : (diff < 0.0f) ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) + : ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + ImGui::TextColored(ilvlColor, "%s", ilvlBuf); } - if (eq->item.armor > 0) { - ImGui::Text("%d Armor", eq->item.armor); + + // Helper: render a numeric stat diff line + auto showDiff = [](const char* label, float newVal, float eqVal) { + if (newVal == 0.0f && eqVal == 0.0f) return; + float diff = newVal - eqVal; + char buf[128]; + if (diff > 0.0f) { + std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, newVal, diff); + ImGui::TextColored(ImVec4(0.0f, 1.0f, 0.0f, 1.0f), "%s", buf); + } else if (diff < 0.0f) { + std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, newVal, -diff); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", buf); + } else { + std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, newVal); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf); + } + }; + + // DPS comparison for weapons + if (isWeaponInventoryType(item.inventoryType) && isWeaponInventoryType(eq->item.inventoryType)) { + float newDps = 0.0f, eqDps = 0.0f; + if (item.damageMax > 0.0f && item.delayMs > 0) + newDps = ((item.damageMin + item.damageMax) * 0.5f) / (item.delayMs / 1000.0f); + if (eq->item.damageMax > 0.0f && eq->item.delayMs > 0) + eqDps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / (eq->item.delayMs / 1000.0f); + showDiff("DPS", newDps, eqDps); } - std::string eqBonusLine; - appendBonus(eqBonusLine, eq->item.strength, "Str"); - appendBonus(eqBonusLine, eq->item.agility, "Agi"); - appendBonus(eqBonusLine, eq->item.stamina, "Sta"); - appendBonus(eqBonusLine, eq->item.intellect, "Int"); - appendBonus(eqBonusLine, eq->item.spirit, "Spi"); - if (!eqBonusLine.empty()) { - ImGui::TextColored(green, "%s", eqBonusLine.c_str()); + + // Armor + showDiff("Armor", static_cast(item.armor), static_cast(eq->item.armor)); + + // Primary stats + showDiff("Str", static_cast(item.strength), static_cast(eq->item.strength)); + showDiff("Agi", static_cast(item.agility), static_cast(eq->item.agility)); + showDiff("Sta", static_cast(item.stamina), static_cast(eq->item.stamina)); + showDiff("Int", static_cast(item.intellect), static_cast(eq->item.intellect)); + showDiff("Spi", static_cast(item.spirit), static_cast(eq->item.spirit)); + + // Extra stats diff — union of stat types from both items + auto findExtraStat = [](const game::ItemDef& it, uint32_t type) -> int32_t { + for (const auto& es : it.extraStats) + if (es.statType == type) return es.statValue; + return 0; + }; + // Collect all extra stat types + std::vector allTypes; + for (const auto& es : item.extraStats) allTypes.push_back(es.statType); + for (const auto& es : eq->item.extraStats) { + bool found = false; + for (uint32_t t : allTypes) if (t == es.statType) { found = true; break; } + if (!found) allTypes.push_back(es.statType); + } + for (uint32_t t : allTypes) { + int32_t nv = findExtraStat(item, t); + int32_t ev = findExtraStat(eq->item, t); + // Find a label for this stat type + const char* lbl = nullptr; + switch (t) { + case 0: lbl = "Mana"; break; + case 1: lbl = "Health"; break; + case 12: lbl = "Defense"; break; + case 13: lbl = "Dodge"; break; + case 14: lbl = "Parry"; break; + case 15: lbl = "Block Rating"; break; + case 16: case 17: case 18: case 31: lbl = "Hit"; break; + case 19: case 20: case 21: case 32: lbl = "Crit"; break; + case 28: case 29: case 30: case 36: lbl = "Haste"; break; + case 35: lbl = "Resilience"; break; + case 37: lbl = "Expertise"; break; + case 38: lbl = "Attack Power"; break; + case 39: lbl = "Ranged AP"; break; + case 41: lbl = "Healing"; break; + case 42: lbl = "Spell Damage"; break; + case 43: lbl = "MP5"; break; + case 44: lbl = "Armor Pen"; break; + case 45: lbl = "Spell Power"; break; + case 46: lbl = "HP5"; break; + case 47: lbl = "Spell Pen"; break; + case 48: lbl = "Block Value"; break; + default: lbl = nullptr; break; + } + if (!lbl) continue; + showDiff(lbl, static_cast(nv), static_cast(ev)); } } + } else if (inventory && !ImGui::GetIO().KeyShift && item.inventoryType > 0) { + if (findComparableEquipped(*inventory, item.inventoryType)) { + ImGui::TextDisabled("Hold Shift to compare"); + } + } + + // Destroy hint (not shown for quest items) + if (item.itemId != 0 && item.bindType != 4) { + ImGui::Spacing(); + if (ImGui::GetIO().KeyShift) { + ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.45f, 0.9f), "Shift+RClick to destroy"); + } else { + ImGui::TextDisabled("Shift+RClick to destroy"); + } + } + + ImGui::EndTooltip(); +} + +// --------------------------------------------------------------------------- +// Tooltip overload for ItemQueryResponseData (used by loot window, etc.) +// --------------------------------------------------------------------------- +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info, const game::Inventory* inventory, uint64_t itemGuid) { + // Shared SpellItemEnchantment name lookup — used for socket gems, socket bonus, and enchants. + static std::unordered_map s_enchLookup; + static bool s_enchLookupLoaded = false; + if (!s_enchLookupLoaded && assetManager_) { + s_enchLookupLoaded = true; + auto dbc = assetManager_->loadDBC("SpellItemEnchantment.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* lay = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellItemEnchantment") : nullptr; + uint32_t nf = lay ? lay->field("Name") : 8u; + if (nf == 0xFFFFFFFF) nf = 8; + uint32_t fc = dbc->getFieldCount(); + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t eid = dbc->getUInt32(r, 0); + if (eid == 0 || nf >= fc) continue; + std::string en = dbc->getString(r, nf); + if (!en.empty()) s_enchLookup[eid] = std::move(en); + } + } + } + + ImGui::BeginTooltip(); + + ImVec4 qColor = getQualityColor(static_cast(info.quality)); + ImGui::TextColored(qColor, "%s", info.name.c_str()); + if (info.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel); + } + + // Unique / Heroic indicators + constexpr uint32_t kFlagHeroic = 0x8; // ITEM_FLAG_HEROIC_TOOLTIP + constexpr uint32_t kFlagUniqueEquipped = 0x1000000; // ITEM_FLAG_UNIQUE_EQUIPPABLE + if (info.itemFlags & kFlagHeroic) { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 0.0f, 1.0f), "Heroic"); + } + if (info.maxCount == 1) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique"); + } else if (info.itemFlags & kFlagUniqueEquipped) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Unique-Equipped"); + } + + // Binding type + switch (info.bindType) { + case 1: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when picked up"); break; + case 2: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when equipped"); break; + case 3: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Binds when used"); break; + case 4: ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Quest Item"); break; + default: break; + } + + // Slot / subclass + if (info.inventoryType > 0) { + const char* slotName = ""; + switch (info.inventoryType) { + case 1: slotName = "Head"; break; + case 2: slotName = "Neck"; break; + case 3: slotName = "Shoulder"; break; + case 4: slotName = "Shirt"; break; + case 5: slotName = "Chest"; break; + case 6: slotName = "Waist"; break; + case 7: slotName = "Legs"; break; + case 8: slotName = "Feet"; break; + case 9: slotName = "Wrist"; break; + case 10: slotName = "Hands"; break; + case 11: slotName = "Finger"; break; + case 12: slotName = "Trinket"; break; + case 13: slotName = "One-Hand"; break; + case 14: slotName = "Shield"; break; + case 15: slotName = "Ranged"; break; + case 16: slotName = "Back"; break; + case 17: slotName = "Two-Hand"; break; + case 18: slotName = "Bag"; break; + case 19: slotName = "Tabard"; break; + case 20: slotName = "Robe"; break; + case 21: slotName = "Main Hand"; break; + case 22: slotName = "Off Hand"; break; + case 23: slotName = "Held In Off-hand"; break; + case 25: slotName = "Thrown"; break; + case 26: slotName = "Ranged"; break; + default: break; + } + if (slotName[0]) { + if (!info.subclassName.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info.subclassName.c_str()); + else + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); + } + + // Proficiency check for vendor/loot tooltips (ItemQueryResponseData has itemClass/subClass) + if (gameHandler_) { + bool canUse = true; + if (info.itemClass == 2) // Weapon + canUse = gameHandler_->canUseWeaponSubclass(info.subClass); + else if (info.itemClass == 4 && info.subClass > 0) // Armor (skip subclass 0 = misc) + canUse = gameHandler_->canUseArmorSubclass(info.subClass); + if (!canUse) + ImGui::TextColored(ImVec4(1.0f, 0.2f, 0.2f, 1.0f), "You can't use this type of item."); + } + } + + // Weapon stats + auto isWeaponInvType = [](uint32_t t) { + return t == 13 || t == 15 || t == 17 || t == 21 || t == 25 || t == 26; + }; + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + if (isWeaponInvType(info.inventoryType) && info.damageMax > 0.0f && info.delayMs > 0) { + float speed = static_cast(info.delayMs) / 1000.0f; + float dps = ((info.damageMin + info.damageMax) * 0.5f) / speed; + ImGui::Text("%.0f - %.0f Damage", info.damageMin, info.damageMax); + ImGui::SameLine(160.0f); + ImGui::TextDisabled("Speed %.2f", speed); + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "(%.1f damage per second)", dps); + } + + if (info.armor > 0) ImGui::Text("%d Armor", info.armor); + + // Elemental resistances (fire resist gear, nature resist gear, etc.) + { + const int32_t resVals[6] = { info.holyRes, info.fireRes, info.natureRes, + info.frostRes, info.shadowRes, info.arcaneRes }; + static const char* resLabels[6] = { + "Holy Resistance", "Fire Resistance", "Nature Resistance", + "Frost Resistance", "Shadow Resistance", "Arcane Resistance" + }; + for (int i = 0; i < 6; ++i) + if (resVals[i] > 0) ImGui::Text("+%d %s", resVals[i], resLabels[i]); + } + + auto appendBonus = [](std::string& out, int32_t val, const char* name) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " " + name; + }; + std::string bonusLine; + appendBonus(bonusLine, info.strength, "Str"); + appendBonus(bonusLine, info.agility, "Agi"); + appendBonus(bonusLine, info.stamina, "Sta"); + appendBonus(bonusLine, info.intellect, "Int"); + appendBonus(bonusLine, info.spirit, "Spi"); + if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); + + // Extra stats + for (const auto& es : info.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 16: case 17: case 18: case 31: statName = "Hit Rating"; break; + case 19: case 20: case 21: case 32: statName = "Crit Rating"; break; + case 28: case 29: case 30: case 36: statName = "Haste Rating"; break; + case 35: statName = "Resilience"; break; + case 37: statName = "Expertise Rating"; break; + case 38: statName = "Attack Power"; break; + case 39: statName = "Ranged Attack Power"; break; + case 41: statName = "Healing Power"; break; + case 42: statName = "Spell Damage"; break; + case 43: statName = "Mana per 5 sec"; break; + case 44: statName = "Armor Penetration"; break; + case 45: statName = "Spell Power"; break; + case 46: statName = "Health per 5 sec"; break; + case 47: statName = "Spell Penetration"; break; + case 48: statName = "Block Value"; break; + default: statName = nullptr; break; + } + char buf[64]; + if (statName) + std::snprintf(buf, sizeof(buf), "%+d %s", es.statValue, statName); + else + std::snprintf(buf, sizeof(buf), "%+d (stat %u)", es.statValue, es.statType); + ImGui::TextColored(green, "%s", buf); + } + + if (info.requiredLevel > 1) { + uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0; + bool meetsReq = (playerLvl >= info.requiredLevel); + ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel); + } + + // Required skill (e.g. "Requires Engineering (300)") + if (info.requiredSkill != 0 && info.requiredSkillRank > 0) { + // Lazy-load SkillLine.dbc names + static std::unordered_map s_skillNames; + static bool s_skillNamesLoaded = false; + if (!s_skillNamesLoaded && assetManager_) { + s_skillNamesLoaded = true; + auto dbc = assetManager_->loadDBC("SkillLine.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SkillLine") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 2; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t sid = dbc->getUInt32(r, idF); + if (!sid) continue; + std::string sname = dbc->getString(r, nameF); + if (!sname.empty()) s_skillNames[sid] = std::move(sname); + } + } + } + uint32_t playerSkillVal = 0; + if (gameHandler_) { + const auto& skills = gameHandler_->getPlayerSkills(); + auto skPit = skills.find(info.requiredSkill); + if (skPit != skills.end()) playerSkillVal = skPit->second.effectiveValue(); + } + bool meetsSkill = (playerSkillVal == 0 || playerSkillVal >= info.requiredSkillRank); + ImVec4 skColor = meetsSkill ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + auto skIt = s_skillNames.find(info.requiredSkill); + if (skIt != s_skillNames.end()) + ImGui::TextColored(skColor, "Requires %s (%u)", skIt->second.c_str(), info.requiredSkillRank); + else + ImGui::TextColored(skColor, "Requires Skill %u (%u)", info.requiredSkill, info.requiredSkillRank); + } + + // Required reputation (e.g. "Requires Exalted with Argent Dawn") + if (info.requiredReputationFaction != 0 && info.requiredReputationRank > 0) { + static std::unordered_map s_factionNames; + static bool s_factionNamesLoaded = false; + if (!s_factionNamesLoaded && assetManager_) { + s_factionNamesLoaded = true; + auto dbc = assetManager_->loadDBC("Faction.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Faction") : nullptr; + uint32_t idF = layout ? (*layout)["ID"] : 0; + uint32_t nameF = layout ? (*layout)["Name"] : 20; + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t fid = dbc->getUInt32(r, idF); + if (!fid) continue; + std::string fname = dbc->getString(r, nameF); + if (!fname.empty()) s_factionNames[fid] = std::move(fname); + } + } + } + static const char* kRepRankNames[] = { + "Hated", "Hostile", "Unfriendly", "Neutral", + "Friendly", "Honored", "Revered", "Exalted" + }; + const char* rankName = (info.requiredReputationRank < 8) + ? kRepRankNames[info.requiredReputationRank] : "Unknown"; + auto fIt = s_factionNames.find(info.requiredReputationFaction); + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.75f), "Requires %s with %s", + rankName, + fIt != s_factionNames.end() ? fIt->second.c_str() : "Unknown Faction"); + } + + // Class restriction (e.g. "Classes: Paladin, Warrior") + if (info.allowableClass != 0) { + static const struct { uint32_t mask; const char* name; } kClasses[] = { + { 1, "Warrior" }, + { 2, "Paladin" }, + { 4, "Hunter" }, + { 8, "Rogue" }, + { 16, "Priest" }, + { 32, "Death Knight" }, + { 64, "Shaman" }, + { 128, "Mage" }, + { 256, "Warlock" }, + { 1024, "Druid" }, + }; + // Count matching classes + int matchCount = 0; + for (const auto& kc : kClasses) + if (info.allowableClass & kc.mask) ++matchCount; + // Only show if restricted to a subset (not all classes) + if (matchCount > 0 && matchCount < 10) { + char classBuf[128] = "Classes: "; + bool first = true; + for (const auto& kc : kClasses) { + if (!(info.allowableClass & kc.mask)) continue; + if (!first) strncat(classBuf, ", ", sizeof(classBuf) - strlen(classBuf) - 1); + strncat(classBuf, kc.name, sizeof(classBuf) - strlen(classBuf) - 1); + first = false; + } + // Check if player's class is allowed + bool playerAllowed = true; + if (gameHandler_) { + uint8_t pc = gameHandler_->getPlayerClass(); + uint32_t pmask = (pc > 0 && pc <= 10) ? (1u << (pc - 1)) : 0; + playerAllowed = (pmask == 0 || (info.allowableClass & pmask)); + } + ImVec4 clColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(clColor, "%s", classBuf); + } + } + + // Race restriction (e.g. "Races: Night Elf, Human") + if (info.allowableRace != 0) { + static const struct { uint32_t mask; const char* name; } kRaces[] = { + { 1, "Human" }, + { 2, "Orc" }, + { 4, "Dwarf" }, + { 8, "Night Elf" }, + { 16, "Undead" }, + { 32, "Tauren" }, + { 64, "Gnome" }, + { 128, "Troll" }, + { 512, "Blood Elf" }, + { 1024, "Draenei" }, + }; + constexpr uint32_t kAllPlayable = 1|2|4|8|16|32|64|128|512|1024; + // Only show if not all playable races are allowed + if ((info.allowableRace & kAllPlayable) != kAllPlayable) { + int matchCount = 0; + for (const auto& kr : kRaces) + if (info.allowableRace & kr.mask) ++matchCount; + if (matchCount > 0) { + char raceBuf[160] = "Races: "; + bool first = true; + for (const auto& kr : kRaces) { + if (!(info.allowableRace & kr.mask)) continue; + if (!first) strncat(raceBuf, ", ", sizeof(raceBuf) - strlen(raceBuf) - 1); + strncat(raceBuf, kr.name, sizeof(raceBuf) - strlen(raceBuf) - 1); + first = false; + } + bool playerAllowed = true; + if (gameHandler_) { + uint8_t pr = gameHandler_->getPlayerRace(); + uint32_t pmask = (pr > 0 && pr <= 11) ? (1u << (pr - 1)) : 0; + playerAllowed = (pmask == 0 || (info.allowableRace & pmask)); + } + ImVec4 rColor = playerAllowed ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(rColor, "%s", raceBuf); + } + } + } + + // Spell effects + for (const auto& sp : info.spells) { + if (sp.spellId == 0) continue; + const char* trigger = nullptr; + switch (sp.spellTrigger) { + case 0: trigger = "Use"; break; // on use + case 1: trigger = "Equip"; break; // on equip + case 2: trigger = "Chance on Hit"; break; // proc on melee hit + case 4: trigger = "Use"; break; // soulstone (still shows as Use) + case 5: trigger = "Use"; break; // on use, no delay + case 6: trigger = "Use"; break; // learn spell (recipe/pattern) + default: break; + } + if (!trigger) continue; + if (gameHandler_) { + // Prefer the spell's tooltip text (the actual effect description). + // Fall back to the spell name if the description is empty. + const std::string& spDesc = gameHandler_->getSpellDescription(sp.spellId); + const std::string& spName = spDesc.empty() ? gameHandler_->getSpellName(sp.spellId) : spDesc; + if (!spName.empty()) { + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 320.0f); + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: %s", trigger, spName.c_str()); + ImGui::PopTextWrapPos(); + } else { + ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "%s: Spell #%u", trigger, sp.spellId); + } + } + } + + // Gem socket slots + { + static const struct { uint32_t mask; const char* label; ImVec4 col; } kSocketTypes[] = { + { 1, "Meta Socket", { 0.7f, 0.7f, 0.9f, 1.0f } }, + { 2, "Red Socket", { 1.0f, 0.3f, 0.3f, 1.0f } }, + { 4, "Yellow Socket", { 1.0f, 0.9f, 0.3f, 1.0f } }, + { 8, "Blue Socket", { 0.3f, 0.6f, 1.0f, 1.0f } }, + }; + // Get socket gem enchant IDs for this item (filled from item update fields) + std::array sockGems{}; + if (itemGuid != 0 && gameHandler_) + sockGems = gameHandler_->getItemSocketEnchantIds(itemGuid); + + bool hasSocket = false; + for (int i = 0; i < 3; ++i) { + if (info.socketColor[i] == 0) continue; + if (!hasSocket) { ImGui::Spacing(); hasSocket = true; } + for (const auto& st : kSocketTypes) { + if (info.socketColor[i] & st.mask) { + if (sockGems[i] != 0) { + auto git = s_enchLookup.find(sockGems[i]); + if (git != s_enchLookup.end()) + ImGui::TextColored(st.col, "%s: %s", st.label, git->second.c_str()); + else + ImGui::TextColored(st.col, "%s: (gem %u)", st.label, sockGems[i]); + } else { + ImGui::TextColored(st.col, "%s", st.label); + } + break; + } + } + } + if (hasSocket && info.socketBonus != 0) { + auto enchIt = s_enchLookup.find(info.socketBonus); + if (enchIt != s_enchLookup.end()) + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: %s", enchIt->second.c_str()); + else + ImGui::TextColored(ImVec4(0.5f, 0.8f, 0.5f, 1.0f), "Socket Bonus: (id %u)", info.socketBonus); + } + } + + // Weapon/armor enchant display for equipped items + if (itemGuid != 0 && gameHandler_) { + auto [permId, tempId] = gameHandler_->getItemEnchantIds(itemGuid); + if (permId != 0) { + auto it2 = s_enchLookup.find(permId); + const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.0f, 0.8f, 1.0f, 1.0f), "Enchanted: %s", ename); + } + if (tempId != 0) { + auto it2 = s_enchLookup.find(tempId); + const char* ename = (it2 != s_enchLookup.end()) ? it2->second.c_str() : nullptr; + if (ename) ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.4f, 1.0f), "%s (temporary)", ename); + } + } + + // Item set membership + if (info.itemSetId != 0) { + // Lazy-load full ItemSet.dbc data (name + item IDs + bonus spells/thresholds) + struct SetEntry { + std::string name; + std::array itemIds{}; + std::array spellIds{}; + std::array thresholds{}; + }; + static std::unordered_map s_setData; + static bool s_setDataLoaded = false; + if (!s_setDataLoaded && assetManager_) { + s_setDataLoaded = true; + auto dbc = assetManager_->loadDBC("ItemSet.dbc"); + if (dbc && dbc->isLoaded()) { + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemSet") : nullptr; + auto lf = [&](const char* k, uint32_t def) -> uint32_t { + return layout ? (*layout)[k] : def; + }; + uint32_t idF = lf("ID", 0), nameF = lf("Name", 1); + static const char* itemKeys[10] = { + "Item0","Item1","Item2","Item3","Item4", + "Item5","Item6","Item7","Item8","Item9" + }; + static const char* spellKeys[10] = { + "Spell0","Spell1","Spell2","Spell3","Spell4", + "Spell5","Spell6","Spell7","Spell8","Spell9" + }; + static const char* thrKeys[10] = { + "Threshold0","Threshold1","Threshold2","Threshold3","Threshold4", + "Threshold5","Threshold6","Threshold7","Threshold8","Threshold9" + }; + uint32_t itemFallback[10], spellFallback[10], thrFallback[10]; + for (int i = 0; i < 10; ++i) { + itemFallback[i] = 18 + i; + spellFallback[i] = 28 + i; + thrFallback[i] = 38 + i; + } + for (uint32_t r = 0; r < dbc->getRecordCount(); ++r) { + uint32_t id = dbc->getUInt32(r, idF); + if (!id) continue; + SetEntry e; + e.name = dbc->getString(r, nameF); + for (int i = 0; i < 10; ++i) { + e.itemIds[i] = dbc->getUInt32(r, layout ? (*layout)[itemKeys[i]] : itemFallback[i]); + e.spellIds[i] = dbc->getUInt32(r, layout ? (*layout)[spellKeys[i]] : spellFallback[i]); + e.thresholds[i] = dbc->getUInt32(r, layout ? (*layout)[thrKeys[i]] : thrFallback[i]); + } + s_setData[id] = std::move(e); + } + } + } + + auto setIt = s_setData.find(info.itemSetId); + ImGui::Spacing(); + if (setIt != s_setData.end()) { + const SetEntry& se = setIt->second; + // Count equipped pieces + int equipped = 0, total = 0; + for (int i = 0; i < 10; ++i) { + if (se.itemIds[i] == 0) continue; + ++total; + if (inventory) { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& eSlot = inventory->getEquipSlot(static_cast(s)); + if (!eSlot.empty() && eSlot.item.itemId == se.itemIds[i]) { ++equipped; break; } + } + } + } + if (total > 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), + "%s (%d/%d)", se.name.empty() ? "Set" : se.name.c_str(), equipped, total); + } else { + if (!se.name.empty()) + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "%s", se.name.c_str()); + } + // Show set bonuses: gray if not reached, green if active + if (gameHandler_) { + for (int i = 0; i < 10; ++i) { + if (se.spellIds[i] == 0 || se.thresholds[i] == 0) continue; + const std::string& bname = gameHandler_->getSpellName(se.spellIds[i]); + bool active = (equipped >= static_cast(se.thresholds[i])); + ImVec4 col = active ? ImVec4(0.5f, 1.0f, 0.5f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + if (!bname.empty()) + ImGui::TextColored(col, "(%u) %s", se.thresholds[i], bname.c_str()); + else + ImGui::TextColored(col, "(%u) Set Bonus", se.thresholds[i]); + } + } + } else { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Set (id %u)", info.itemSetId); + } + } + + if (info.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + if (!info.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", info.description.c_str()); + } + + if (info.sellPrice > 0) { + uint32_t g = info.sellPrice / 10000; + uint32_t s = (info.sellPrice / 100) % 100; + uint32_t c = info.sellPrice % 100; + ImGui::TextDisabled("Sell:"); ImGui::SameLine(0, 4); + renderCoinsText(g, s, c); + } + + // Shift-hover: compare with currently equipped item + if (inventory && ImGui::GetIO().KeyShift && info.inventoryType > 0) { + if (const game::ItemSlot* eq = findComparableEquipped(*inventory, static_cast(info.inventoryType))) { + ImGui::Separator(); + ImGui::TextDisabled("Equipped:"); + VkDescriptorSet eqIcon = getItemIcon(eq->item.displayInfoId); + if (eqIcon) { ImGui::Image((ImTextureID)(uintptr_t)eqIcon, ImVec2(18.0f, 18.0f)); ImGui::SameLine(); } + ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + + auto showDiff = [](const char* label, float nv, float ev) { + if (nv == 0.0f && ev == 0.0f) return; + float diff = nv - ev; + char buf[96]; + if (diff > 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▲%.0f)", label, nv, diff); ImGui::TextColored(ImVec4(0.0f,1.0f,0.0f,1.0f), "%s", buf); } + else if (diff < 0.0f) { std::snprintf(buf, sizeof(buf), "%s: %.0f (▼%.0f)", label, nv, -diff); ImGui::TextColored(ImVec4(1.0f,0.3f,0.3f,1.0f), "%s", buf); } + else { std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, nv); ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", buf); } + }; + + float ilvlDiff = static_cast(info.itemLevel) - static_cast(eq->item.itemLevel); + if (info.itemLevel > 0 || eq->item.itemLevel > 0) { + char ilvlBuf[64]; + if (ilvlDiff > 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", info.itemLevel, ilvlDiff); + else if (ilvlDiff < 0) std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", info.itemLevel, -ilvlDiff); + else std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", info.itemLevel); + ImVec4 ic = ilvlDiff > 0 ? ImVec4(0,1,0,1) : ilvlDiff < 0 ? ImVec4(1,0.3f,0.3f,1) : ImVec4(0.7f,0.7f,0.7f,1); + ImGui::TextColored(ic, "%s", ilvlBuf); + } + + // DPS comparison for weapons + if (isWeaponInvType(info.inventoryType) && isWeaponInvType(eq->item.inventoryType)) { + float newDps = 0.0f, eqDps = 0.0f; + if (info.damageMax > 0.0f && info.delayMs > 0) + newDps = ((info.damageMin + info.damageMax) * 0.5f) / (info.delayMs / 1000.0f); + if (eq->item.damageMax > 0.0f && eq->item.delayMs > 0) + eqDps = ((eq->item.damageMin + eq->item.damageMax) * 0.5f) / (eq->item.delayMs / 1000.0f); + showDiff("DPS", newDps, eqDps); + } + + showDiff("Armor", static_cast(info.armor), static_cast(eq->item.armor)); + showDiff("Str", static_cast(info.strength), static_cast(eq->item.strength)); + showDiff("Agi", static_cast(info.agility), static_cast(eq->item.agility)); + showDiff("Sta", static_cast(info.stamina), static_cast(eq->item.stamina)); + showDiff("Int", static_cast(info.intellect), static_cast(eq->item.intellect)); + showDiff("Spi", static_cast(info.spirit), static_cast(eq->item.spirit)); + + // Extra stats diff — union of stat types from both items + auto findExtraStat = [](const auto& it, uint32_t type) -> int32_t { + for (const auto& es : it.extraStats) + if (es.statType == type) return es.statValue; + return 0; + }; + std::vector allTypes; + for (const auto& es : info.extraStats) allTypes.push_back(es.statType); + for (const auto& es : eq->item.extraStats) { + bool found = false; + for (uint32_t t : allTypes) if (t == es.statType) { found = true; break; } + if (!found) allTypes.push_back(es.statType); + } + for (uint32_t t : allTypes) { + int32_t nv = findExtraStat(info, t); + int32_t ev = findExtraStat(eq->item, t); + const char* lbl = nullptr; + switch (t) { + case 0: lbl = "Mana"; break; + case 1: lbl = "Health"; break; + case 12: lbl = "Defense"; break; + case 13: lbl = "Dodge"; break; + case 14: lbl = "Parry"; break; + case 15: lbl = "Block Rating"; break; + case 16: case 17: case 18: case 31: lbl = "Hit"; break; + case 19: case 20: case 21: case 32: lbl = "Crit"; break; + case 28: case 29: case 30: case 36: lbl = "Haste"; break; + case 35: lbl = "Resilience"; break; + case 37: lbl = "Expertise"; break; + case 38: lbl = "Attack Power"; break; + case 39: lbl = "Ranged AP"; break; + case 41: lbl = "Healing"; break; + case 42: lbl = "Spell Damage"; break; + case 43: lbl = "MP5"; break; + case 44: lbl = "Armor Pen"; break; + case 45: lbl = "Spell Power"; break; + case 46: lbl = "HP5"; break; + case 47: lbl = "Spell Pen"; break; + case 48: lbl = "Block Value"; break; + default: lbl = nullptr; break; + } + if (!lbl) continue; + showDiff(lbl, static_cast(nv), static_cast(ev)); + } + } + } else if (info.inventoryType > 0) { + ImGui::TextDisabled("Hold Shift to compare"); } ImGui::EndTooltip(); diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp new file mode 100644 index 00000000..a7f52a3b --- /dev/null +++ b/src/ui/keybinding_manager.cpp @@ -0,0 +1,314 @@ +#include "ui/keybinding_manager.hpp" +#include "core/logger.hpp" +#include +#include + +namespace wowee::ui { + +static bool isReservedMovementKey(ImGuiKey key) { + return key == ImGuiKey_W || key == ImGuiKey_A || key == ImGuiKey_S || + key == ImGuiKey_D || key == ImGuiKey_Q || key == ImGuiKey_E; +} + +KeybindingManager& KeybindingManager::getInstance() { + static KeybindingManager instance; + return instance; +} + +KeybindingManager::KeybindingManager() { + initializeDefaults(); +} + +void KeybindingManager::initializeDefaults() { + // Set default keybindings + bindings_[static_cast(Action::TOGGLE_CHARACTER_SCREEN)] = ImGuiKey_C; + bindings_[static_cast(Action::TOGGLE_INVENTORY)] = ImGuiKey_I; + bindings_[static_cast(Action::TOGGLE_BAGS)] = ImGuiKey_B; + bindings_[static_cast(Action::TOGGLE_SPELLBOOK)] = ImGuiKey_P; // WoW standard key + bindings_[static_cast(Action::TOGGLE_TALENTS)] = ImGuiKey_N; // WoW standard key + bindings_[static_cast(Action::TOGGLE_QUESTS)] = ImGuiKey_L; + bindings_[static_cast(Action::TOGGLE_MINIMAP)] = ImGuiKey_None; // minimap is always visible; no default toggle + bindings_[static_cast(Action::TOGGLE_SETTINGS)] = ImGuiKey_Escape; + bindings_[static_cast(Action::TOGGLE_CHAT)] = ImGuiKey_Enter; + bindings_[static_cast(Action::TOGGLE_GUILD_ROSTER)] = ImGuiKey_O; + bindings_[static_cast(Action::TOGGLE_DUNGEON_FINDER)] = ImGuiKey_J; // Originally I, reassigned to avoid conflict + bindings_[static_cast(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map + bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; + bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) + bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) + bindings_[static_cast(Action::TOGGLE_SKILLS)] = ImGuiKey_K; // WoW standard: K opens Skills/Professions +} + +bool KeybindingManager::isActionPressed(Action action, bool repeat) { + auto it = bindings_.find(static_cast(action)); + if (it == bindings_.end()) return false; + ImGuiKey key = it->second; + if (key == ImGuiKey_None) return false; + + // When typing in a text field (e.g. chat input), never treat A-Z or 0-9 as shortcuts. + const ImGuiIO& io = ImGui::GetIO(); + if (io.WantTextInput) { + if ((key >= ImGuiKey_A && key <= ImGuiKey_Z) || + (key >= ImGuiKey_0 && key <= ImGuiKey_9)) { + return false; + } + } + + return ImGui::IsKeyPressed(key, repeat); +} + +ImGuiKey KeybindingManager::getKeyForAction(Action action) const { + auto it = bindings_.find(static_cast(action)); + if (it == bindings_.end()) return ImGuiKey_None; + return it->second; +} + +void KeybindingManager::setKeyForAction(Action action, ImGuiKey key) { + // Reserve movement keys so they cannot be used as UI shortcuts. + (void)action; + if (isReservedMovementKey(key)) { + key = ImGuiKey_None; + } + bindings_[static_cast(action)] = key; +} + +void KeybindingManager::resetToDefaults() { + bindings_.clear(); + initializeDefaults(); +} + +const char* KeybindingManager::getActionName(Action action) { + switch (action) { + case Action::TOGGLE_CHARACTER_SCREEN: return "Character Screen"; + case Action::TOGGLE_INVENTORY: return "Inventory"; + case Action::TOGGLE_BAGS: return "Bags"; + case Action::TOGGLE_SPELLBOOK: return "Spellbook"; + case Action::TOGGLE_TALENTS: return "Talents"; + case Action::TOGGLE_QUESTS: return "Quests"; + case Action::TOGGLE_MINIMAP: return "Minimap"; + case Action::TOGGLE_SETTINGS: return "Settings"; + case Action::TOGGLE_CHAT: return "Chat"; + case Action::TOGGLE_GUILD_ROSTER: return "Guild Roster / Social"; + case Action::TOGGLE_DUNGEON_FINDER: return "Dungeon Finder"; + case Action::TOGGLE_WORLD_MAP: return "World Map"; + case Action::TOGGLE_NAMEPLATES: return "Nameplates"; + case Action::TOGGLE_RAID_FRAMES: return "Raid Frames"; + case Action::TOGGLE_ACHIEVEMENTS: return "Achievements"; + case Action::TOGGLE_SKILLS: return "Skills / Professions"; + case Action::ACTION_COUNT: break; + } + return "Unknown"; +} + +void KeybindingManager::loadFromConfigFile(const std::string& filePath) { + std::ifstream file(filePath); + if (!file.is_open()) { + LOG_ERROR("KeybindingManager: Failed to open config file: ", filePath); + return; + } + + std::string line; + bool inKeybindingsSection = false; + + while (std::getline(file, line)) { + // Trim whitespace + size_t start = line.find_first_not_of(" \t\r\n"); + size_t end = line.find_last_not_of(" \t\r\n"); + if (start == std::string::npos) continue; + line = line.substr(start, end - start + 1); + + // Check for section header + if (line == "[Keybindings]") { + inKeybindingsSection = true; + continue; + } else if (line[0] == '[') { + inKeybindingsSection = false; + continue; + } + + if (!inKeybindingsSection || line.empty() || line[0] == ';' || line[0] == '#') continue; + + // Parse key=value pair + size_t eqPos = line.find('='); + if (eqPos == std::string::npos) continue; + + std::string action = line.substr(0, eqPos); + std::string keyStr = line.substr(eqPos + 1); + + // Trim key string + size_t kStart = keyStr.find_first_not_of(" \t"); + size_t kEnd = keyStr.find_last_not_of(" \t"); + if (kStart != std::string::npos) { + keyStr = keyStr.substr(kStart, kEnd - kStart + 1); + } + + // Map action name to enum (simplified mapping) + int actionIdx = -1; + if (action == "toggle_character_screen") actionIdx = static_cast(Action::TOGGLE_CHARACTER_SCREEN); + else if (action == "toggle_inventory") actionIdx = static_cast(Action::TOGGLE_INVENTORY); + else if (action == "toggle_bags") actionIdx = static_cast(Action::TOGGLE_BAGS); + else if (action == "toggle_spellbook") actionIdx = static_cast(Action::TOGGLE_SPELLBOOK); + else if (action == "toggle_talents") actionIdx = static_cast(Action::TOGGLE_TALENTS); + else if (action == "toggle_quests") actionIdx = static_cast(Action::TOGGLE_QUESTS); + else if (action == "toggle_minimap") actionIdx = static_cast(Action::TOGGLE_MINIMAP); + else if (action == "toggle_settings") actionIdx = static_cast(Action::TOGGLE_SETTINGS); + else if (action == "toggle_chat") actionIdx = static_cast(Action::TOGGLE_CHAT); + else if (action == "toggle_guild_roster") actionIdx = static_cast(Action::TOGGLE_GUILD_ROSTER); + else if (action == "toggle_dungeon_finder") actionIdx = static_cast(Action::TOGGLE_DUNGEON_FINDER); + else if (action == "toggle_world_map") actionIdx = static_cast(Action::TOGGLE_WORLD_MAP); + else if (action == "toggle_nameplates") actionIdx = static_cast(Action::TOGGLE_NAMEPLATES); + else if (action == "toggle_raid_frames") actionIdx = static_cast(Action::TOGGLE_RAID_FRAMES); + else if (action == "toggle_quest_log") actionIdx = static_cast(Action::TOGGLE_QUESTS); // legacy alias + else if (action == "toggle_achievements") actionIdx = static_cast(Action::TOGGLE_ACHIEVEMENTS); + else if (action == "toggle_skills") actionIdx = static_cast(Action::TOGGLE_SKILLS); + + if (actionIdx < 0) continue; + + // Parse key string to ImGuiKey (simple mapping of common keys) + ImGuiKey key = ImGuiKey_None; + if (keyStr.length() == 1) { + // Single character key (A-Z, 0-9) + char c = keyStr[0]; + if (c >= 'A' && c <= 'Z') { + key = static_cast(ImGuiKey_A + (c - 'A')); + } else if (c >= '0' && c <= '9') { + key = static_cast(ImGuiKey_0 + (c - '0')); + } + } else if (keyStr == "Escape") { + key = ImGuiKey_Escape; + } else if (keyStr == "Enter") { + key = ImGuiKey_Enter; + } else if (keyStr == "Tab") { + key = ImGuiKey_Tab; + } else if (keyStr == "Backspace") { + key = ImGuiKey_Backspace; + } else if (keyStr == "Space") { + key = ImGuiKey_Space; + } else if (keyStr == "Delete") { + key = ImGuiKey_Delete; + } else if (keyStr == "Home") { + key = ImGuiKey_Home; + } else if (keyStr == "End") { + key = ImGuiKey_End; + } else if (keyStr.find("F") == 0 && keyStr.length() <= 3) { + // F1-F12 keys + int fNum = std::stoi(keyStr.substr(1)); + if (fNum >= 1 && fNum <= 12) { + key = static_cast(ImGuiKey_F1 + (fNum - 1)); + } + } + + if (key == ImGuiKey_None) continue; + + // Reserve movement keys so they cannot be used as UI shortcuts. + if (isReservedMovementKey(key)) { + continue; + } + + bindings_[actionIdx] = key; + } + + file.close(); + LOG_INFO("KeybindingManager: Loaded keybindings from ", filePath); +} + +void KeybindingManager::saveToConfigFile(const std::string& filePath) const { + std::ifstream inFile(filePath); + std::string content; + std::string line; + + // Read existing file, removing [Keybindings] section if it exists + bool inKeybindingsSection = false; + if (inFile.is_open()) { + while (std::getline(inFile, line)) { + if (line == "[Keybindings]") { + inKeybindingsSection = true; + continue; + } else if (line[0] == '[') { + inKeybindingsSection = false; + } + + if (!inKeybindingsSection) { + content += line + "\n"; + } + } + inFile.close(); + } + + // Append new Keybindings section + content += "[Keybindings]\n"; + + static const struct { + Action action; + const char* name; + } actionMap[] = { + {Action::TOGGLE_CHARACTER_SCREEN, "toggle_character_screen"}, + {Action::TOGGLE_INVENTORY, "toggle_inventory"}, + {Action::TOGGLE_BAGS, "toggle_bags"}, + {Action::TOGGLE_SPELLBOOK, "toggle_spellbook"}, + {Action::TOGGLE_TALENTS, "toggle_talents"}, + {Action::TOGGLE_QUESTS, "toggle_quests"}, + {Action::TOGGLE_MINIMAP, "toggle_minimap"}, + {Action::TOGGLE_SETTINGS, "toggle_settings"}, + {Action::TOGGLE_CHAT, "toggle_chat"}, + {Action::TOGGLE_GUILD_ROSTER, "toggle_guild_roster"}, + {Action::TOGGLE_DUNGEON_FINDER, "toggle_dungeon_finder"}, + {Action::TOGGLE_WORLD_MAP, "toggle_world_map"}, + {Action::TOGGLE_NAMEPLATES, "toggle_nameplates"}, + {Action::TOGGLE_RAID_FRAMES, "toggle_raid_frames"}, + {Action::TOGGLE_ACHIEVEMENTS, "toggle_achievements"}, + {Action::TOGGLE_SKILLS, "toggle_skills"}, + }; + + for (const auto& [action, nameStr] : actionMap) { + auto it = bindings_.find(static_cast(action)); + if (it == bindings_.end()) continue; + + ImGuiKey key = it->second; + std::string keyStr; + + // Convert ImGuiKey to string + if (key >= ImGuiKey_A && key <= ImGuiKey_Z) { + keyStr += static_cast('A' + (key - ImGuiKey_A)); + } else if (key >= ImGuiKey_0 && key <= ImGuiKey_9) { + keyStr += static_cast('0' + (key - ImGuiKey_0)); + } else if (key == ImGuiKey_Escape) { + keyStr = "Escape"; + } else if (key == ImGuiKey_Enter) { + keyStr = "Enter"; + } else if (key == ImGuiKey_Tab) { + keyStr = "Tab"; + } else if (key == ImGuiKey_Backspace) { + keyStr = "Backspace"; + } else if (key == ImGuiKey_Space) { + keyStr = "Space"; + } else if (key == ImGuiKey_Delete) { + keyStr = "Delete"; + } else if (key == ImGuiKey_Home) { + keyStr = "Home"; + } else if (key == ImGuiKey_End) { + keyStr = "End"; + } else if (key >= ImGuiKey_F1 && key <= ImGuiKey_F12) { + keyStr = "F" + std::to_string(1 + (key - ImGuiKey_F1)); + } + + if (!keyStr.empty()) { + content += nameStr; + content += "="; + content += keyStr; + content += "\n"; + } + } + + // Write back to file + std::ofstream outFile(filePath); + if (outFile.is_open()) { + outFile << content; + outFile.close(); + LOG_INFO("KeybindingManager: Saved keybindings to ", filePath); + } else { + LOG_ERROR("KeybindingManager: Failed to write config file: ", filePath); + } +} + +} // namespace wowee::ui diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 00fbd173..fe5cd2cb 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -1,4 +1,6 @@ #include "ui/quest_log_screen.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/keybinding_manager.hpp" #include "core/application.hpp" #include "core/input.hpp" #include @@ -80,6 +82,14 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler pos += replacement.length(); } + // Resolve class and race names for $C and $R placeholders + std::string className = "Adventurer"; + std::string raceName = "Unknown"; + if (character) { + className = game::getClassName(character->characterClass); + raceName = game::getRaceName(character->race); + } + // Replace simple placeholders pos = 0; while ((pos = result.find('$', pos)) != std::string::npos) { @@ -90,11 +100,12 @@ std::string replaceGenderPlaceholders(const std::string& text, game::GameHandler switch (code) { case 'n': case 'N': replacement = playerName; break; + case 'c': case 'C': replacement = className; break; + case 'r': case 'R': replacement = raceName; break; case 'p': replacement = pronouns.subject; break; case 'o': replacement = pronouns.object; break; case 's': replacement = pronouns.possessive; break; case 'S': replacement = pronouns.possessiveP; break; - case 'r': replacement = pronouns.object; break; case 'b': case 'B': replacement = "\n"; break; case 'g': case 'G': pos++; continue; default: pos++; continue; @@ -203,16 +214,32 @@ std::string cleanQuestTitleForUi(const std::string& raw, uint32_t questId) { if (s.size() > 72) s = s.substr(0, 72) + "..."; return s; } + +void renderCoinsText(uint32_t g, uint32_t s, uint32_t c) { + bool any = false; + if (g > 0) { + ImGui::TextColored(ImVec4(1.00f, 0.82f, 0.00f, 1.0f), "%ug", g); + any = true; + } + if (s > 0 || g > 0) { + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.80f, 0.80f, 0.80f, 1.0f), "%us", s); + any = true; + } + if (any) ImGui::SameLine(0, 3); + ImGui::TextColored(ImVec4(0.72f, 0.45f, 0.20f, 1.0f), "%uc", c); +} } // anonymous namespace -void QuestLogScreen::render(game::GameHandler& gameHandler) { - // L key toggle (edge-triggered) - ImGuiIO& io = ImGui::GetIO(); - bool lDown = !io.WantTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_L); - if (lDown && !lKeyWasDown) { +void QuestLogScreen::render(game::GameHandler& gameHandler, InventoryScreen& invScreen) { + // Quests toggle via keybinding (edge-triggered) + // Customizable key (default: L) from KeybindingManager + bool questsDown = KeybindingManager::getInstance().isActionPressed( + KeybindingManager::Action::TOGGLE_QUESTS, false); + if (questsDown && !lKeyWasDown) { open = !open; } - lKeyWasDown = lDown; + lKeyWasDown = questsDown; if (!open) return; @@ -245,6 +272,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { else activeCount++; } + // Search bar + filter buttons on one row + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 210.0f); + ImGui::InputTextWithHint("##qsearch", "Search quests...", questSearchFilter_, sizeof(questSearchFilter_)); + ImGui::SameLine(); + if (ImGui::RadioButton("All", questFilterMode_ == 0)) questFilterMode_ = 0; + ImGui::SameLine(); + if (ImGui::RadioButton("Active", questFilterMode_ == 1)) questFilterMode_ = 1; + ImGui::SameLine(); + if (ImGui::RadioButton("Ready", questFilterMode_ == 2)) questFilterMode_ = 2; + + // Summary counts ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.35f, 1.0f), "Active: %d", activeCount); ImGui::SameLine(); ImGui::TextColored(ImVec4(0.45f, 0.95f, 0.45f, 1.0f), "Ready: %d", completeCount); @@ -267,14 +305,36 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { for (size_t i = 0; i < quests.size(); i++) { if (quests[i].questId == pendingSelectQuestId_) { selectedIndex = static_cast(i); + // Clear filter so the target quest is visible + questSearchFilter_[0] = '\0'; + questFilterMode_ = 0; break; } } pendingSelectQuestId_ = 0; } + // Build a case-insensitive lowercase copy of the search filter once + char filterLower[64] = {}; + for (size_t fi = 0; fi < sizeof(questSearchFilter_) && questSearchFilter_[fi]; ++fi) + filterLower[fi] = static_cast(std::tolower(static_cast(questSearchFilter_[fi]))); + + int visibleQuestCount = 0; for (size_t i = 0; i < quests.size(); i++) { const auto& q = quests[i]; + + // Apply mode filter + if (questFilterMode_ == 1 && q.complete) continue; + if (questFilterMode_ == 2 && !q.complete) continue; + + // Apply name search filter + if (filterLower[0]) { + std::string titleLower = cleanQuestTitleForUi(q.title, q.questId); + for (char& c : titleLower) c = static_cast(std::tolower(static_cast(c))); + if (titleLower.find(filterLower) == std::string::npos) continue; + } + + visibleQuestCount++; ImGui::PushID(static_cast(i)); bool selected = (selectedIndex == static_cast(i)); @@ -316,8 +376,41 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { questDetailQueryNoResponse_.erase(q.questId); } } + + // Right-click context menu on quest row + if (ImGui::BeginPopupContextItem("QuestRowCtx")) { + selectedIndex = static_cast(i); // select on right-click too + ImGui::TextDisabled("%s", displayTitle.c_str()); + ImGui::Separator(); + bool tracked = gameHandler.isQuestTracked(q.questId); + if (ImGui::MenuItem(tracked ? "Untrack" : "Track")) { + gameHandler.setQuestTracked(q.questId, !tracked); + } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + selectedIndex = -1; + } + } + ImGui::EndPopup(); + } + ImGui::PopID(); } + if (visibleQuestCount == 0) { + ImGui::Spacing(); + if (filterLower[0] || questFilterMode_ != 0) + ImGui::TextDisabled("No quests match the filter."); + else + ImGui::TextDisabled("No active quests."); + } ImGui::EndChild(); ImGui::SameLine(); @@ -379,23 +472,146 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Tracked Progress"); for (const auto& [entry, progress] : sel.killCounts) { - ImGui::BulletText("Kill %u: %u/%u", entry, progress.first, progress.second); + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + // Game object objective: fall back to GO name cache. + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } + if (name.empty()) name = "Unknown (" + std::to_string(entry) + ")"; + ImGui::BulletText("%s: %u/%u", name.c_str(), progress.first, progress.second); } for (const auto& [itemId, count] : sel.itemCounts) { std::string itemLabel = "Item " + std::to_string(itemId); + uint32_t dispId = 0; if (const auto* info = gameHandler.getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; + dispId = info->displayInfoId; + } else { + gameHandler.ensureItemInfo(itemId); + } + uint32_t required = 1; + auto reqIt = sel.requiredItemCounts.find(itemId); + if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; + VkDescriptorSet iconTex = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + const auto* objInfo = gameHandler.getItemInfo(itemId); + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(14, 14)); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } + ImGui::SameLine(); + ImGui::Text("%s: %u/%u", itemLabel.c_str(), count, required); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } + } else { + ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); + if (objInfo && objInfo->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*objInfo); + ImGui::EndTooltip(); + } } - ImGui::BulletText("%s: %u", itemLabel.c_str(), count); } } - // Track / Abandon buttons + // Reward summary + bool hasAnyReward = (sel.rewardMoney != 0); + for (const auto& ri : sel.rewardItems) if (ri.itemId) hasAnyReward = true; + for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) hasAnyReward = true; + if (hasAnyReward) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "Rewards"); + + // Money reward + if (sel.rewardMoney > 0) { + uint32_t rg = static_cast(sel.rewardMoney) / 10000; + uint32_t rs = static_cast(sel.rewardMoney % 10000) / 100; + uint32_t rc = static_cast(sel.rewardMoney % 100); + renderCoinsText(rg, rs, rc); + } + + // Guaranteed reward items + bool anyFixed = false; + for (const auto& ri : sel.rewardItems) if (ri.itemId) { anyFixed = true; break; } + if (anyFixed) { + ImGui::TextDisabled("You will receive:"); + for (const auto& ri : sel.rewardItems) { + if (!ri.itemId) continue; + std::string name = "Item " + std::to_string(ri.itemId); + uint32_t dispId = 0; + const auto* info = gameHandler.getItemInfo(ri.itemId); + if (info && info->valid) { + if (!info->name.empty()) name = info->name; + dispId = info->displayInfoId; + } + VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16)); + ImGui::SameLine(); + } + if (ri.count > 1) + ImGui::Text("%s x%u", name.c_str(), ri.count); + else + ImGui::Text("%s", name.c_str()); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + } + } + + // Choice reward items + bool anyChoice = false; + for (const auto& ri : sel.rewardChoiceItems) if (ri.itemId) { anyChoice = true; break; } + if (anyChoice) { + ImGui::TextDisabled("Choose one of:"); + for (const auto& ri : sel.rewardChoiceItems) { + if (!ri.itemId) continue; + std::string name = "Item " + std::to_string(ri.itemId); + uint32_t dispId = 0; + const auto* info = gameHandler.getItemInfo(ri.itemId); + if (info && info->valid) { + if (!info->name.empty()) name = info->name; + dispId = info->displayInfoId; + } + VkDescriptorSet icon = dispId ? invScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (icon) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(16, 16)); + ImGui::SameLine(); + } + if (ri.count > 1) + ImGui::Text("%s x%u", name.c_str(), ri.count); + else + ImGui::Text("%s", name.c_str()); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + invScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + } + } + } + + // Track / Share / 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 (gameHandler.isInGroup() && !sel.complete) { + ImGui::SameLine(); + if (ImGui::Button("Share", ImVec2(80.0f, 0.0f))) { + gameHandler.shareQuestWithParty(sel.questId); + } + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Share this quest with your party"); + } if (!sel.complete) { ImGui::SameLine(); if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) { diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 6e857d73..3d2ceeed 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -1,4 +1,5 @@ #include "ui/spellbook_screen.hpp" +#include "ui/keybinding_manager.hpp" #include "core/input.hpp" #include "core/application.hpp" #include "rendering/vk_context.hpp" @@ -45,18 +46,66 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { } uint32_t fieldCount = dbc->getFieldCount(); - if (fieldCount < 154) { - LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, expected 234+"); + // Classic 1.12 Spell.dbc has 148 fields (Tooltip at index 147), TBC has ~220+ (SchoolMask at 215), WotLK has 234. + // Require at least 148 fields so all expansions can load spell names/icons via the DBC layout. + if (fieldCount < 148) { + LOG_WARNING("Spellbook: Spell.dbc has ", fieldCount, " fields, too few to load"); return; } const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + // Load SpellCastTimes.dbc: field 0=ID, field 1=Base(ms), field 2=PerLevel, field 3=Minimum + std::unordered_map castTimeMap; // index → base ms + auto castTimeDbc = assetManager->loadDBC("SpellCastTimes.dbc"); + if (castTimeDbc && castTimeDbc->isLoaded()) { + for (uint32_t i = 0; i < castTimeDbc->getRecordCount(); ++i) { + uint32_t id = castTimeDbc->getUInt32(i, 0); + int32_t base = static_cast(castTimeDbc->getUInt32(i, 1)); + if (id > 0 && base > 0) + castTimeMap[id] = static_cast(base); + } + } + + // Load SpellRange.dbc. Field layout differs by expansion: + // Classic 1.12: 0=ID, 1=MinRange, 2=MaxRange, 3=Flags, 4+=strings + // TBC / WotLK: 0=ID, 1=MinRangeFriendly, 2=MinRangeHostile, + // 3=MaxRangeFriendly, 4=MaxRangeHostile, 5=Flags, 6+=strings + // The correct field is declared in each expansion's dbc_layouts.json. + uint32_t spellRangeMaxField = 4; // WotLK / TBC default: MaxRangeHostile + const auto* spellRangeL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("SpellRange") + : nullptr; + if (spellRangeL) { + try { spellRangeMaxField = (*spellRangeL)["MaxRange"]; } catch (...) {} + } + std::unordered_map rangeMap; // index → max yards + auto rangeDbc = assetManager->loadDBC("SpellRange.dbc"); + if (rangeDbc && rangeDbc->isLoaded()) { + uint32_t rangeFieldCount = rangeDbc->getFieldCount(); + if (rangeFieldCount > spellRangeMaxField) { + for (uint32_t i = 0; i < rangeDbc->getRecordCount(); ++i) { + uint32_t id = rangeDbc->getUInt32(i, 0); + float maxRange = rangeDbc->getFloat(i, spellRangeMaxField); + if (id > 0 && maxRange > 0.0f) + rangeMap[id] = maxRange; + } + } + } + + // schoolField / isSchoolEnum are declared before the lambda so the WotLK fallback path + // can override them before the second tryLoad call. + uint32_t schoolField_ = UINT32_MAX; + bool isSchoolEnum_ = false; + auto tryLoad = [&](uint32_t idField, uint32_t attrField, uint32_t iconField, uint32_t nameField, uint32_t rankField, uint32_t tooltipField, + uint32_t powerTypeField, uint32_t manaCostField, + uint32_t castTimeIndexField, uint32_t rangeIndexField, const char* label) { spellData.clear(); uint32_t count = dbc->getRecordCount(); + const uint32_t fc = dbc->getFieldCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t spellId = dbc->getUInt32(i, idField); if (spellId == 0) continue; @@ -66,8 +115,31 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { info.attributes = dbc->getUInt32(i, attrField); info.iconId = dbc->getUInt32(i, iconField); info.name = dbc->getString(i, nameField); - info.rank = dbc->getString(i, rankField); - info.description = dbc->getString(i, tooltipField); + if (rankField < fc) info.rank = dbc->getString(i, rankField); + if (tooltipField < fc) info.description = dbc->getString(i, tooltipField); + // Optional fields: only read if field index is valid for this DBC version + if (powerTypeField < fc) info.powerType = dbc->getUInt32(i, powerTypeField); + if (manaCostField < fc) info.manaCost = dbc->getUInt32(i, manaCostField); + if (castTimeIndexField < fc) { + uint32_t ctIdx = dbc->getUInt32(i, castTimeIndexField); + if (ctIdx > 0) { + auto ctIt = castTimeMap.find(ctIdx); + if (ctIt != castTimeMap.end()) info.castTimeMs = ctIt->second; + } + } + if (rangeIndexField < fc) { + uint32_t rangeIdx = dbc->getUInt32(i, rangeIndexField); + if (rangeIdx > 0) { + auto rangeIt = rangeMap.find(rangeIdx); + if (rangeIt != rangeMap.end()) info.rangeIndex = static_cast(rangeIt->second); + } + } + if (schoolField_ < fc) { + uint32_t raw = dbc->getUInt32(i, schoolField_); + // Classic/Turtle use a 0-6 school enum; TBC/WotLK use a bitmask. + // enum→mask: schoolEnum N maps to bit (1u << N), e.g. 0→1 (physical), 4→16 (frost). + info.schoolMask = isSchoolEnum_ ? (raw <= 6 ? (1u << raw) : 0u) : raw; + } if (!info.name.empty()) { spellData[spellId] = std::move(info); @@ -77,21 +149,51 @@ void SpellbookScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { }; if (spellL) { - uint32_t tooltipField = 139; - // Try to get Tooltip field from layout, fall back to 139 - try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + // Default to UINT32_MAX for optional fields; tryLoad will skip them if >= fieldCount. + // Avoids reading wrong data from expansion DBCs that lack these fields (e.g. Classic/TBC). + uint32_t tooltipField = UINT32_MAX; + uint32_t powerTypeField = UINT32_MAX; + uint32_t manaCostField = UINT32_MAX; + uint32_t castTimeIdxField = UINT32_MAX; + uint32_t rangeIdxField = UINT32_MAX; + try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + try { powerTypeField = (*spellL)["PowerType"]; } catch (...) {} + try { manaCostField = (*spellL)["ManaCost"]; } catch (...) {} + try { castTimeIdxField = (*spellL)["CastingTimeIndex"]; } catch (...) {} + try { rangeIdxField = (*spellL)["RangeIndex"]; } catch (...) {} + // Try SchoolMask (TBC/WotLK bitmask) then SchoolEnum (Classic/Turtle 0-6 value) + schoolField_ = UINT32_MAX; + isSchoolEnum_ = false; + try { schoolField_ = (*spellL)["SchoolMask"]; } catch (...) {} + if (schoolField_ == UINT32_MAX) { + try { schoolField_ = (*spellL)["SchoolEnum"]; isSchoolEnum_ = true; } catch (...) {} + } tryLoad((*spellL)["ID"], (*spellL)["Attributes"], (*spellL)["IconID"], - (*spellL)["Name"], (*spellL)["Rank"], tooltipField, "expansion layout"); + (*spellL)["Name"], (*spellL)["Rank"], tooltipField, + powerTypeField, manaCostField, castTimeIdxField, rangeIdxField, + "expansion layout"); } if (spellData.empty() && fieldCount >= 200) { LOG_INFO("Spellbook: Retrying with WotLK field indices (DBC has ", fieldCount, " fields)"); - tryLoad(0, 4, 133, 136, 153, 139, "WotLK fallback"); + // WotLK Spell.dbc field indices (verified against 3.3.5a schema); SchoolMask at field 225 + schoolField_ = 225; + isSchoolEnum_ = false; + tryLoad(0, 4, 133, 136, 153, 139, 14, 39, 47, 49, "WotLK fallback"); } dbcLoaded = !spellData.empty(); } +bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler& gameHandler, + pipeline::AssetManager* assetManager) { + if (!dbcLoadAttempted) loadSpellDBC(assetManager); + const SpellInfo* info = getSpellInfo(spellId); + if (!info) return false; + renderSpellTooltip(info, gameHandler, /*showUsageHints=*/false); + return true; +} + std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetManager* assetManager) { if (!dbcLoadAttempted) { loadSpellDBC(assetManager); @@ -101,6 +203,29 @@ std::string SpellbookScreen::lookupSpellName(uint32_t spellId, pipeline::AssetMa return {}; } +uint32_t SpellbookScreen::getSpellMaxRange(uint32_t spellId, pipeline::AssetManager* assetManager) { + if (!dbcLoadAttempted) { + loadSpellDBC(assetManager); + } + auto it = spellData.find(spellId); + if (it != spellData.end()) return it->second.rangeIndex; + return 0; +} + +void SpellbookScreen::getSpellPowerInfo(uint32_t spellId, pipeline::AssetManager* assetManager, + uint32_t& outCost, uint32_t& outPowerType) { + outCost = 0; + outPowerType = 0; + if (!dbcLoadAttempted) { + loadSpellDBC(assetManager); + } + auto it = spellData.find(spellId); + if (it != spellData.end()) { + outCost = it->second.manaCost; + outPowerType = it->second.powerType; + } +} + void SpellbookScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { if (iconDbLoaded) return; iconDbLoaded = true; @@ -309,6 +434,14 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; + // Rate-limit GPU uploads to avoid a multi-frame stall when switching tabs. + // Icons not loaded this frame will be retried next frame (progressive load). + static int loadsThisFrame = 0; + static int lastImGuiFrame = -1; + int curFrame = ImGui::GetFrameCount(); + if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; } + if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { spellIconCache[iconId] = VK_NULL_HANDLE; @@ -335,6 +468,7 @@ VkDescriptorSet SpellbookScreen::getSpellIcon(uint32_t iconId, pipeline::AssetMa return VK_NULL_HANDLE; } + ++loadsThisFrame; VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); spellIconCache[iconId] = ds; return ds; @@ -345,7 +479,7 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { return (it != spellData.end()) ? &it->second : nullptr; } -void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) { +void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints) { ImGui::BeginTooltip(); ImGui::PushTextWrapPos(320.0f); @@ -363,6 +497,91 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "Passive"); } + // Spell school — only show for non-physical schools (physical is the default/implicit) + if (info->schoolMask != 0 && info->schoolMask != 1 /*physical*/) { + struct SchoolEntry { uint32_t mask; const char* name; ImVec4 color; }; + static constexpr SchoolEntry kSchools[] = { + { 2, "Holy", { 1.0f, 1.0f, 0.6f, 1.0f } }, + { 4, "Fire", { 1.0f, 0.5f, 0.1f, 1.0f } }, + { 8, "Nature", { 0.4f, 0.9f, 0.3f, 1.0f } }, + { 16, "Frost", { 0.5f, 0.8f, 1.0f, 1.0f } }, + { 32, "Shadow", { 0.7f, 0.4f, 1.0f, 1.0f } }, + { 64, "Arcane", { 0.9f, 0.5f, 1.0f, 1.0f } }, + }; + bool first = true; + for (const auto& s : kSchools) { + if (info->schoolMask & s.mask) { + if (!first) ImGui::SameLine(0, 0); + if (first) { + ImGui::TextColored(s.color, "%s", s.name); + first = false; + } else { + ImGui::SameLine(0, 2); + ImGui::TextColored(s.color, "/%s", s.name); + } + } + } + } + + // Resource cost + cast time on same row (WoW style) + if (!info->isPassive()) { + // Left: resource cost (with talent flat/pct modifier applied) + char costBuf[64] = ""; + if (info->manaCost > 0) { + const char* powerName = "Mana"; + switch (info->powerType) { + case 1: powerName = "Rage"; break; + case 3: powerName = "Energy"; break; + case 4: powerName = "Focus"; break; + default: break; + } + // Apply SMSG_SET_FLAT/PCT_SPELL_MODIFIER Cost modifier (SpellModOp::Cost = 14) + int32_t flatCost = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::Cost); + int32_t pctCost = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::Cost); + uint32_t displayCost = static_cast( + game::GameHandler::applySpellMod(static_cast(info->manaCost), flatCost, pctCost)); + std::snprintf(costBuf, sizeof(costBuf), "%u %s", displayCost, powerName); + } + + // Right: cast time (with talent CastingTime modifier applied) + char castBuf[32] = ""; + if (info->castTimeMs == 0) { + std::snprintf(castBuf, sizeof(castBuf), "Instant cast"); + } else { + // Apply SpellModOp::CastingTime (10) modifiers + int32_t flatCT = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::CastingTime); + int32_t pctCT = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::CastingTime); + int32_t modCT = game::GameHandler::applySpellMod( + static_cast(info->castTimeMs), flatCT, pctCT); + float secs = static_cast(modCT) / 1000.0f; + std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs > 0.0f ? secs : 0.0f); + } + + if (costBuf[0] || castBuf[0]) { + float wrapW = 320.0f; + if (costBuf[0] && castBuf[0]) { + float castW = ImGui::CalcTextSize(castBuf).x; + ImGui::Text("%s", costBuf); + ImGui::SameLine(wrapW - castW); + ImGui::Text("%s", castBuf); + } else if (castBuf[0]) { + ImGui::Text("%s", castBuf); + } else { + ImGui::Text("%s", costBuf); + } + } + + // Range + if (info->rangeIndex > 0) { + char rangeBuf[32]; + if (info->rangeIndex <= 5) + std::snprintf(rangeBuf, sizeof(rangeBuf), "Melee range"); + else + std::snprintf(rangeBuf, sizeof(rangeBuf), "%u yd range", info->rangeIndex); + ImGui::Text("%s", rangeBuf); + } + } + // Cooldown if active float cd = gameHandler.getSpellCooldown(info->spellId); if (cd > 0.0f) { @@ -375,8 +594,8 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextWrapped("%s", info->description.c_str()); } - // Usage hints - if (!info->isPassive()) { + // Usage hints — only shown when browsing the spellbook, not on action bar hover + if (!info->isPassive() && showUsageHints) { ImGui::Spacing(); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar"); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast"); @@ -387,13 +606,14 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle } void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetManager* assetManager) { - // P key toggle (edge-triggered) - bool wantsTextInput = ImGui::GetIO().WantTextInput; - bool pDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_P); - if (pDown && !pKeyWasDown) { + // Spellbook toggle via keybinding (edge-triggered) + // Customizable key (default: P) from KeybindingManager + bool spellbookDown = KeybindingManager::getInstance().isActionPressed( + KeybindingManager::Action::TOGGLE_SPELLBOOK, false); + if (spellbookDown && !pKeyWasDown) { open = !open; } - pKeyWasDown = pDown; + pKeyWasDown = spellbookDown; if (!open) return; @@ -479,9 +699,49 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Row selectable ImGui::Selectable("##row", false, - ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowHeight)); + ImGuiSelectableFlags_AllowDoubleClick | ImGuiSelectableFlags_DontClosePopups, + ImVec2(0, rowHeight)); bool rowHovered = ImGui::IsItemHovered(); bool rowClicked = ImGui::IsItemClicked(0); + + // Right-click context menu + if (ImGui::BeginPopupContextItem("##SpellCtx")) { + ImGui::TextDisabled("%s", info->name.c_str()); + if (!info->rank.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", info->rank.c_str()); + } + ImGui::Separator(); + if (!isPassive) { + if (onCooldown) ImGui::BeginDisabled(); + if (ImGui::MenuItem("Cast")) { + uint64_t tgt = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(info->spellId, tgt); + } + if (onCooldown) ImGui::EndDisabled(); + } + if (!isPassive) { + if (ImGui::MenuItem("Add to Action Bar")) { + const auto& bar = gameHandler.getActionBar(); + int firstEmpty = -1; + for (int si = 0; si < game::GameHandler::SLOTS_PER_BAR; ++si) { + if (bar[si].isEmpty()) { firstEmpty = si; break; } + } + if (firstEmpty >= 0) { + gameHandler.setActionBarSlot(firstEmpty, + game::ActionBarSlot::SPELL, info->spellId); + } + } + } + if (ImGui::MenuItem("Copy Spell Link")) { + char linkBuf[256]; + snprintf(linkBuf, sizeof(linkBuf), + "|cffffd000|Hspell:%u|h[%s]|h|r", + info->spellId, info->name.c_str()); + pendingChatSpellLink_ = linkBuf; + } + ImGui::EndPopup(); + } ImVec2 rMin = ImGui::GetItemRectMin(); ImVec2 rMax = ImGui::GetItemRectMax(); auto* dl = ImGui::GetWindowDrawList(); @@ -570,15 +830,25 @@ void SpellbookScreen::render(game::GameHandler& gameHandler, pipeline::AssetMana // Interaction if (rowHovered) { - // Start drag on click (not passive) - if (rowClicked && !isPassive) { + // Shift-click to insert spell link into chat + if (rowClicked && ImGui::GetIO().KeyShift && !info->name.empty()) { + // WoW spell link format: |cffffd000|Hspell:|h[Name]|h|r + char linkBuf[256]; + snprintf(linkBuf, sizeof(linkBuf), + "|cffffd000|Hspell:%u|h[%s]|h|r", + info->spellId, info->name.c_str()); + pendingChatSpellLink_ = linkBuf; + } + // Start drag on click (not passive, not shift-click) + else if (rowClicked && !isPassive && !ImGui::GetIO().KeyShift) { draggingSpell_ = true; dragSpellId_ = info->spellId; dragSpellIconTex_ = iconTex; } // Double-click to cast - if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown) { + if (ImGui::IsMouseDoubleClicked(0) && !isPassive && !onCooldown + && !ImGui::GetIO().KeyShift) { draggingSpell_ = false; dragSpellId_ = 0; dragSpellIconTex_ = VK_NULL_HANDLE; diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index eeff7c41..5f87712f 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -1,4 +1,5 @@ #include "ui/talent_screen.hpp" +#include "ui/keybinding_manager.hpp" #include "core/input.hpp" #include "core/application.hpp" #include "core/logger.hpp" @@ -22,13 +23,14 @@ static const char* getClassName(uint8_t classId) { } void TalentScreen::render(game::GameHandler& gameHandler) { - // N key toggle (edge-triggered) - bool wantsTextInput = ImGui::GetIO().WantTextInput; - bool nDown = !wantsTextInput && core::Input::getInstance().isKeyPressed(SDL_SCANCODE_N); - if (nDown && !nKeyWasDown) { + // Talents toggle via keybinding (edge-triggered) + // Customizable key (default: N) from KeybindingManager + bool talentsDown = KeybindingManager::getInstance().isActionPressed( + KeybindingManager::Action::TOGGLE_TALENTS, false); + if (talentsDown && !nKeyWasDown) { open = !open; } - nKeyWasDown = nDown; + nKeyWasDown = talentsDown; if (!open) return; @@ -74,6 +76,7 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { gameHandler.loadTalentDbc(); loadSpellDBC(assetManager); loadSpellIconDBC(assetManager); + loadGlyphPropertiesDBC(assetManager); } uint8_t playerClass = gameHandler.getPlayerClass(); @@ -159,8 +162,43 @@ void TalentScreen::renderTalentTrees(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } } + + // Glyphs tab (WotLK only — visible when any glyph slot is populated or DBC data loaded) + if (!glyphProperties_.empty() || [&]() { + const auto& g = gameHandler.getGlyphs(); + for (auto id : g) if (id != 0) return true; + return false; }()) { + if (ImGui::BeginTabItem("Glyphs")) { + renderGlyphs(gameHandler); + ImGui::EndTabItem(); + } + } + ImGui::EndTabBar(); } + + // Talent learn confirmation popup + if (talentConfirmOpen_) { + ImGui::OpenPopup("Learn Talent?##talent_confirm"); + talentConfirmOpen_ = false; + } + if (ImGui::BeginPopupModal("Learn Talent?##talent_confirm", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.3f, 1.0f), "%s", pendingTalentName_.c_str()); + ImGui::Text("Rank %u", pendingTalentRank_ + 1); + ImGui::Spacing(); + ImGui::TextWrapped("Spend a talent point?"); + ImGui::Spacing(); + if (ImGui::Button("Learn", ImVec2(80, 0))) { + gameHandler.learnTalent(pendingTalentId_, pendingTalentRank_); + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(80, 0))) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } } void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tabId, @@ -186,20 +224,23 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab return a->column < b->column; }); - // Find grid dimensions - uint8_t maxRow = 0, maxCol = 0; + // Find grid dimensions — use int to avoid uint8_t wrap-around infinite loops + int maxRow = 0, maxCol = 0; for (const auto* talent : talents) { - maxRow = std::max(maxRow, talent->row); - maxCol = std::max(maxCol, talent->column); + maxRow = std::max(maxRow, (int)talent->row); + maxCol = std::max(maxCol, (int)talent->column); } + // Sanity-cap to prevent runaway loops from corrupt/unexpected DBC data + maxRow = std::min(maxRow, 15); + maxCol = std::min(maxCol, 15); // WoW talent grids are always 4 columns wide if (maxCol < 3) maxCol = 3; const float iconSize = 40.0f; const float spacing = 8.0f; const float cellSize = iconSize + spacing; - const float gridWidth = (maxCol + 1) * cellSize + spacing; - const float gridHeight = (maxRow + 1) * cellSize + spacing; + const float gridWidth = (float)(maxCol + 1) * cellSize + spacing; + const float gridHeight = (float)(maxRow + 1) * cellSize + spacing; // Points in this tree uint32_t pointsInTree = 0; @@ -214,7 +255,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab float availW = ImGui::GetContentRegionAvail().x; float offsetX = std::max(0.0f, (availW - gridWidth) * 0.5f); - ImGui::BeginChild("TalentGrid", ImVec2(0, 0), false); + char childId[32]; + snprintf(childId, sizeof(childId), "TalentGrid_%u", tabId); + ImGui::BeginChild(childId, ImVec2(0, 0), false); ImVec2 gridOrigin = ImGui::GetCursorScreenPos(); gridOrigin.x += offsetX; @@ -226,9 +269,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab if (bgIt != bgTextureCache_.end()) { bgTex = bgIt->second; } else { - // Try to load the background texture + // Only load the background if icon uploads aren't saturating this frame. + // Background is cosmetic; skip if we're already loading icons this frame. std::string bgPath = bgFile; - // Normalize path separators for (auto& c : bgPath) { if (c == '\\') c = '/'; } bgPath += ".blp"; auto blpData = assetManager->readFile(bgPath); @@ -242,6 +285,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab } } } + // Cache even if null to avoid retrying every frame on missing files bgTextureCache_[tabId] = bgTex; } @@ -282,7 +326,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue; uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]); - bool met = prereqRank >= talent->prereqRank[i]; + bool met = prereqRank > talent->prereqRank[i]; // storage 1-indexed, DBC 0-indexed ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150); ImVec2 from = fromIt->second.center; @@ -304,8 +348,8 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab } // Render talent icons - for (uint8_t row = 0; row <= maxRow; ++row) { - for (uint8_t col = 0; col <= maxCol; ++col) { + for (int row = 0; row <= maxRow; ++row) { + for (int col = 0; col <= maxCol; ++col) { const game::GameHandler::TalentEntry* talent = nullptr; for (const auto* t : talents) { if (t->row == row && t->column == col) { @@ -323,8 +367,9 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab renderTalent(gameHandler, *talent, pointsInTree); } else { // Empty cell — invisible placeholder - ImGui::InvisibleButton(("e_" + std::to_string(row) + "_" + std::to_string(col)).c_str(), - ImVec2(iconSize, iconSize)); + char emptyId[32]; + snprintf(emptyId, sizeof(emptyId), "e_%u_%u_%u", tabId, row, col); + ImGui::InvisibleButton(emptyId, ImVec2(iconSize, iconSize)); } } } @@ -352,7 +397,7 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, for (int i = 0; i < 3; ++i) { if (talent.prereqTalent[i] != 0) { uint8_t prereqRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - if (prereqRank < talent.prereqRank[i]) { + if (prereqRank <= talent.prereqRank[i]) { // storage 1-indexed, DBC 0-indexed prereqsMet = false; canLearn = false; break; @@ -519,14 +564,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, if (!prereq || prereq->rankSpells[0] == 0) continue; uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - bool met = prereqCurrentRank >= talent.prereqRank[i]; + bool met = prereqCurrentRank > talent.prereqRank[i]; // storage 1-indexed, DBC 0-indexed ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1); const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]); ImGui::Spacing(); + const uint8_t reqRankDisplay = talent.prereqRank[i] + 1u; // DBC 0-indexed → display 1-indexed ImGui::TextColored(pColor, "Requires %u point%s in %s", - talent.prereqRank[i], - talent.prereqRank[i] > 1 ? "s" : "", + reqRankDisplay, + reqRankDisplay > 1 ? "s" : "", prereqName.empty() ? "prerequisite" : prereqName.c_str()); } @@ -551,16 +597,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, ImGui::EndTooltip(); } - // Handle click + // Handle click — open confirmation dialog instead of learning directly if (clicked && canLearn && prereqsMet) { - const auto& learned = gameHandler.getLearnedTalents(); - uint8_t desiredRank; - if (learned.find(talent.talentId) == learned.end()) { - desiredRank = 0; // First rank (0-indexed on wire) - } else { - desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn - } - gameHandler.learnTalent(talent.talentId, desiredRank); + talentConfirmOpen_ = true; + pendingTalentId_ = talent.talentId; + pendingTalentRank_ = currentRank; + uint32_t nextSpell = (currentRank < 5) ? talent.rankSpells[currentRank] : 0; + pendingTalentName_ = nextSpell ? gameHandler.getSpellName(nextSpell) : ""; + if (pendingTalentName_.empty()) + pendingTalentName_ = spellId ? gameHandler.getSpellName(spellId) : "Talent"; } ImGui::PopID(); @@ -576,15 +621,27 @@ void TalentScreen::loadSpellDBC(pipeline::AssetManager* assetManager) { if (!dbc || !dbc->isLoaded()) return; const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + uint32_t fieldCount = dbc->getFieldCount(); + // Detect DBC/layout mismatch: Classic layout expects ~173 fields but we may + // load the WotLK base DBC (234 fields). Use WotLK field indices in that case. + uint32_t idField = 0, iconField = 133, tooltipField = 139; + if (spellL) { + uint32_t layoutIcon = (*spellL)["IconID"]; + if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { + idField = (*spellL)["ID"]; + iconField = layoutIcon; + try { tooltipField = (*spellL)["Tooltip"]; } catch (...) {} + } + } uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { - uint32_t spellId = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); + uint32_t spellId = dbc->getUInt32(i, idField); if (spellId == 0) continue; - uint32_t iconId = dbc->getUInt32(i, spellL ? (*spellL)["IconID"] : 133); + uint32_t iconId = dbc->getUInt32(i, iconField); spellIconIds[spellId] = iconId; - std::string tooltip = dbc->getString(i, spellL ? (*spellL)["Tooltip"] : 139); + std::string tooltip = dbc->getString(i, tooltipField); if (!tooltip.empty()) { spellTooltips[spellId] = tooltip; } @@ -610,12 +667,116 @@ void TalentScreen::loadSpellIconDBC(pipeline::AssetManager* assetManager) { } } +void TalentScreen::loadGlyphPropertiesDBC(pipeline::AssetManager* assetManager) { + if (glyphDbcLoaded) return; + glyphDbcLoaded = true; + + if (!assetManager || !assetManager->isInitialized()) return; + + auto dbc = assetManager->loadDBC("GlyphProperties.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + // GlyphProperties.dbc: field 0=ID, field 1=SpellID, field 2=GlyphSlotFlags (1=minor), field 3=SpellIconID + for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { + uint32_t id = dbc->getUInt32(i, 0); + uint32_t spellId = dbc->getUInt32(i, 1); + uint32_t flags = dbc->getUInt32(i, 2); + if (id == 0) continue; + GlyphInfo info; + info.spellId = spellId; + info.isMajor = (flags == 0); // flag 0 = major, flag 1 = minor + glyphProperties_[id] = info; + } +} + +void TalentScreen::renderGlyphs(game::GameHandler& gameHandler) { + auto* assetManager = core::Application::getInstance().getAssetManager(); + const auto& glyphs = gameHandler.getGlyphs(); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "Major Glyphs"); + ImGui::Separator(); + + // WotLK: 6 glyph slots total. Slots 0,2,4 are major by convention from the server, + // but we check GlyphProperties.dbc flags when available. + // Display all 6 slots grouped: show major (non-minor) first, then minor. + std::vector> majorSlots, minorSlots; + for (int i = 0; i < game::GameHandler::MAX_GLYPH_SLOTS; i++) { + uint16_t glyphId = glyphs[i]; + bool isMajor = true; + if (glyphId != 0) { + auto git = glyphProperties_.find(glyphId); + if (git != glyphProperties_.end()) isMajor = git->second.isMajor; + else isMajor = (i % 2 == 0); // fallback: even slots = major + } else { + isMajor = (i % 2 == 0); // empty slots follow same pattern + } + if (isMajor) majorSlots.push_back({i, true}); + else minorSlots.push_back({i, false}); + } + + auto renderGlyphSlot = [&](int slotIdx) { + uint16_t glyphId = glyphs[slotIdx]; + char label[64]; + if (glyphId == 0) { + snprintf(label, sizeof(label), "Slot %d [Empty]", slotIdx + 1); + ImGui::TextDisabled("%s", label); + return; + } + + uint32_t spellId = 0; + uint32_t iconId = 0; + auto git = glyphProperties_.find(glyphId); + if (git != glyphProperties_.end()) { + spellId = git->second.spellId; + auto iit = spellIconIds.find(spellId); + if (iit != spellIconIds.end()) iconId = iit->second; + } + + // Icon (24x24) + VkDescriptorSet icon = getSpellIcon(iconId, assetManager); + if (icon != VK_NULL_HANDLE) { + ImGui::Image((ImTextureID)(uintptr_t)icon, ImVec2(24, 24)); + ImGui::SameLine(0, 6); + } else { + ImGui::Dummy(ImVec2(24, 24)); + ImGui::SameLine(0, 6); + } + + // Spell name + const std::string& name = spellId ? gameHandler.getSpellName(spellId) : ""; + if (!name.empty()) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), "%s", name.c_str()); + } else { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "Glyph #%u", (uint32_t)glyphId); + } + }; + + for (auto& [idx, major] : majorSlots) renderGlyphSlot(idx); + + ImGui::Spacing(); + ImGui::TextColored(ImVec4(0.6f, 0.8f, 1.0f, 1.0f), "Minor Glyphs"); + ImGui::Separator(); + for (auto& [idx, major] : minorSlots) renderGlyphSlot(idx); +} + VkDescriptorSet TalentScreen::getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager) { if (iconId == 0 || !assetManager) return VK_NULL_HANDLE; auto cit = spellIconCache.find(iconId); if (cit != spellIconCache.end()) return cit->second; + // Rate-limit texture uploads to avoid multi-hundred-ms stalls when switching + // to a tab whose icons are not yet cached (each upload is a blocking GPU op). + // Allow at most 4 new icon loads per frame; the rest show a blank icon and + // load on the next frame, spreading the cost across ~5 frames. + static int loadsThisFrame = 0; + static int lastImGuiFrame = -1; + int curFrame = ImGui::GetFrameCount(); + if (curFrame != lastImGuiFrame) { loadsThisFrame = 0; lastImGuiFrame = curFrame; } + if (loadsThisFrame >= 4) return VK_NULL_HANDLE; // defer, don't cache null + ++loadsThisFrame; + auto pit = spellIconPaths.find(iconId); if (pit == spellIconPaths.end()) { spellIconCache[iconId] = VK_NULL_HANDLE; diff --git a/tools/diff_classic_turtle_opcodes.py b/tools/diff_classic_turtle_opcodes.py new file mode 100644 index 00000000..0e548b47 --- /dev/null +++ b/tools/diff_classic_turtle_opcodes.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +""" +Report the semantic opcode diff between the Classic and Turtle expansion maps. + +The report normalizes: +- hex formatting differences (0x67 vs 0x067) +- alias names that collapse to the same canonical opcode + +It highlights: +- true wire differences for the same canonical opcode +- canonical opcodes present only in Classic or only in Turtle +- name-only differences where the wire matches after aliasing +""" + +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Tuple + +from opcode_map_utils import load_opcode_map + + +RE_OPCODE_NAME = re.compile(r"^(?:CMSG|SMSG|MSG)_[A-Z0-9_]+$") + + +def read_aliases(path: Path) -> Dict[str, str]: + data = json.loads(path.read_text()) + aliases = data.get("aliases", {}) + out: Dict[str, str] = {} + for key, value in aliases.items(): + if isinstance(key, str) and isinstance(value, str): + out[key] = value + return out + + +def canonicalize(name: str, aliases: Dict[str, str]) -> str: + seen = set() + current = name + while current in aliases and current not in seen: + seen.add(current) + current = aliases[current] + return current + + +def load_map(path: Path) -> Dict[str, int]: + data = load_opcode_map(path) + out: Dict[str, int] = {} + for key, value in data.items(): + if not isinstance(key, str) or not RE_OPCODE_NAME.match(key): + continue + if not isinstance(value, str) or not value.lower().startswith("0x"): + continue + out[key] = int(value, 16) + return out + + +@dataclass(frozen=True) +class CanonicalEntry: + canonical_name: str + raw_value: int + raw_names: Tuple[str, ...] + + +def build_canonical_entries( + raw_map: Dict[str, int], aliases: Dict[str, str] +) -> Dict[str, CanonicalEntry]: + grouped: Dict[str, List[Tuple[str, int]]] = {} + for raw_name, raw_value in raw_map.items(): + canonical_name = canonicalize(raw_name, aliases) + grouped.setdefault(canonical_name, []).append((raw_name, raw_value)) + + out: Dict[str, CanonicalEntry] = {} + for canonical_name, entries in grouped.items(): + raw_values = {raw_value for _, raw_value in entries} + if len(raw_values) != 1: + formatted = ", ".join( + f"{name}=0x{raw_value:03X}" for name, raw_value in sorted(entries) + ) + raise ValueError( + f"Expansion map contains multiple wires for canonical opcode " + f"{canonical_name}: {formatted}" + ) + raw_value = next(iter(raw_values)) + raw_names = tuple(sorted(name for name, _ in entries)) + out[canonical_name] = CanonicalEntry(canonical_name, raw_value, raw_names) + return out + + +def format_hex(raw_value: int) -> str: + return f"0x{raw_value:03X}" + + +def emit_section(title: str, rows: Iterable[str], limit: int | None) -> None: + rows = list(rows) + print(f"{title}: {len(rows)}") + if not rows: + return + shown = rows if limit is None else rows[:limit] + for row in shown: + print(f" {row}") + if limit is not None and len(rows) > limit: + print(f" ... {len(rows) - limit} more") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".") + parser.add_argument( + "--limit", + type=int, + default=80, + help="Maximum rows to print per section; use -1 for no limit.", + ) + args = parser.parse_args() + + root = Path(args.root).resolve() + aliases = read_aliases(root / "Data/opcodes/aliases.json") + classic_raw = load_map(root / "Data/expansions/classic/opcodes.json") + turtle_raw = load_map(root / "Data/expansions/turtle/opcodes.json") + + classic = build_canonical_entries(classic_raw, aliases) + turtle = build_canonical_entries(turtle_raw, aliases) + + classic_names = set(classic) + turtle_names = set(turtle) + shared_names = classic_names & turtle_names + + different_wire = [] + same_wire_name_only = [] + for canonical_name in sorted(shared_names): + c = classic[canonical_name] + t = turtle[canonical_name] + if c.raw_value != t.raw_value: + different_wire.append( + f"{canonical_name}: classic={format_hex(c.raw_value)} " + f"turtle={format_hex(t.raw_value)}" + ) + elif c.raw_names != t.raw_names: + same_wire_name_only.append( + f"{canonical_name}: wire={format_hex(c.raw_value)} " + f"classic_names={list(c.raw_names)} turtle_names={list(t.raw_names)}" + ) + + classic_only = [ + f"{name}: {format_hex(classic[name].raw_value)} names={list(classic[name].raw_names)}" + for name in sorted(classic_names - turtle_names) + ] + turtle_only = [ + f"{name}: {format_hex(turtle[name].raw_value)} names={list(turtle[name].raw_names)}" + for name in sorted(turtle_names - classic_names) + ] + + limit = None if args.limit < 0 else args.limit + + print(f"classic canonical entries: {len(classic)}") + print(f"turtle canonical entries: {len(turtle)}") + print(f"shared canonical entries: {len(shared_names)}") + print() + emit_section("Different wire", different_wire, limit) + print() + emit_section("Classic only", classic_only, limit) + print() + emit_section("Turtle only", turtle_only, limit) + print() + emit_section("Same wire, name-only differences", same_wire_name_only, limit) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/opcode_map_utils.py b/tools/opcode_map_utils.py new file mode 100644 index 00000000..c3566057 --- /dev/null +++ b/tools/opcode_map_utils.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Dict, Set + + +RE_OPCODE_NAME = re.compile(r"^(?:CMSG|SMSG|MSG)_[A-Z0-9_]+$") + + +def load_opcode_map(path: Path, _seen: Set[Path] | None = None) -> Dict[str, str]: + if _seen is None: + _seen = set() + + path = path.resolve() + if path in _seen: + chain = " -> ".join(str(p) for p in list(_seen) + [path]) + raise ValueError(f"Opcode map inheritance cycle: {chain}") + _seen.add(path) + + data = json.loads(path.read_text()) + merged: Dict[str, str] = {} + + extends = data.get("_extends") + if isinstance(extends, str) and extends: + merged.update(load_opcode_map(path.parent / extends, _seen)) + + remove = data.get("_remove", []) + if isinstance(remove, list): + for name in remove: + if isinstance(name, str): + merged.pop(name, None) + + for key, value in data.items(): + if not isinstance(key, str) or not RE_OPCODE_NAME.match(key): + continue + if isinstance(value, str): + merged[key] = value + elif isinstance(value, int): + merged[key] = str(value) + + _seen.remove(path) + return merged diff --git a/tools/validate_opcode_maps.py b/tools/validate_opcode_maps.py index a562439b..7acb62ed 100644 --- a/tools/validate_opcode_maps.py +++ b/tools/validate_opcode_maps.py @@ -17,6 +17,8 @@ import re from pathlib import Path from typing import Dict, Iterable, List, Set +from opcode_map_utils import load_opcode_map + RE_OPCODE_NAME = re.compile(r"^(?:CMSG|SMSG|MSG)_[A-Z0-9_]+$") RE_CODE_REF = re.compile(r"\bOpcode::((?:CMSG|SMSG|MSG)_[A-Z0-9_]+)\b") @@ -53,12 +55,8 @@ def iter_expansion_files(expansions_dir: Path) -> Iterable[Path]: def load_expansion_names(path: Path) -> Dict[str, str]: - data = json.loads(path.read_text()) - out: Dict[str, str] = {} - for k, v in data.items(): - if RE_OPCODE_NAME.match(k): - out[k] = str(v) - return out + data = load_opcode_map(path) + return {k: str(v) for k, v in data.items() if RE_OPCODE_NAME.match(k)} def collect_code_refs(root: Path) -> Set[str]: