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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-11-10 14:50:33 -06:00
parent 95e0746441
commit dc5f13cdbc
4 changed files with 125 additions and 0 deletions

View File

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

View File

@@ -1,3 +1,4 @@
#include <limits>
#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<int64_t>::max());
// edit, the editor level: the client takes anything but 0 as the editor being on
character->SetAttribute("edit", m_EditorEnabled ? static_cast<uint32_t>(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<int64_t>(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<uint64_t>(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());

View File

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

View File

@@ -17,6 +17,7 @@
#include "tinyxml2.h"
#include <ctime>
#include <string>
#include <gtest/gtest.h>
@@ -115,6 +116,81 @@ TEST_F(CharacterSaveFieldsTests, TooltipFlagsMissingInOldSave) {
EXPECT_STREQ(doc.FirstChildElement("obj")->FirstChildElement("char")->Attribute("ttip"), "0");
}
// The <char> 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"(<obj v="1"><mf/><char cc="22609" cm="5000" ls="0" mldt="1301715419" v="1" vd="1" lrx="1" lry="2" lrz="3" lrrx="0" lrry="0.5" lrrz="0" lrrw="0.5" stime="349" lzcs="2233038349"/></obj>)");
Character character(1, nullptr);
Entity player(40, info);
auto* const characterComponent = player.AddComponent<CharacterComponent>(-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<uint64_t>(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<CharacterComponent>(-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"(<obj v="1"><mf/><char acct="1" cc="123456789012" gm="0" ls="15700" ttip="4" time="54390"/></obj>)");
Character character(1, nullptr);
Entity player(42, info);
auto* const characterComponent = player.AddComponent<CharacterComponent>(-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);
}
// <skil sc>: 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) {