From 921d2c8c2a81f9d1e52d2d11421ce8ae202bec75 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 03:06:03 -0500 Subject: [PATCH] feat(character): keep and save the tooltip flags (SetTooltipFlag, char@ttip) The client sends SetTooltipFlag (469: bFlag, then the tooltip, 2 live packets, both tooltip 24) when a tooltip has been shown, and follows it with SetFlag for the same ID. DLU dropped it and never wrote char@ttip, so the client's tooltip bits reset on every load. - SetTooltipFlag message struct and handler: the bit is set or cleared on the CharacterComponent exactly as the client does it (LWOCharacterComponent::SendMessage 0x00d34330): tooltips above 127 are ignored, set = 1 << tooltip, clear = mask ~1 << tooltip (which also clears the lower bits), shifts of 64 or more give 0. - Saved as char@ttip, a 64-bit value (the client reads it with GetLongLongValue in LoadFromSaveData 0x00ca4910). Live wrote it on every character (226 live charxmls: "0" or "16777216" = tooltip 24). Old saves without it load with 0. Check in game: on a new character, trigger a first-time tooltip (e.g. the one about an item or a new area), change zones and log out and back in: the character loads normally and the same tooltip does not come back. Co-Authored-By: Claude Opus 5.5 --- dGame/dComponents/CharacterComponent.cpp | 14 ++++ dGame/dComponents/CharacterComponent.h | 11 +++ dGame/dGameMessages/GameMessageHandler.cpp | 1 + dGame/dGameMessages/PlayerMessages.cpp | 16 +++++ dGame/dGameMessages/PlayerMessages.h | 12 ++++ .../CharacterSaveFieldsTests.cpp | 37 ++++++++++ .../dGameMessagesTests/CMakeLists.txt | 3 +- .../ClientMessagesTests.cpp | 69 +++++++++++++++++++ 8 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp diff --git a/dGame/dComponents/CharacterComponent.cpp b/dGame/dComponents/CharacterComponent.cpp index 0bf730018..d8d07bb67 100644 --- a/dGame/dComponents/CharacterComponent.cpp +++ b/dGame/dComponents/CharacterComponent.cpp @@ -290,6 +290,9 @@ void CharacterComponent::LoadFromXml(const tinyxml2::XMLDocument& doc) { character->QueryInt64Attribute("ls", &m_Uscore); + m_TooltipFlags = 0; + character->QueryUnsigned64Attribute("ttip", &m_TooltipFlags); + // Load the statistics const auto* statisticsAttribute = character->FindAttribute("stt"); if (statisticsAttribute) { @@ -420,6 +423,8 @@ void CharacterComponent::UpdateXml(tinyxml2::XMLDocument& doc) { if (m_ClaimCodes[3] != 0) character->SetAttribute("co3", m_ClaimCodes[3]); character->SetAttribute("ls", m_Uscore); + // Live wrote ttip on every character, 0 when no tooltip was flagged + character->SetAttribute("ttip", m_TooltipFlags); // Custom attribute to keep track of reputation. character->SetAttribute("rpt", GetReputation()); character->SetAttribute("stt", StatisticsToString().c_str()); @@ -471,6 +476,15 @@ void CharacterComponent::UpdateXml(tinyxml2::XMLDocument& doc) { m_LastUpdateTimestamp = newUpdateTimestamp; } +void CharacterComponent::SetTooltipFlag(const int32_t tooltip, const bool flag) { + if (tooltip > 127) return; + // x86 64-bit shifts (__allshl) by 64 or more give 0; the shift count is the tooltip's low byte + const auto shift = static_cast(tooltip); + const auto shifted = [shift](const uint64_t value) { return shift < 64 ? value << shift : 0; }; + if (flag) m_TooltipFlags |= shifted(1); + else m_TooltipFlags &= shifted(~uint64_t{ 1 }); +} + void CharacterComponent::SetLastRocketConfig(std::u16string config) { m_IsLanding = !config.empty(); m_LastRocketConfig = config; diff --git a/dGame/dComponents/CharacterComponent.h b/dGame/dComponents/CharacterComponent.h index 3318d60ab..bc4be664b 100644 --- a/dGame/dComponents/CharacterComponent.h +++ b/dGame/dComponents/CharacterComponent.h @@ -327,6 +327,14 @@ public: const std::u16string& GetLastRocketConfig() const { return m_LastRocketConfig; }; + /** + * Sets or clears a tooltip's "seen" bit as the client's SetTooltipFlag does (LWOCharacterComponent::SendMessage + * 0x00d34330): tooltips above 127 are ignored; setting shifts 1 left by the tooltip (nothing at 64 or more); + * clearing masks with ~1 shifted left by the tooltip, which also clears every lower bit. Saved as char@ttip. + */ + void SetTooltipFlag(int32_t tooltip, bool flag); + uint64_t GetTooltipFlags() const { return m_TooltipFlags; } + uint64_t GetTotalTimePlayed() const { return m_TotalTimePlayed; }; /** @@ -364,6 +372,9 @@ private: */ int64_t m_Uscore; + // The tooltips the player has seen, one bit each (char@ttip) + uint64_t m_TooltipFlags{}; + /** * The lifetime reputation earned by the entity */ diff --git a/dGame/dGameMessages/GameMessageHandler.cpp b/dGame/dGameMessages/GameMessageHandler.cpp index 7114f456c..79d18310c 100644 --- a/dGame/dGameMessages/GameMessageHandler.cpp +++ b/dGame/dGameMessages/GameMessageHandler.cpp @@ -193,6 +193,7 @@ namespace { { PICKUP_CURRENCY, []() { return std::make_unique(); } }, { MODIFY_PLAYER_ZONE_STATISTIC, []() { return std::make_unique(); } }, { UPDATE_PLAYER_STATISTIC, []() { return std::make_unique(); } }, + { SET_TOOLTIP_FLAG, []() { return std::make_unique(); } }, { REPORT_BUG, []() { return std::make_unique(); } }, { VERIFY_ACK, []() { return std::make_unique(); } }, diff --git a/dGame/dGameMessages/PlayerMessages.cpp b/dGame/dGameMessages/PlayerMessages.cpp index 464ff8b18..02a43ecff 100644 --- a/dGame/dGameMessages/PlayerMessages.cpp +++ b/dGame/dGameMessages/PlayerMessages.cpp @@ -75,6 +75,22 @@ namespace GameMessages { return bitStream.Read(reputation); } + void SetTooltipFlag::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(bFlag); + bitStream.Write(iToolTip); + } + + bool SetTooltipFlag::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(bFlag)); + VALIDATE_READ(bitStream.Read(iToolTip)); + return true; + } + + void SetTooltipFlag::Handle(Entity& entity, const SystemAddress& sysAddr) { + auto* const characterComponent = entity.GetComponent(); + if (characterComponent) characterComponent->SetTooltipFlag(iToolTip, bFlag); + } + void ToggleGMInvis::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(bStateOut); } diff --git a/dGame/dGameMessages/PlayerMessages.h b/dGame/dGameMessages/PlayerMessages.h index 8e0fd77ac..038564e98 100644 --- a/dGame/dGameMessages/PlayerMessages.h +++ b/dGame/dGameMessages/PlayerMessages.h @@ -67,6 +67,18 @@ namespace GameMessages { int64_t reputation{}; }; + // Client -> server. The client sets or clears a tooltip's bit in its own flags, then sends SetFlag for the same + // ID; the server keeps the bits and saves them as char@ttip, which the client reads on load. + struct SetTooltipFlag : public NetGameMsg { + SetTooltipFlag() : NetGameMsg(MessageType::Game::SET_TOOLTIP_FLAG) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + void Handle(Entity& entity, const SystemAddress& sysAddr) override; + + bool bFlag{}; + int32_t iToolTip{}; + }; + // Server -> client, broadcast. struct ToggleGMInvis : public NetGameMsg { ToggleGMInvis() : NetGameMsg(MessageType::Game::TOGGLE_GM_INVIS) {} diff --git a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp index 1d79d3236..b62a63b94 100644 --- a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp +++ b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp @@ -3,6 +3,8 @@ #include "GameDependencies.h" #include "CDClientDatabase.h" +#include "Character.h" +#include "CharacterComponent.h" #include "Entity.h" #include "GameMessages.h" #include "MissionComponent.h" @@ -72,3 +74,38 @@ TEST_F(CharacterSaveFieldsTests, MissionTypeStatesMissingInOldSave) { missions->UpdateXml(doc); EXPECT_EQ(Print(doc), R"()"); } + +// char@ttip: the tooltip bits (the client reads it with GetLongLongValue). Live wrote it on every character. +TEST_F(CharacterSaveFieldsTests, TooltipFlagsRoundTrip) { + tinyxml2::XMLDocument doc; + Parse(doc, R"()"); + + Character character(1, nullptr); + Entity player(23, info); + auto* const characterComponent = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + characterComponent->LoadFromXml(doc); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 16777216u); + + characterComponent->SetTooltipFlag(2, true); + characterComponent->UpdateXml(doc); + EXPECT_STREQ(doc.FirstChildElement("obj")->FirstChildElement("char")->Attribute("ttip"), "16777220"); + + Entity reloaded(24, info); + auto* const reloadedComponent = reloaded.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + reloadedComponent->LoadFromXml(doc); + EXPECT_EQ(reloadedComponent->GetTooltipFlags(), 16777220u); +} + +// Saves from before ttip was written load with no tooltips flagged and gain ttip="0". +TEST_F(CharacterSaveFieldsTests, TooltipFlagsMissingInOldSave) { + tinyxml2::XMLDocument doc; + Parse(doc, R"()"); + + Character character(1, nullptr); + Entity player(25, info); + auto* const characterComponent = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + characterComponent->LoadFromXml(doc); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 0u); + characterComponent->UpdateXml(doc); + EXPECT_STREQ(doc.FirstChildElement("obj")->FirstChildElement("char")->Attribute("ttip"), "0"); +} diff --git a/tests/dGameTests/dGameMessagesTests/CMakeLists.txt b/tests/dGameTests/dGameMessagesTests/CMakeLists.txt index 561531b2a..70e10d40c 100644 --- a/tests/dGameTests/dGameMessagesTests/CMakeLists.txt +++ b/tests/dGameTests/dGameMessagesTests/CMakeLists.txt @@ -16,7 +16,8 @@ SET(DGAMEMESSAGES_TESTS "VendorMessagesTests.cpp" "LegacyGameMessageTests.cpp" "GameMessageDecoderTests.cpp" - "HotPropertyMessageTests.cpp") + "HotPropertyMessageTests.cpp" + "ClientMessagesTests.cpp") # Get the folder name and prepend it to the files above get_filename_component(thisFolderName ${CMAKE_CURRENT_SOURCE_DIR} NAME) diff --git a/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp b/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp new file mode 100644 index 000000000..1bb16e273 --- /dev/null +++ b/tests/dGameTests/dGameMessagesTests/ClientMessagesTests.cpp @@ -0,0 +1,69 @@ +// Client -> server game messages DLU used to drop (docs/CaptureUnknowns.md section 4). Packets are from 2011/2012 +// live captures with the object ID replaced. +#include "GameDependencies.h" +#include "GameMessageTestUtils.h" + +#include "CharacterComponent.h" +#include "Entity.h" +#include "PlayerMessages.h" + +#include + +#include + +using namespace GameMessageTestUtils; + +namespace { + // A synthetic player object ID, as the object ID of the live packets below + constexpr const char* PLAYER_HEADER = "53040005000000000100000000000010"; +} + +class ClientMessagesTests : public GameDependenciesTest { +protected: + std::unique_ptr player; + std::unique_ptr character; + + void SetUp() override { + SetUpDependencies(); + player = std::make_unique(0x1000000000000001LL, info); + character = std::make_unique(1, nullptr); + player->AddComponent(-1, character.get(), UNASSIGNED_SYSTEM_ADDRESS); + } + + void TearDown() override { + player.reset(); + character.reset(); + TearDownDependencies(); + } +}; + +// SetTooltipFlag (469): bFlag, then the tooltip. Both live samples flagged tooltip 24; live saved ttip="16777216". +TEST_F(ClientMessagesTests, SetTooltipFlagMatchesLiveCapture) { + auto msg = FromLiveClientCapture(std::string(PLAYER_HEADER) + "d501" + "8c00000000"); + EXPECT_TRUE(msg.bFlag); + EXPECT_EQ(msg.iToolTip, 24); + EXPECT_EQ(RoundTrip(msg).iToolTip, 24); + ExpectTruncatedFails(msg); + + auto* const characterComponent = player->GetComponent(); + msg.Handle(*player, UNASSIGNED_SYSTEM_ADDRESS); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 16777216u); +} + +// The client's bit operations: set shifts 1 by the tooltip; clear masks with ~1 shifted by the tooltip (clearing +// every lower bit too); shifts of 64 or more give 0; tooltips above 127 are ignored. +TEST_F(ClientMessagesTests, SetTooltipFlagFollowsTheClient) { + auto* const characterComponent = player->GetComponent(); + characterComponent->SetTooltipFlag(0, true); + characterComponent->SetTooltipFlag(3, true); + characterComponent->SetTooltipFlag(63, true); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 0x8000000000000009ULL); + characterComponent->SetTooltipFlag(64, true); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 0x8000000000000009ULL); + characterComponent->SetTooltipFlag(128, false); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 0x8000000000000009ULL); + characterComponent->SetTooltipFlag(3, false); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 0x8000000000000000ULL); + characterComponent->SetTooltipFlag(100, false); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 0u); +}