From dc5f13cdbc0621020f40ba80638ca58fbd4ca442 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 14:50:33 -0600 Subject: [PATCH] feat: write the character attributes live saved and the client reads LWOCharacterComponent::LoadFromSaveData (0x00ca4910) reads them, and live saves had every one (450 of 450 characters saved at least once): - cm, the most coins a character can hold: 9223372036854775807 on every live save, the client's own value without it. Written when missing, an existing one is kept, and nothing caps the coins by it. - edit (editor level), gid and gn (guild id and name). - mldt (when the property moderation notice was last shown): 0 when missing, as most live saves. - v and vd: the votes left and the day they are for (GetVotesLeft / AdjustVotesLeft). Read back; a new day fills them up to the 3 of WorldConfig's character_votes_per_day. - lrx.. lrrw: the last respawn point reached, once one was reached in the world. - lzcs: the checksum of the zone in lzid. Saves without them load as before. stime is not written: live kept the session's seconds in it and the time before the session in time, which would change what time means in existing saves. Co-Authored-By: Claude Opus 5.5 --- dGame/Character.cpp | 2 + .../Character/CharacterComponent.cpp | 37 +++++++++ .../Character/CharacterComponent.h | 10 +++ .../CharacterSaveFieldsTests.cpp | 76 +++++++++++++++++++ 4 files changed, 125 insertions(+) diff --git a/dGame/Character.cpp b/dGame/Character.cpp index 1f4a7bbb6..88efe347e 100644 --- a/dGame/Character.cpp +++ b/dGame/Character.cpp @@ -290,6 +290,8 @@ void Character::SaveXMLToDatabase() { lzidConcat = (lzidConcat << 16) | uint16_t(zoneInfo.GetInstanceID()); lzidConcat = (lzidConcat << 16) | uint16_t(zoneInfo.GetMapID()); character->SetAttribute("lzid", lzidConcat); + // The checksum of that zone, as live wrote next to it (LWOCharacterComponent::LoadFromSaveData 0x00ca4910 reads it) + character->SetAttribute("lzcs", Game::zoneManager->GetZone()->GetChecksum()); character->SetAttribute("lnzid", GetLastNonInstanceZoneID()); //Darwin's backup: diff --git a/dGame/dComponents/Character/CharacterComponent.cpp b/dGame/dComponents/Character/CharacterComponent.cpp index ebc45df96..e43e3ce78 100644 --- a/dGame/dComponents/Character/CharacterComponent.cpp +++ b/dGame/dComponents/Character/CharacterComponent.cpp @@ -1,3 +1,4 @@ +#include #include "CharacterComponent.h" #include "BitStream.h" #include "tinyxml2.h" @@ -328,6 +329,12 @@ void CharacterComponent::LoadFromSaveData(const tinyxml2::XMLDocument& doc) { m_TooltipFlags = 0; character->QueryUnsigned64Attribute("ttip", &m_TooltipFlags); + // Saves from before these were written have neither: a full day's votes, filled up at the next save + m_VotesLeft = VOTES_PER_DAY; + m_VoteDay = 0; + character->QueryIntAttribute("v", &m_VotesLeft); + character->QueryUnsigned64Attribute("vd", &m_VoteDay); + // Load the statistics const auto* statisticsAttribute = character->FindAttribute("stt"); if (statisticsAttribute) { @@ -448,6 +455,36 @@ void CharacterComponent::WriteSaveData(tinyxml2::XMLDocument& doc) { character->SetAttribute("ls", m_Uscore); // Live wrote ttip on every character, 0 when no tooltip was flagged character->SetAttribute("ttip", m_TooltipFlags); + // Live wrote these on every character (450 of 450 saves that had been saved once); the client reads them in + // LWOCharacterComponent::LoadFromSaveData (0x00ca4910). + // cm, the most coins the character can hold: always 9223372036854775807 on live, the client's own value without + // it. Nothing here caps coins by it, and one that is already in the save is left as it is. + if (!character->FindAttribute("cm")) character->SetAttribute("cm", std::numeric_limits::max()); + // edit, the editor level: the client takes anything but 0 as the editor being on + character->SetAttribute("edit", m_EditorEnabled ? static_cast(m_EditorLevel) : 0u); + // mldt, when the property moderation notice was last shown: not kept here, 0 as on most live saves + if (!character->FindAttribute("mldt")) character->SetAttribute("mldt", 0); + // gid and gn, the guild (0 and empty without one) + character->SetAttribute("gid", static_cast(m_GuildID)); + character->SetAttribute("gn", GeneralUtils::UTF16ToWTF8(m_GuildName).c_str()); + // v and vd, the votes left and the day they are for: a new day fills them up + const uint64_t today = static_cast(std::time(nullptr)) / 86400; + if (m_VoteDay != today) { + m_VotesLeft = VOTES_PER_DAY; + m_VoteDay = today; + } + character->SetAttribute("v", m_VotesLeft); + character->SetAttribute("vd", m_VoteDay); + // lrx.., the last respawn point reached, once one was reached in this world + if (m_respawnPos != NiPoint3Constant::ZERO) { + character->SetAttribute("lrx", m_respawnPos.x); + character->SetAttribute("lry", m_respawnPos.y); + character->SetAttribute("lrz", m_respawnPos.z); + character->SetAttribute("lrrx", m_respawnRot.x); + character->SetAttribute("lrry", m_respawnRot.y); + character->SetAttribute("lrrz", m_respawnRot.z); + character->SetAttribute("lrrw", m_respawnRot.w); + } // Custom attribute to keep track of reputation. character->SetAttribute("rpt", GetReputation()); character->SetAttribute("stt", StatisticsToString().c_str()); diff --git a/dGame/dComponents/Character/CharacterComponent.h b/dGame/dComponents/Character/CharacterComponent.h index 74e20b532..15c7883e7 100644 --- a/dGame/dComponents/Character/CharacterComponent.h +++ b/dGame/dComponents/Character/CharacterComponent.h @@ -368,6 +368,11 @@ public: void SetTooltipFlag(int32_t tooltip, bool flag); uint64_t GetTooltipFlags() const { return m_TooltipFlags; } + // WorldConfig's character_votes_per_day + static constexpr int32_t VOTES_PER_DAY = 3; + int32_t GetVotesLeft() const { return m_VotesLeft; } + uint64_t GetVoteDay() const { return m_VoteDay; } + uint64_t GetTotalTimePlayed() const { return m_TotalTimePlayed; }; /** @@ -408,6 +413,11 @@ private: // The tooltips the player has seen, one bit each (char@ttip) uint64_t m_TooltipFlags{}; + // The votes the character has left today (char@v) and the day, in days since 1970, they were last filled up on + // (char@vd), as LWOCharacterComponent::LoadFromSaveData (0x00ca4910) reads them for GetVotesLeft + int32_t m_VotesLeft = VOTES_PER_DAY; + uint64_t m_VoteDay{}; + /** * The lifetime reputation earned by the entity */ diff --git a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp index 1204a7451..723df45a5 100644 --- a/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp +++ b/tests/dGameTests/dComponentsTests/CharacterSaveFieldsTests.cpp @@ -17,6 +17,7 @@ #include "tinyxml2.h" +#include #include #include @@ -115,6 +116,81 @@ TEST_F(CharacterSaveFieldsTests, TooltipFlagsMissingInOldSave) { EXPECT_STREQ(doc.FirstChildElement("obj")->FirstChildElement("char")->Attribute("ttip"), "0"); } +// The attributes live wrote on every character and the client reads: cm, edit, mldt, gid, gn, v, vd and the +// last respawn point. A save that has them keeps what the server does not own, and nothing touches the coins. +TEST_F(CharacterSaveFieldsTests, LiveCharacterAttributesRoundTrip) { + tinyxml2::XMLDocument doc; + Parse(doc, R"()"); + + Character character(1, nullptr); + Entity player(40, info); + auto* const characterComponent = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + characterComponent->LoadFromSaveData(doc); + EXPECT_EQ(characterComponent->GetVotesLeft(), 1); + EXPECT_EQ(characterComponent->GetVoteDay(), 1u); + + characterComponent->WriteSaveData(doc); + const auto* const saved = doc.FirstChildElement("obj")->FirstChildElement("char"); + EXPECT_STREQ(saved->Attribute("cc"), "22609"); + EXPECT_STREQ(saved->Attribute("cm"), "5000"); // kept, not replaced and not applied + EXPECT_STREQ(saved->Attribute("mldt"), "1301715419"); + EXPECT_STREQ(saved->Attribute("edit"), "0"); + EXPECT_STREQ(saved->Attribute("gid"), "0"); + EXPECT_STREQ(saved->Attribute("gn"), ""); + // A new day: the votes are filled up + const uint64_t today = static_cast(std::time(nullptr)) / 86400; + EXPECT_LE(saved->Unsigned64Attribute("vd") - today, 1u); // (the day may turn during the test) + EXPECT_STREQ(saved->Attribute("v"), "3"); + // No respawn point was reached in this world: the saved one stays + EXPECT_STREQ(saved->Attribute("lrx"), "1"); + EXPECT_STREQ(saved->Attribute("lrrw"), "0.5"); + // Not written by the server: left alone + EXPECT_STREQ(saved->Attribute("stime"), "349"); + EXPECT_STREQ(saved->Attribute("lzcs"), "2233038349"); + + // The same day: the votes left are kept + Entity reloaded(41, info); + auto* const reloadedComponent = reloaded.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + doc.FirstChildElement("obj")->FirstChildElement("char")->SetAttribute("v", 2); + reloadedComponent->LoadFromSaveData(doc); + EXPECT_EQ(reloadedComponent->GetVotesLeft(), 2); + reloadedComponent->SetGuild(77, u"Builders"); + reloadedComponent->WriteSaveData(doc); + EXPECT_STREQ(saved->Attribute("v"), "2"); + EXPECT_STREQ(saved->Attribute("gid"), "77"); + EXPECT_STREQ(saved->Attribute("gn"), "Builders"); +} + +// Saves from before they were written load as before and gain them with the values live had: the coins are not +// touched and cm is the client's own "no limit". +TEST_F(CharacterSaveFieldsTests, LiveCharacterAttributesMissingInOldSave) { + tinyxml2::XMLDocument doc; + Parse(doc, R"()"); + + Character character(1, nullptr); + Entity player(42, info); + auto* const characterComponent = player.AddComponent(-1, &character, UNASSIGNED_SYSTEM_ADDRESS); + characterComponent->LoadFromSaveData(doc); + EXPECT_EQ(characterComponent->GetVotesLeft(), 3); + EXPECT_EQ(characterComponent->GetTooltipFlags(), 4u); + EXPECT_EQ(characterComponent->GetUScore(), 15700); + + characterComponent->WriteSaveData(doc); + const auto* const saved = doc.FirstChildElement("obj")->FirstChildElement("char"); + EXPECT_STREQ(saved->Attribute("cc"), "123456789012"); + EXPECT_STREQ(saved->Attribute("acct"), "1"); + EXPECT_STREQ(saved->Attribute("ls"), "15700"); + EXPECT_STREQ(saved->Attribute("cm"), "9223372036854775807"); + EXPECT_STREQ(saved->Attribute("edit"), "0"); + EXPECT_STREQ(saved->Attribute("mldt"), "0"); + EXPECT_STREQ(saved->Attribute("gid"), "0"); + EXPECT_STREQ(saved->Attribute("gn"), ""); + EXPECT_STREQ(saved->Attribute("v"), "3"); + EXPECT_NE(saved->Attribute("vd"), nullptr); + EXPECT_EQ(saved->Attribute("lrx"), nullptr); // no respawn point reached yet + EXPECT_EQ(saved->Attribute("stime"), nullptr); +} + // : the cooldown groups still running and the seconds left, as live wrote them (sc="17:15.6958;") and the // client reads them (split on ';' and ':'). TEST_F(CharacterSaveFieldsTests, SkillCooldownsRoundTrip) {