From b845faee725a98261042b48ade038ba6247e0e9d Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 02:34:46 -0500 Subject: [PATCH] fix(messages): ChangeObjectWorldState and UnEquipInventory as the client reads them; add UncastSkill ChangeObjectWorldState's state is optional on the wire (a flag, then the state when it isn't INWORLD): live sent 80 80 00 00 00 for ATTACHED. DLU wrote the bare u32, so the client read the rocket's ATTACHED as INWORLD. UnEquipInventory gets its optional replacementObjectID (the client always sends the flag; the server-sent copies in the captures have it too), so it can be sent server -> client. New UncastSkill (1206, server -> client: the client ends its running instance of the skill, LWOSkillComponent::msgUncastSkill 0x00bd86d0). Tests: all three against live capture bytes. Check in game: launch a rocket; the rocket is shown in the player's hands during the launch animation (for the launcher and players nearby). Co-Authored-By: Claude Opus 5.5 --- dGame/dGameMessages/InventoryMessages.cpp | 2 ++ dGame/dGameMessages/InventoryMessages.h | 5 ++-- dGame/dGameMessages/SkillMessages.cpp | 9 +++++++ dGame/dGameMessages/SkillMessages.h | 10 ++++++++ dGame/dGameMessages/ZoneMessages.cpp | 5 ++-- dGame/dGameMessages/ZoneMessages.h | 5 ++-- .../InventoryMessagesTests.cpp | 7 +++++- .../RemainingMessagesTests.cpp | 24 +++++++++++++++++-- 8 files changed, 58 insertions(+), 9 deletions(-) diff --git a/dGame/dGameMessages/InventoryMessages.cpp b/dGame/dGameMessages/InventoryMessages.cpp index e3bd8cf61..ea9e2f058 100644 --- a/dGame/dGameMessages/InventoryMessages.cpp +++ b/dGame/dGameMessages/InventoryMessages.cpp @@ -168,6 +168,7 @@ namespace GameMessages { bitStream.Write(bIgnoreCooldown); bitStream.Write(bOutSuccess); bitStream.Write(itemToUnequip); + BitStreamUtils::WriteOptional(bitStream, replacementObjectID, LWOOBJID_EMPTY); } bool UnEquipInventory::Deserialize(RakNet::BitStream& bitStream) { @@ -175,6 +176,7 @@ namespace GameMessages { VALIDATE_READ(bitStream.Read(bIgnoreCooldown)); VALIDATE_READ(bitStream.Read(bOutSuccess)); VALIDATE_READ(bitStream.Read(itemToUnequip)); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, replacementObjectID, LWOOBJID_EMPTY)); return true; } diff --git a/dGame/dGameMessages/InventoryMessages.h b/dGame/dGameMessages/InventoryMessages.h index a76e8867e..136761278 100644 --- a/dGame/dGameMessages/InventoryMessages.h +++ b/dGame/dGameMessages/InventoryMessages.h @@ -93,8 +93,8 @@ namespace GameMessages { LWOOBJID itemToEquip{}; }; - // Client -> server. The client also sends an optional replacementObjectID after itemToUnequip, which DLU - // has never read (and still does not). + // Both directions: the client asks to unequip; the server tells the client an item was unequipped when it took + // it away (live: bIgnoreCooldown set, after RemoveItemFromInventory). struct UnEquipInventory : public NetGameMsg { UnEquipInventory() : NetGameMsg(MessageType::Game::UN_EQUIP_INVENTORY) {} void Serialize(RakNet::BitStream& bitStream) const override; @@ -105,6 +105,7 @@ namespace GameMessages { bool bIgnoreCooldown{}; bool bOutSuccess{}; LWOOBJID itemToUnequip{}; + LWOOBJID replacementObjectID{ LWOOBJID_EMPTY }; // optional }; // Client -> server. diff --git a/dGame/dGameMessages/SkillMessages.cpp b/dGame/dGameMessages/SkillMessages.cpp index eba5413d5..b2a9b61c3 100644 --- a/dGame/dGameMessages/SkillMessages.cpp +++ b/dGame/dGameMessages/SkillMessages.cpp @@ -48,6 +48,15 @@ namespace GameMessages { return true; } + void UncastSkill::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(skillID); + } + + bool UncastSkill::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(skillID)); + return true; + } + void SelectSkill::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(bFromSkillSet); bitStream.Write(skillID); diff --git a/dGame/dGameMessages/SkillMessages.h b/dGame/dGameMessages/SkillMessages.h index c6cd5f14b..0be118386 100644 --- a/dGame/dGameMessages/SkillMessages.h +++ b/dGame/dGameMessages/SkillMessages.h @@ -39,6 +39,16 @@ namespace GameMessages { TSkillID skillID{}; }; + // Server -> client, to the player only. The client's SkillComponent ends its running instance of the skill + // (LWOSkillComponent::msgUncastSkill, 0x00bd86d0): live sent it for an unequipped item's equip skills. + struct UncastSkill : public NetGameMsg { + UncastSkill() : NetGameMsg(MessageType::Game::UNCAST_SKILL) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + + int32_t skillID{}; + }; + // Client -> server. The player picked a skill; DLU only uses it to fix invisible items on first load. struct SelectSkill : public NetGameMsg { SelectSkill() : NetGameMsg(MessageType::Game::SELECT_SKILL) {} diff --git a/dGame/dGameMessages/ZoneMessages.cpp b/dGame/dGameMessages/ZoneMessages.cpp index 6d45a2572..36c0f8884 100644 --- a/dGame/dGameMessages/ZoneMessages.cpp +++ b/dGame/dGameMessages/ZoneMessages.cpp @@ -238,11 +238,12 @@ namespace GameMessages { } void ChangeObjectWorldState::Serialize(RakNet::BitStream& bitStream) const { - bitStream.Write(newState); + BitStreamUtils::WriteOptional(bitStream, newState, eObjectWorldState::INWORLD); } bool ChangeObjectWorldState::Deserialize(RakNet::BitStream& bitStream) { - return bitStream.Read(newState); + VALIDATE_READ(BitStreamUtils::ReadOptional(bitStream, newState, eObjectWorldState::INWORLD)); + return true; } void LocalizedAnnouncementServerToSingleClient::Serialize(RakNet::BitStream& bitStream) const { diff --git a/dGame/dGameMessages/ZoneMessages.h b/dGame/dGameMessages/ZoneMessages.h index 0597e1ff7..ccf1c8306 100644 --- a/dGame/dGameMessages/ZoneMessages.h +++ b/dGame/dGameMessages/ZoneMessages.h @@ -82,13 +82,14 @@ namespace GameMessages { void Handle(Entity& entity, const SystemAddress& sysAddr) override; }; - // Server -> client, to one client (UNASSIGNED broadcasts). + // Server -> client, to one client (UNASSIGNED broadcasts). newState is optional (a flag, then the state when it + // isn't INWORLD), as live sent it (e.g. 80 80 00 00 00 for ATTACHED). struct ChangeObjectWorldState : public NetGameMsg { ChangeObjectWorldState() : NetGameMsg(MessageType::Game::CHANGE_OBJECT_WORLD_STATE) {} void Serialize(RakNet::BitStream& bitStream) const override; bool Deserialize(RakNet::BitStream& bitStream) override; - eObjectWorldState newState{}; + eObjectWorldState newState{ eObjectWorldState::INWORLD }; // optional }; // Server -> client, to one client. The client looks both strings up in its locale (falling back to the text diff --git a/tests/dGameTests/dGameMessagesTests/InventoryMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/InventoryMessagesTests.cpp index 8f490b852..4781393fe 100644 --- a/tests/dGameTests/dGameMessagesTests/InventoryMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/InventoryMessagesTests.cpp @@ -347,7 +347,12 @@ TEST_F(InventoryMessagesTests, InboundReadsLikeLegacy) { unequip.bIgnoreCooldown = !flag; unequip.bOutSuccess = flag; unequip.itemToUnequip = id; - const auto legacyUnequip = ReadWithLegacy(unequip, LegacyGameMessages::ReadUnequipItem); + // The legacy read stopped before the optional replacementObjectID (its flag bit is left over) + RakNet::BitStream unequipWire; + unequip.Serialize(unequipWire); + RakNet::BitStream legacyUnequipStream(unequipWire.GetData(), unequipWire.GetNumberOfBytesUsed(), false); + const auto legacyUnequip = LegacyGameMessages::ReadUnequipItem(legacyUnequipStream); + EXPECT_EQ(legacyUnequipStream.GetReadOffset() + 1, unequipWire.GetNumberOfBitsUsed()); EXPECT_EQ(RoundTrip(unequip).itemToUnequip, legacyUnequip.objectID); ExpectTruncatedFails(unequip); diff --git a/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp index 58e110b11..389ddb991 100644 --- a/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp +++ b/tests/dGameTests/dGameMessagesTests/RemainingMessagesTests.cpp @@ -6,6 +6,8 @@ #include "ObjectMessages.h" #include "PlayerMessages.h" #include "QuickBuildMessages.h" +#include "InventoryMessages.h" +#include "SkillMessages.h" #include "StatisticID.h" #include "ZoneMessages.h" @@ -352,8 +354,7 @@ TEST_F(RemainingMessagesTests, ZoneMessagesMatchLegacy) { GameMessages::ChangeObjectWorldState msg; msg.target = target; msg.newState = state; - ExpectSameAsLegacy([&](const SystemAddress& address) { LegacyGameMessages::SendChangeObjectWorldState(target, state, address); }, msg); - RoundTrip(msg); + EXPECT_EQ(RoundTrip(msg).newState, state); } } // Name-value text: a null terminator follows non-empty text and is not counted. @@ -743,3 +744,22 @@ TEST_F(RemainingMessagesTests, UpdatePlayerStatisticMatchesLiveCapture) { EXPECT_EQ(meters.updateID, static_cast(StatisticID::MetersTraveled)); EXPECT_EQ(meters.updateValue, 29); } + +// Packets from 2011/2012 live captures, sent when items were equipped and unequipped. DLU wrote ChangeObjectWorldState's +// state without the flag the client reads first, so the client read ATTACHED as INWORLD. +TEST_F(RemainingMessagesTests, EquipMessagesMatchLiveCapture) { + const auto attached = FromLiveCapture("5305000c000000002d21026701000010c7048080000000"); + EXPECT_EQ(attached.target, 0x100000016702212dLL); + EXPECT_EQ(attached.newState, eObjectWorldState::ATTACHED); + const auto inventory = FromLiveCapture("5305000c00000000b5915b6701000010c7048100000000"); + EXPECT_EQ(inventory.newState, eObjectWorldState::INVENTORY); + + const auto uncast = FromLiveCapture("5305000c000000001f147b5b01000010b6046a010000"); + EXPECT_EQ(uncast.skillID, 362); + + const auto unequip = FromLiveCapture("5305000c000000001f147b5b01000010e9004cb607ae4020000200"); + EXPECT_FALSE(unequip.bEvenIfDead); + EXPECT_TRUE(unequip.bIgnoreCooldown); + EXPECT_FALSE(unequip.bOutSuccess); + EXPECT_EQ(unequip.replacementObjectID, LWOOBJID_EMPTY); +}