Compare commits

...

40 commits

Author SHA1 Message Date
Kelsi
43ebae217c fix: align turtle world packet parsing
Some checks are pending
Build / Build (arm64) (push) Waiting to run
Build / Build (x86-64) (push) Waiting to run
Build / Build (macOS arm64) (push) Waiting to run
Build / Build (windows-arm64) (push) Waiting to run
Build / Build (windows-x86-64) (push) Waiting to run
Security / CodeQL (C/C++) (push) Waiting to run
Security / Semgrep (push) Waiting to run
Security / Sanitizer Build (ASan/UBSan) (push) Waiting to run
2026-03-15 03:40:58 -07:00
Kelsi
6ede9a2968 refactor: derive turtle opcodes from classic 2026-03-15 02:55:05 -07:00
Kelsi
0b6265bc55 fix: align turtle protocol compatibility 2026-03-15 01:47:36 -07:00
Kelsi
b0fafe5efa fix: stabilize turtle world entry session handling 2026-03-15 01:21:23 -07:00
Kelsi
4dba20b757 fix: avoid unsigned subtraction checks in packet bounds 2026-03-14 22:27:42 -07:00
Kelsi
eea3784976 fix: harden turtle movement parsing and warden fallback 2026-03-14 22:18:28 -07:00
Kelsi
f44ef7b9ea fix: optimize turtle monster move wrapped parsing 2026-03-14 22:01:26 -07:00
Kelsi
bce1f4d211 fix: reject malformed monster move payloads 2026-03-14 21:52:03 -07:00
Kelsi
f57893a459 fix(combatlog): reject truncated spell damage log tails 2026-03-14 21:52:03 -07:00
Kelsi
f07b730473 fix(combatlog): reject truncated resist logs 2026-03-14 21:52:03 -07:00
Kelsi
5c8a2afa35 fix(combatlog): accept extended TBC spell damage payloads 2026-03-14 21:52:03 -07:00
Kelsi
385ac1e66c fix(combatlog): reject truncated instakill logs without spell id 2026-03-14 21:52:03 -07:00
Kelsi
83a368aa85 fix(combatlog): reject spell start packets missing target flags 2026-03-14 21:52:03 -07:00
Kelsi
f4ecef2ec5 fix(combatlog): reject truncated classic attacker-state packets 2026-03-14 21:52:03 -07:00
Kelsi
4d4e5ed3b9 fix(combatlog): enforce TBC attacker-state packet bounds 2026-03-14 21:52:03 -07:00
Kelsi
71e34e41b7 fix(combatlog): clamp attacker-state subdamage count to payload 2026-03-14 21:52:03 -07:00
Kelsi
90bc9118f9 fix(combatlog): validate packed GUID bounds in spell energize log 2026-03-14 21:52:03 -07:00
Kelsi
80d59a80aa fix(combatlog): relax packed GUID minimum-size gates 2026-03-14 21:52:03 -07:00
Kelsi
c9467778dc fix(combatlog): enforce TBC spell damage/heal packet bounds 2026-03-14 21:52:03 -07:00
Kelsi
6ccfdc9d11 fix(combatlog): validate packed GUID bounds in spell damage/heal logs 2026-03-14 21:52:03 -07:00
Kelsi
24a63beb3c fix(combatlog): reject truncated spell start target GUIDs 2026-03-14 21:52:03 -07:00
Kelsi
bcfdcce062 fix(combatlog): reject truncated spell go packets missing counts 2026-03-14 21:52:03 -07:00
Kelsi
b24da8463c fix(combatlog): avoid partial spell miss log entries on truncation 2026-03-14 21:52:03 -07:00
Kelsi
f0ba85fa80 fix(combatlog): reset spell go parser output before decode 2026-03-14 21:52:03 -07:00
Kelsi
6b290009aa fix(combatlog): fail classic and tbc spell go parse on truncation 2026-03-14 21:52:03 -07:00
Kelsi
e0ac81450d fix(combatlog): enforce full spell start fixed-field bounds 2026-03-14 21:52:03 -07:00
Kelsi
918762501f fix(combatlog): fail spell go parse on truncated target lists 2026-03-14 21:52:03 -07:00
Kelsi
ffa6dda4d9 fix(combatlog): validate packed GUID bounds in attacker state parsers 2026-03-14 21:52:03 -07:00
Kelsi
5a9be91fac fix(combatlog): validate packed guid bounds in spell go parser 2026-03-14 21:52:03 -07:00
Kelsi
4561eb8696 fix(combatlog): validate packed GUID bounds in spell start parser 2026-03-14 21:52:03 -07:00
Kelsi
c9858655f6 fix(combatlog): validate packed guid bounds in classic spell cast parsers 2026-03-14 21:52:03 -07:00
Kelsi
69ff91e9a2 fix(combatlog): validate packed GUID bounds in spell cast parsers 2026-03-14 21:52:03 -07:00
Kelsi
5ecc46623a fix(combatlog): consume full spell go target lists when capped 2026-03-14 21:52:03 -07:00
Kelsi
c90c8fb8cf fix(combatlog): parse full spell miss target lists 2026-03-14 21:52:03 -07:00
Kelsi
a962422b12 fix(combatlog): map alternate immune2 spell miss value 2026-03-14 21:52:03 -07:00
Kelsi
753f4ef1be fix(combatlog): map immune2 spell miss results correctly 2026-03-14 21:52:03 -07:00
Kelsi
5911b8eb01 fix(combatlog): show resisted amount from resist log packets 2026-03-14 21:52:03 -07:00
Kelsi
5575fc6f28 fix(combatlog): preserve unknown source for environmental entries 2026-03-14 21:52:03 -07:00
Kelsi Rae Davis
ecb56e9a35
Merge pull request #17 from Kelsidavis/maint
Merge branch 'master' into maint
2026-03-14 21:47:07 -07:00
Kelsi
d34a3967f6 Merge branch 'master' into maint 2026-03-14 09:24:19 -07:00
29 changed files with 3630 additions and 2126 deletions

View file

@ -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"
}

View file

@ -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"
]
}

View file

@ -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"
}
}

View file

@ -224,6 +224,7 @@ private:
std::future<PreparedCreatureModel> future;
};
std::vector<AsyncCreatureLoad> asyncCreatureLoads_;
std::unordered_set<uint32_t> 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<uint64_t> deadCreatureGuids_; // GUIDs that should spawn in corpse/death pose
@ -280,7 +281,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<uint64_t, PendingTransportMove> pendingTransportMoves_; // guid -> latest pre-registration move
std::deque<PendingTransportRegistration> pendingTransportRegistrations_;
uint32_t nextGameObjectModelId_ = 20000;
uint32_t nextGameObjectWmoModelId_ = 40000;
bool testTransportSetup_ = false;
@ -433,6 +444,7 @@ private:
};
std::vector<PendingTransportDoodadBatch> pendingTransportDoodadBatches_;
static constexpr size_t MAX_TRANSPORT_DOODADS_PER_FRAME = 4;
void processPendingTransportRegistrations();
void processPendingTransportDoodads();
// Quest marker billboard sprites (above NPCs)

View file

@ -7,6 +7,7 @@
#include "game/inventory.hpp"
#include "game/spell_defines.hpp"
#include "game/group_defines.hpp"
#include "network/packet.hpp"
#include <glm/glm.hpp>
#include <memory>
#include <string>
@ -2089,6 +2090,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<uint64_t>& guids);
void applyUpdateObjectBlock(const UpdateBlock& block, bool& newItemCreated);
void finalizeUpdateObjectBatch(bool newItemCreated);
/**
* Handle SMSG_AUTH_CHALLENGE from server
@ -2413,6 +2423,14 @@ private:
// Network
std::unique_ptr<network::WorldSocket> socket;
std::deque<network::Packet> pendingIncomingPackets_;
struct PendingUpdateObjectWork {
UpdateObjectData data;
size_t nextBlockIndex = 0;
bool outOfRangeProcessed = false;
bool newItemCreated = false;
};
std::deque<PendingUpdateObjectWork> pendingUpdateObjectWork_;
// State
WorldState state = WorldState::DISCONNECTED;
@ -2434,6 +2452,8 @@ 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

View file

@ -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"},

View file

@ -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<uint16_t, uint16_t> logicalToWire_; // LogicalOpcode → wire
std::unordered_map<uint16_t, uint16_t> wireToLogical_; // wire → LogicalOpcode
static std::optional<LogicalOpcode> nameToLogical(const std::string& name);
static const char* logicalToName(LogicalOpcode op);
};
/**

View file

@ -439,18 +439,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;
};

View file

@ -756,38 +756,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,
};
/**

View file

@ -7,7 +7,13 @@
#include "auth/vanilla_crypt.hpp"
#include <functional>
#include <vector>
#include <deque>
#include <cstdint>
#include <chrono>
#include <thread>
#include <mutex>
#include <atomic>
#include <string>
namespace wowee {
namespace network {
@ -66,6 +72,8 @@ public:
*/
void initEncryption(const std::vector<uint8_t>& sessionKey, uint32_t build = 12340);
void tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason);
/**
* Check if header encryption is enabled
*/
@ -76,11 +84,23 @@ private:
* Try to parse complete packets from receive buffer
*/
void tryParsePackets();
void pumpNetworkIO();
void dispatchQueuedPackets();
void asyncPumpLoop();
void startAsyncPump();
void stopAsyncPump();
void closeSocketNoJoin();
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<bool> asyncPumpStop_{false};
std::atomic<bool> asyncPumpRunning_{false};
mutable std::mutex ioMutex_;
mutable std::mutex callbackMutex_;
// WotLK RC4 ciphers for header encryption/decryption
auth::RC4 encryptCipher;
@ -94,6 +114,8 @@ private:
size_t receiveReadOffset_ = 0;
// Optional reused packet queue (feature-gated) to reduce per-update allocations.
std::vector<Packet> parsedPacketsScratch_;
// Parsed packets waiting for callback dispatch; drained with a strict per-update budget.
std::deque<Packet> pendingPacketCallbacks_;
// Runtime-gated network optimization toggles (default off).
bool useFastRecvAppend_ = false;
@ -105,6 +127,9 @@ 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_;
// Packet callback
std::function<void(const Packet&)> packetCallback;

View file

@ -296,7 +296,9 @@ private:
std::unordered_map<VkTexture*, bool> textureColorKeyBlackByPtr_;
std::unordered_map<std::string, VkTexture*> compositeCache_; // key → texture for reuse
std::unordered_set<std::string> failedTextureCache_; // negative cache for budget exhaustion
std::unordered_map<std::string, uint64_t> failedTextureRetryAt_;
std::unordered_set<std::string> loggedTextureLoadFails_; // dedup warning logs
uint64_t textureLookupSerial_ = 0;
size_t textureCacheBytes_ = 0;
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 1024ull * 1024 * 1024;

View file

@ -477,7 +477,9 @@ private:
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 2048ull * 1024 * 1024;
std::unordered_set<std::string> failedTextureCache_;
std::unordered_map<std::string, uint64_t> failedTextureRetryAt_;
std::unordered_set<std::string> loggedTextureLoadFails_;
uint64_t textureLookupSerial_ = 0;
uint32_t textureBudgetRejectWarnings_ = 0;
std::unique_ptr<VkTexture> whiteTexture_;
std::unique_ptr<VkTexture> glowTexture_;

View file

@ -671,7 +671,9 @@ private:
uint64_t textureCacheCounter_ = 0;
size_t textureCacheBudgetBytes_ = 8192ull * 1024 * 1024; // 8 GB default, overridden at init
std::unordered_set<std::string> failedTextureCache_;
std::unordered_map<std::string, uint64_t> failedTextureRetryAt_;
std::unordered_set<std::string> loggedTextureLoadFails_;
uint64_t textureLookupSerial_ = 0;
uint32_t textureBudgetRejectWarnings_ = 0;
// Default white texture

View file

@ -824,6 +824,7 @@ void Application::logoutToLogin() {
if (load.future.valid()) load.future.wait();
}
asyncCreatureLoads_.clear();
asyncCreatureDisplayLoads_.clear();
// --- Creature spawn queues ---
pendingCreatureSpawns_.clear();
@ -842,6 +843,7 @@ void Application::logoutToLogin() {
gameObjectInstances_.clear();
pendingGameObjectSpawns_.clear();
pendingTransportMoves_.clear();
pendingTransportRegistrations_.clear();
pendingTransportDoodadBatches_.clear();
world.reset();
@ -1053,6 +1055,7 @@ void Application::update(float deltaTime) {
updateCheckpoint = "in_game: gameobject/transport queues";
runInGameStage("gameobject/transport queues", [&] {
processGameObjectSpawnQueue();
processPendingTransportRegistrations();
processPendingTransportDoodads();
});
inGameStep = "pending mount";
@ -1725,6 +1728,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) {
@ -2025,24 +2041,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<float> {
@ -2712,133 +2723,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<glm::vec3> 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<glm::vec3> 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});
}
});
@ -2853,6 +2759,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,
@ -4155,6 +4070,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float
deferredEquipmentQueue_.clear();
pendingGameObjectSpawns_.clear();
pendingTransportMoves_.clear();
pendingTransportRegistrations_.clear();
pendingTransportDoodadBatches_.clear();
if (renderer) {
@ -4210,6 +4126,7 @@ 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();
@ -4866,25 +4783,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, s.scale);
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();
@ -7437,12 +7352,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<float, std::milli>(
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;
@ -7451,12 +7377,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);
@ -7471,6 +7398,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) {
@ -7478,6 +7426,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();
@ -7504,8 +7456,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);
@ -7659,6 +7609,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<int>(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) {
// Too many in-flight — defer to next frame
@ -7904,6 +7862,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();
@ -8304,6 +8263,151 @@ void Application::processGameObjectSpawnQueue() {
}
}
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<float, std::milli>(
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<glm::vec3> 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<glm::vec3> 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;

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,9 @@
#include <sstream>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <string_view>
#include <unordered_set>
namespace wowee {
namespace game {
@ -47,6 +49,155 @@ static std::string_view canonicalOpcodeName(std::string_view name) {
return name;
}
static std::optional<uint16_t> 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<uint16_t>(kOpcodeNames[i].op);
}
}
return std::nullopt;
}
static std::optional<std::string> 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<std::string> parseStringArrayField(const std::string& json, const char* fieldName) {
std::vector<std::string> 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<uint16_t, uint16_t>& logicalToWire,
std::unordered_map<uint16_t, uint16_t>& wireToLogical,
std::unordered_set<std::string>& 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<char>(f)), std::istreambuf_iterator<char>());
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<uint16_t>(std::stoul(valStr, nullptr, 16));
} else {
wire = static_cast<uint16_t>(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<LogicalOpcode> 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<char>(f)), std::istreambuf_iterator<char>());
// 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<uint16_t>(std::stoul(valStr, nullptr, 16));
} else {
wire = static_cast<uint16_t>(std::stoul(valStr));
}
} catch (...) {
pos = valEnd + 1;
continue;
}
auto logOp = nameToLogical(key);
if (logOp) {
uint16_t logIdx = static_cast<uint16_t>(*logOp);
logicalToWire_[logIdx] = wire;
wireToLogical_[wire] = logIdx;
++loaded;
}
pos = valEnd + 1;
}
if (loaded == 0) {
std::unordered_set<std::string> 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;
}

View file

@ -22,6 +22,18 @@ bool hasFullPackedGuid(const network::Packet& packet) {
return packet.getSize() - packet.getReadPos() >= guidBytes;
}
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
// ============================================================================
@ -63,12 +75,12 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo
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) {
// Movement flags (u32 only — NO extra flags byte in Classic)
@ -183,26 +195,26 @@ 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) {
/*uint32_t highGuid =*/ packet.readUInt32();
}
// ALL/SELF extra uint32
if (updateFlags & UPDATEFLAG_ALL) {
/*uint32_t unkAll =*/ packet.readUInt32();
}
// Current melee target as packed guid
if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) {
/*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
// Transport progress / world time
if (updateFlags & UPDATEFLAG_TRANSPORT) {
/*uint32_t transportTime =*/ packet.readUInt32();
}
return true;
}
@ -355,11 +367,21 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo
// + 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
@ -370,10 +392,18 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa
data.castTime = packet.readUInt32();
// SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK)
if (rem() < 2) return true;
if (rem() < 2) {
LOG_WARNING("[Classic] Spell start: missing targetFlags");
packet.setReadPos(startPos);
return false;
}
uint16_t targetFlags = packet.readUInt16();
// TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID
if (((targetFlags & 0x02) || (targetFlags & 0x800)) && rem() >= 1) {
if ((targetFlags & 0x02) || (targetFlags & 0x800)) {
if (!hasFullPackedGuid(packet)) {
packet.setReadPos(startPos);
return false;
}
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
}
@ -395,11 +425,16 @@ bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartDa
// + 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();
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
@ -408,52 +443,107 @@ bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& da
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();
// Cap hit count to prevent OOM from huge target lists
if (data.hitCount > 128) {
LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
data.hitCount = 128;
// hitCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation.
if (rem() < 1) {
LOG_WARNING("[Classic] Spell go: missing hitCount after fixed fields");
packet.setReadPos(startPos);
return false;
}
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) {
data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet));
const uint8_t rawHitCount = packet.readUInt8();
if (rawHitCount > 128) {
LOG_WARNING("[Classic] Spell go: hitCount capped (requested=", (int)rawHitCount, ")");
}
// Check if we read all expected hits
if (data.hitTargets.size() < data.hitCount) {
LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(),
"/", (int)data.hitCount);
data.hitCount = data.hitTargets.size();
// Packed GUIDs are variable length, but each target needs at least 1 byte (mask).
// Require the minimum bytes before entering per-target parsing loops.
if (rem() < static_cast<size_t>(rawHitCount) + 1u) { // +1 for mandatory missCount byte
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, ")");
}
packet.setReadPos(startPos);
return false;
}
const uint8_t storedHitLimit = std::min<uint8_t>(rawHitCount, 128);
data.hitTargets.reserve(storedHitLimit);
bool truncatedTargets = false;
for (uint16_t i = 0; i < rawHitCount; ++i) {
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("[Classic] Spell go: truncated hit targets at index ", i,
"/", (int)rawHitCount);
truncatedTargets = true;
break;
}
const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet);
if (i < storedHitLimit) {
data.hitTargets.push_back(targetGuid);
}
}
if (truncatedTargets) {
packet.setReadPos(startPos);
return false;
}
data.hitCount = static_cast<uint8_t>(data.hitTargets.size());
// Miss targets
if (rem() < 1) return true;
data.missCount = packet.readUInt8();
// Cap miss count to prevent OOM
if (data.missCount > 128) {
LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)data.missCount, ")");
data.missCount = 128;
// missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation.
if (rem() < 1) {
LOG_WARNING("[Classic] Spell go: missing missCount after hit target list");
packet.setReadPos(startPos);
return false;
}
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) {
const uint8_t rawMissCount = packet.readUInt8();
if (rawMissCount > 128) {
LOG_WARNING("[Classic] Spell go: missCount capped (requested=", (int)rawMissCount, ")");
}
// Each miss entry needs at least packed-guid mask (1) + missType (1).
if (rem() < static_cast<size_t>(rawMissCount) * 2u) {
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, ")");
}
packet.setReadPos(startPos);
return false;
}
const uint8_t storedMissLimit = std::min<uint8_t>(rawMissCount, 128);
data.missTargets.reserve(storedMissLimit);
for (uint16_t i = 0; i < rawMissCount; ++i) {
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", i,
"/", (int)rawMissCount);
truncatedTargets = true;
break;
}
SpellGoMissEntry m;
m.targetGuid = UpdateObjectParser::readPackedGuid(packet);
if (rem() < 1) break;
if (rem() < 1) {
LOG_WARNING("[Classic] Spell go: missing missType at miss index ", i,
"/", (int)rawMissCount);
truncatedTargets = true;
break;
}
m.missType = packet.readUInt8();
if (m.missType == 11) {
if (rem() < 5) break;
if (rem() < 5) {
LOG_WARNING("[Classic] Spell go: truncated reflect payload at miss index ", i,
"/", (int)rawMissCount);
truncatedTargets = true;
break;
}
(void)packet.readUInt32();
(void)packet.readUInt8();
}
data.missTargets.push_back(m);
if (i < storedMissLimit) {
data.missTargets.push_back(m);
}
}
// Check if we read all expected misses
if (data.missTargets.size() < data.missCount) {
LOG_WARNING("[Classic] Spell go: truncated miss targets at index ", (int)data.missTargets.size(),
"/", (int)data.missCount);
data.missCount = data.missTargets.size();
if (truncatedTargets) {
packet.setReadPos(startPos);
return false;
}
data.missCount = static_cast<uint8_t>(data.missTargets.size());
LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
@ -471,19 +561,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<int32_t>(packet.readUInt32());
data.subDamageCount = packet.readUInt8();
for (uint8_t i = 0; i < data.subDamageCount && rem() >= 20; ++i) {
const uint8_t maxSubDamageCount = static_cast<uint8_t>(std::min<size_t>(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();
@ -492,8 +605,12 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att
sub.resisted = packet.readUInt32();
data.subDamages.push_back(sub);
}
data.subDamageCount = static_cast<uint8_t>(data.subDamages.size());
if (rem() < 8) return true;
if (rem() < 8) {
packet.setReadPos(startPos);
return false;
}
data.victimState = packet.readUInt32();
data.overkill = static_cast<int32_t>(packet.readUInt32());
@ -1585,12 +1702,12 @@ bool TurtlePacketParsers::parseMovementBlock(network::Packet& packet, UpdateBloc
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) {
size_t livingStart = packet.getReadPos();
@ -1705,37 +1822,261 @@ 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) {
/*uint32_t highGuid =*/ packet.readUInt32();
}
if (updateFlags & UPDATEFLAG_ALL) {
/*uint32_t unkAll =*/ packet.readUInt32();
}
if (updateFlags & UPDATEFLAG_MELEE_ATTACKING) {
/*uint64_t meleeTargetGuid =*/ UpdateObjectParser::readPackedGuid(packet);
}
if (updateFlags & UPDATEFLAG_TRANSPORT) {
/*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<uint8_t>(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<uint8_t>(UpdateType::NEAR_OBJECTS)) {
packet.setReadPos(start);
return false;
}
const UpdateType updateType = static_cast<UpdateType>(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<ObjectType>(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");
}
if (!ok) {
ok = parseMovementVariant(
[](network::Packet& p, UpdateBlock& b) {
return UpdateObjectParser::parseMovementBlock(p, b);
}, "wotlk");
}
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");

View file

@ -425,9 +425,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<uint8_t>(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;
@ -450,6 +457,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()) {
@ -473,7 +481,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;
}
@ -1234,6 +1242,8 @@ bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector<MailMe
// Correct TBC format (cmangos-tbc): objectGuid(u64) + casterGuid(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + castTime(u32)
// ============================================================================
bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) {
data = SpellStartData{};
const size_t startPos = packet.getReadPos();
if (packet.getSize() - packet.getReadPos() < 22) return false;
data.casterGuid = packet.readUInt64(); // full GUID (object)
@ -1243,11 +1253,20 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData&
data.castFlags = packet.readUInt32();
data.castTime = packet.readUInt32();
if (packet.getReadPos() + 4 <= packet.getSize()) {
uint32_t targetFlags = packet.readUInt32();
if ((targetFlags & 0x02) && packet.getReadPos() + 8 <= packet.getSize()) {
data.targetGuid = packet.readUInt64(); // full GUID in TBC
if (packet.getReadPos() + 4 > packet.getSize()) {
LOG_WARNING("[TBC] Spell start: missing targetFlags");
packet.setReadPos(startPos);
return false;
}
uint32_t targetFlags = packet.readUInt32();
const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT
if (needsTargetGuid) {
if (packet.getReadPos() + 8 > packet.getSize()) {
packet.setReadPos(startPos);
return false;
}
data.targetGuid = packet.readUInt64(); // full GUID in TBC
}
LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
@ -1261,6 +1280,10 @@ bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData&
// WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags.
// ============================================================================
bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) {
// Always reset output to avoid stale targets when callers reuse buffers.
data = SpellGoData{};
const size_t startPos = packet.getReadPos();
// Fixed header before hit/miss lists:
// casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32)
if (packet.getSize() - packet.getReadPos() < 25) return false;
@ -1273,55 +1296,77 @@ bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data)
// NOTE: NO timestamp field here in TBC (WotLK added packet.readUInt32())
if (packet.getReadPos() >= 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();
// Cap hit count to prevent OOM from huge target lists
if (data.hitCount > 128) {
LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
data.hitCount = 128;
const uint8_t rawHitCount = packet.readUInt8();
if (rawHitCount > 128) {
LOG_WARNING("[TBC] Spell go: hitCount capped (requested=", (int)rawHitCount, ")");
}
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
}
// Check if we read all expected hits
if (data.hitTargets.size() < data.hitCount) {
LOG_WARNING("[TBC] Spell go: truncated hit targets at index ", (int)data.hitTargets.size(),
"/", (int)data.hitCount);
data.hitCount = data.hitTargets.size();
}
if (packet.getReadPos() < packet.getSize()) {
data.missCount = packet.readUInt8();
// Cap miss count to prevent OOM
if (data.missCount > 128) {
LOG_WARNING("[TBC] Spell go: missCount capped (requested=", (int)data.missCount, ")");
data.missCount = 128;
const uint8_t storedHitLimit = std::min<uint8_t>(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;
}
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();
if (m.missType == 11) {
if (packet.getReadPos() + 5 > packet.getSize()) {
break;
}
(void)packet.readUInt32();
(void)packet.readUInt8();
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<uint8_t>(data.hitTargets.size());
if (packet.getReadPos() >= packet.getSize()) {
LOG_WARNING("[TBC] Spell go: missing missCount after hit target list");
packet.setReadPos(startPos);
return false;
}
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<uint8_t>(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) {
if (packet.getReadPos() + 5 > packet.getSize()) {
LOG_WARNING("[TBC] Spell go: truncated reflect payload at miss index ", i,
"/", (int)rawMissCount);
truncatedTargets = true;
break;
}
(void)packet.readUInt32();
(void)packet.readUInt8();
}
if (i < storedMissLimit) {
data.missTargets.push_back(m);
}
// Check if we read all expected misses
if (data.missTargets.size() < data.missCount) {
LOG_WARNING("[TBC] Spell go: truncated miss targets at index ", (int)data.missTargets.size(),
"/", (int)data.missCount);
data.missCount = data.missTargets.size();
}
}
if (truncatedTargets) {
packet.setReadPos(startPos);
return false;
}
data.missCount = static_cast<uint8_t>(data.missTargets.size());
LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);
@ -1369,15 +1414,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<int32_t>(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<int32_t>(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<uint8_t>(std::min<size_t>(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();
@ -1387,10 +1450,17 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke
data.subDamages.push_back(sub);
}
data.subDamageCount = static_cast<uint8_t>(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<int32_t>(packet.readUInt32());
if (packet.getReadPos() < packet.getSize()) {
if (rem() >= 4) {
data.blocked = packet.readUInt32();
}
@ -1406,20 +1476,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;
@ -1437,13 +1515,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();

View file

@ -272,9 +272,28 @@ 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<uint64_t>(offset) + length > imageSize_) return false;
std::memcpy(outBuf, image_.data() + offset, length);

View file

@ -59,15 +59,14 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
// Step 1: Verify MD5 hash
if (!verifyMD5(moduleData, md5Hash)) {
std::cerr << "[WardenModule] MD5 verification failed!" << '\n';
return false;
std::cerr << "[WardenModule] MD5 verification failed; continuing in compatibility mode" << '\n';
}
std::cout << "[WardenModule] ✓ MD5 verified" << '\n';
// 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;
std::cerr << "[WardenModule] RC4 decryption failed; using raw module bytes fallback" << '\n';
decryptedData_ = moduleData;
}
std::cout << "[WardenModule] ✓ RC4 decrypted (" << decryptedData_.size() << " bytes)" << '\n';
@ -85,20 +84,18 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
dataWithoutSig = decryptedData_;
}
if (!decompressZlib(dataWithoutSig, decompressedData_)) {
std::cerr << "[WardenModule] zlib decompression failed!" << '\n';
return false;
std::cerr << "[WardenModule] zlib decompression failed; using decrypted bytes fallback" << '\n';
decompressedData_ = decryptedData_;
}
// Step 5: Parse custom executable format
if (!parseExecutableFormat(decompressedData_)) {
std::cerr << "[WardenModule] Executable format parsing failed!" << '\n';
return false;
std::cerr << "[WardenModule] Executable format parsing failed; continuing with minimal module image" << '\n';
}
// Step 6: Apply relocations
if (!applyRelocations()) {
std::cerr << "[WardenModule] Address relocations failed!" << '\n';
return false;
std::cerr << "[WardenModule] Address relocations failed; continuing with unrelocated image" << '\n';
}
// Step 7: Bind APIs
@ -109,8 +106,7 @@ bool WardenModule::load(const std::vector<uint8_t>& moduleData,
// Step 8: Initialize module
if (!initializeModule()) {
std::cerr << "[WardenModule] Module initialization failed!" << '\n';
return false;
std::cerr << "[WardenModule] Module initialization failed; continuing with stub callbacks" << '\n';
}
// Module loading pipeline complete!

View file

@ -19,6 +19,35 @@ namespace {
inline uint16_t bswap16(uint16_t v) {
return static_cast<uint16_t>(((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 {
@ -1209,7 +1238,13 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
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);
LOG_WARNING("UpdateObjectParser: truncated update mask at block ", i,
" type=", updateTypeName(block.updateType),
" objectType=", static_cast<int>(block.objectType),
" guid=0x", std::hex, block.guid, std::dec,
" readPos=", packet.getReadPos(),
" size=", packet.getSize(),
" maskBlockCount=", static_cast<int>(blockCount));
return false;
}
updateMask[i] = packet.readUInt32();
@ -1238,7 +1273,14 @@ bool UpdateObjectParser::parseUpdateFields(network::Packet& packet, UpdateBlock&
}
// Validate 4 bytes available before reading field value
if (packet.getReadPos() + 4 > packet.getSize()) {
LOG_WARNING("UpdateObjectParser: truncated field value at field ", fieldIndex);
LOG_WARNING("UpdateObjectParser: truncated field value at field ", fieldIndex,
" type=", updateTypeName(block.updateType),
" objectType=", static_cast<int>(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();
@ -1282,7 +1324,7 @@ bool UpdateObjectParser::parseUpdateBlock(network::Packet& packet, UpdateBlock&
case UpdateType::MOVEMENT: {
// Movement update
block.guid = readPackedGuid(packet);
block.guid = packet.readUInt64();
LOG_DEBUG(" MOVEMENT update for GUID: 0x", std::hex, block.guid, std::dec);
return parseMovementBlock(packet, block);
@ -1328,8 +1370,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();
@ -1343,11 +1387,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<uint8_t>(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) {
@ -1371,6 +1422,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) {
@ -3156,7 +3208,7 @@ bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) {
if (pointCount == 0) return true;
// Cap pointCount to prevent excessive iteration from malformed packets
// Cap pointCount to prevent excessive iteration from malformed packets.
constexpr uint32_t kMaxSplinePoints = 1000;
if (pointCount > kMaxSplinePoints) {
LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints,
@ -3266,16 +3318,19 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d
if (pointCount == 0) return true;
// Cap pointCount to prevent excessive iteration from malformed packets
// Reject extreme point counts from malformed packets.
constexpr uint32_t kMaxSplinePoints = 1000;
if (pointCount > kMaxSplinePoints) {
LOG_WARNING("SMSG_MONSTER_MOVE(Vanilla): pointCount=", pointCount, " exceeds max ", kMaxSplinePoints,
" (guid=0x", std::hex, data.guid, std::dec, "), capping");
pointCount = kMaxSplinePoints;
return false;
}
size_t requiredBytes = 12;
if (pointCount > 1) {
requiredBytes += static_cast<size_t>(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();
@ -3285,9 +3340,8 @@ bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& d
if (pointCount > 1) {
size_t skipBytes = static_cast<size_t>(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,
@ -3327,7 +3381,15 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
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)
@ -3339,15 +3401,15 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda
data.totalDamage = static_cast<int32_t>(packet.readUInt32());
data.subDamageCount = packet.readUInt8();
// Cap subDamageCount: each entry is 20 bytes. If the claimed count
// 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 silently clamp to the number of full entries that fit.
// 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<uint8_t>(maxFit > 0 ? 1 : 0);
data.subDamageCount = static_cast<uint8_t>(std::min<size_t>(maxFit, 64));
} else if (data.subDamageCount > 64) {
data.subDamageCount = 64;
}
@ -3399,11 +3461,22 @@ 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) = 30 bytes minimum
if (packet.getSize() - packet.getReadPos() < 30) return false;
// 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)
@ -3419,11 +3492,11 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da
data.absorbed = packet.readUInt32();
data.resisted = packet.readUInt32();
// Skip remaining fields (periodicLog + unused + blocked + flags = 10 bytes)
// 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) {
LOG_WARNING("SpellDamageLog: truncated trailing fields");
data.isCrit = false;
return true;
packet.setReadPos(startPos);
return false;
}
uint8_t periodicLog = packet.readUInt8();
@ -3445,7 +3518,15 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data)
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)
@ -3663,15 +3744,25 @@ bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) {
}
bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) {
// Upfront validation: packed GUID(1-8) + packed GUID(1-8) + castCount(1) + spellId(4) + castFlags(4) + castTime(4) = 22 bytes minimum
if (packet.getSize() - packet.getReadPos() < 22) return false;
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 = 9 bytes)
if (packet.getSize() - packet.getReadPos() < 9) {
// Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 13 bytes)
if (packet.getSize() - packet.getReadPos() < 13) {
packet.setReadPos(startPos);
return false;
}
@ -3681,12 +3772,21 @@ bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) {
data.castFlags = packet.readUInt32();
data.castTime = packet.readUInt32();
// Read target flags and target (simplified)
if (packet.getSize() - packet.getReadPos() >= 4) {
uint32_t targetFlags = packet.readUInt32();
if ((targetFlags & 0x02) && packet.getSize() - packet.getReadPos() >= 1) { // TARGET_FLAG_UNIT, validate packed GUID read
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;
}
uint32_t targetFlags = packet.readUInt32();
const bool needsTargetGuid = (targetFlags & 0x02) || (targetFlags & 0x800); // UNIT/OBJECT
if (needsTargetGuid) {
if (!hasFullPackedGuid(packet)) {
packet.setReadPos(startPos);
return false;
}
data.targetGuid = UpdateObjectParser::readPackedGuid(packet);
}
LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms");
@ -3694,12 +3794,22 @@ 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 missCount.
if (packet.getSize() - packet.getReadPos() < 17) return false;
// 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
@ -3714,59 +3824,81 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) {
// Timestamp in 3.3.5a
packet.readUInt32();
data.hitCount = packet.readUInt8();
// Cap hit count to prevent DoS via massive arrays
if (data.hitCount > 128) {
LOG_WARNING("Spell go: hitCount capped (requested=", (int)data.hitCount, ")");
data.hitCount = 128;
const uint8_t rawHitCount = packet.readUInt8();
if (rawHitCount > 128) {
LOG_WARNING("Spell go: hitCount capped (requested=", (int)rawHitCount, ")");
}
const uint8_t storedHitLimit = std::min<uint8_t>(rawHitCount, 128);
data.hitTargets.reserve(data.hitCount);
for (uint8_t i = 0; i < data.hitCount; ++i) {
bool truncatedTargets = false;
data.hitTargets.reserve(storedHitLimit);
for (uint16_t i = 0; i < rawHitCount; ++i) {
// WotLK hit targets are packed GUIDs, like the caster and miss targets.
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_WARNING("Spell go: truncated hit targets at index ", (int)i, "/", (int)data.hitCount);
data.hitCount = i;
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", (int)rawHitCount);
truncatedTargets = true;
break;
}
data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet));
const uint64_t targetGuid = UpdateObjectParser::readPackedGuid(packet);
if (i < storedHitLimit) {
data.hitTargets.push_back(targetGuid);
}
}
if (truncatedTargets) {
packet.setReadPos(startPos);
return false;
}
data.hitCount = static_cast<uint8_t>(data.hitTargets.size());
// Validate missCount field exists
// missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation.
if (packet.getSize() - packet.getReadPos() < 1) {
return true; // Valid, just no misses
LOG_WARNING("Spell go: missing missCount after hit target list");
packet.setReadPos(startPos);
return false;
}
data.missCount = packet.readUInt8();
// Cap miss count to prevent DoS
if (data.missCount > 128) {
LOG_WARNING("Spell go: missCount capped (requested=", (int)data.missCount, ")");
data.missCount = 128;
const uint8_t rawMissCount = packet.readUInt8();
if (rawMissCount > 128) {
LOG_WARNING("Spell go: missCount capped (requested=", (int)rawMissCount, ")");
}
const uint8_t storedMissLimit = std::min<uint8_t>(rawMissCount, 128);
data.missTargets.reserve(data.missCount);
for (uint8_t i = 0; i < data.missCount; ++i) {
data.missTargets.reserve(storedMissLimit);
for (uint16_t i = 0; i < rawMissCount; ++i) {
// Each miss entry: packed GUID(1-8 bytes) + missType(1 byte).
// REFLECT additionally appends uint32 reflectSpellId + uint8 reflectResult.
if (packet.getSize() - packet.getReadPos() < 2) {
LOG_WARNING("Spell go: truncated miss targets at index ", (int)i, "/", (int)data.missCount);
data.missCount = i;
if (!hasFullPackedGuid(packet)) {
LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", (int)rawMissCount);
truncatedTargets = true;
break;
}
SpellGoMissEntry m;
m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK
m.missType = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0;
if (packet.getSize() - packet.getReadPos() < 1) {
LOG_WARNING("Spell go: missing missType at miss index ", i, "/", (int)rawMissCount);
truncatedTargets = true;
break;
}
m.missType = packet.readUInt8();
if (m.missType == 11) {
if (packet.getSize() - packet.getReadPos() < 5) {
LOG_WARNING("Spell go: truncated reflect payload at miss index ", (int)i, "/", (int)data.missCount);
data.missCount = i;
LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", (int)rawMissCount);
truncatedTargets = true;
break;
}
(void)packet.readUInt32();
(void)packet.readUInt8();
}
data.missTargets.push_back(m);
if (i < storedMissLimit) {
data.missTargets.push_back(m);
}
}
if (truncatedTargets) {
packet.setReadPos(startPos);
return false;
}
data.missCount = static_cast<uint8_t>(data.missTargets.size());
LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount,
" misses=", (int)data.missCount);

View file

@ -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 <iomanip>
@ -9,10 +10,49 @@
#include <fstream>
#include <cstdlib>
#include <cstring>
#include <chrono>
#include <thread>
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;
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<int>(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<int>(parsed);
}();
return budget;
}
inline bool isLoginPipelineSmsg(uint16_t opcode) {
switch (opcode) {
@ -49,6 +89,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 +119,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 +128,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 +141,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 +219,59 @@ 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<std::mutex> lock(ioMutex_);
closeSocketNoJoin();
encryptionEnabled = false;
useVanillaCrypt = false;
receiveBuffer.clear();
receiveReadOffset_ = 0;
parsedPacketsScratch_.clear();
headerBytesDecrypted = 0;
packetTraceStart_ = {};
packetTraceUntil_ = {};
packetTraceReason_.clear();
}
{
std::lock_guard<std::mutex> lock(callbackMutex_);
pendingPacketCallbacks_.clear();
}
LOG_INFO("Disconnected from world server");
}
void WorldSocket::tracePacketsFor(std::chrono::milliseconds duration, const std::string& reason) {
std::lock_guard<std::mutex> lock(ioMutex_);
packetTraceStart_ = std::chrono::steady_clock::now();
packetTraceUntil_ = packetTraceStart_ + duration;
packetTraceReason_ = reason;
LOG_WARNING("WS TRACE enabled: reason='", packetTraceReason_,
"' durationMs=", duration.count());
}
bool WorldSocket::isConnected() const {
std::lock_guard<std::mutex> 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::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<std::mutex> lock(ioMutex_);
if (!connected || sockfd == INVALID_SOCK) return;
const auto& data = packet.getData();
uint16_t opcode = packet.getOpcode();
@ -254,6 +335,17 @@ 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();
if (packetTraceUntil_ > traceNow) {
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
traceNow - packetTraceStart_).count();
LOG_WARNING("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 +409,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<std::mutex> 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<std::mutex> lock(ioMutex_);
if (!connected || sockfd == INVALID_SOCK) return;
auto bufferedBytes = [&]() -> size_t {
return (receiveBuffer.size() >= receiveReadOffset_)
? (receiveBuffer.size() - receiveReadOffset_)
@ -343,7 +474,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 +494,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 +507,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 +519,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 +541,7 @@ void WorldSocket::update() {
}
LOG_ERROR("Receive failed: ", net::errorString(err));
disconnect();
closeSocketNoJoin();
return;
}
@ -434,10 +566,15 @@ 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,
" buffered=", bufferedBytes(), ")");
disconnect();
closeSocketNoJoin();
return;
}
}
@ -462,7 +599,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,7 +629,7 @@ void WorldSocket::tryParsePackets() {
static_cast<int>(rawHeader[2]), " ",
static_cast<int>(rawHeader[3]), std::dec,
" enc=", encryptionEnabled, ". Disconnecting to recover stream.");
disconnect();
closeSocketNoJoin();
return;
}
constexpr uint16_t kMaxWorldPacketSize = 0x4000;
@ -503,7 +641,7 @@ void WorldSocket::tryParsePackets() {
static_cast<int>(rawHeader[2]), " ",
static_cast<int>(rawHeader[3]), std::dec,
" enc=", encryptionEnabled, ". Disconnecting to recover stream.");
disconnect();
closeSocketNoJoin();
return;
}
@ -535,6 +673,16 @@ void WorldSocket::tryParsePackets() {
" buffered=", (receiveBuffer.size() - parseOffset),
" enc=", encryptionEnabled ? "yes" : "no");
}
const auto traceNow = std::chrono::steady_clock::now();
if (packetTraceUntil_ > traceNow) {
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
traceNow - packetTraceStart_).count();
LOG_WARNING("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 +703,7 @@ void WorldSocket::tryParsePackets() {
" payload=", payloadLen, " buffered=", receiveBuffer.size(),
" parseOffset=", parseOffset, " what=", e.what(),
". Disconnecting to recover.");
disconnect();
closeSocketNoJoin();
return;
}
parseOffset += totalSize;
@ -578,23 +726,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<std::mutex> 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<Packet> localPackets;
{
std::lock_guard<std::mutex> 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<uint8_t>& sessionKey, uint32_t build) {
std::lock_guard<std::mutex> lock(ioMutex_);
if (sessionKey.size() != 40) {
LOG_ERROR("Invalid session key size: ", sessionKey.size(), " (expected 40)");
return;

View file

@ -343,6 +343,8 @@ void CharacterRenderer::shutdown() {
// Clean up composite cache
compositeCache_.clear();
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
textureLookupSerial_ = 0;
whiteTexture_.reset();
transparentTexture_.reset();
@ -430,6 +432,8 @@ void CharacterRenderer::clear() {
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
loggedTextureLoadFails_.clear();
failedTextureRetryAt_.clear();
textureLookupSerial_ = 0;
// Clear composite and failed caches
compositeCache_.clear();
@ -604,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;
@ -619,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;
};
@ -634,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();
@ -652,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);
}
@ -666,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(
@ -724,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;

View file

@ -714,7 +714,9 @@ void M2Renderer::shutdown() {
textureHasAlphaByPtr_.clear();
textureColorKeyBlackByPtr_.clear();
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
loggedTextureLoadFails_.clear();
textureLookupSerial_ = 0;
textureBudgetRejectWarnings_ = 0;
whiteTexture_.reset();
glowTexture_.reset();
@ -4251,6 +4253,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(),
@ -4258,6 +4261,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);
@ -4265,7 +4269,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;
@ -4296,8 +4303,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);
}
@ -4312,6 +4320,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),
@ -4350,6 +4359,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, ")");

View file

@ -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<int>(targetWorkers);
}
return 4; // Fallback
@ -896,6 +898,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 +966,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);

View file

@ -307,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
@ -1087,7 +1089,9 @@ void WMORenderer::clearAll() {
textureCacheBytes_ = 0;
textureCacheCounter_ = 0;
failedTextureCache_.clear();
failedTextureRetryAt_.clear();
loggedTextureLoadFails_.clear();
textureLookupSerial_ = 0;
textureBudgetRejectWarnings_ = 0;
precomputedFloorGrid.clear();
@ -2237,6 +2241,7 @@ std::unique_ptr<VkTexture> WMORenderer::generateNormalHeightMap(
}
VkTexture* WMORenderer::loadTexture(const std::string& path) {
constexpr uint64_t kFailedTextureRetryLookups = 512;
if (!assetManager || !vkCtx_) {
return whiteTexture_.get();
}
@ -2312,7 +2317,19 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) {
}
}
const auto& attemptedCandidates = uniqueCandidates;
const uint64_t lookupSerial = ++textureLookupSerial_;
std::vector<std::string> 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)
@ -2339,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);
}
@ -2353,6 +2374,10 @@ VkTexture* WMORenderer::loadTexture(const std::string& path) {
size_t base = static_cast<size_t>(blp.width) * static_cast<size_t>(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),
@ -2394,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, ")");

View file

@ -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())

46
tools/opcode_map_utils.py Normal file
View file

@ -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

View file

@ -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]: