From 9af7dd555a803120559ced473b18ffa73d3dfe13 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:27:12 -0700 Subject: [PATCH] fix: LastMoved and stat-gain stamps stay absolute across restarts (#2729) ReadAnchoredTime shifted Item.LastMoved, Item.DecayResetTime and Mobile.LastStr/Int/DexGain forward by the downtime at load. The bytes were stable from save to save, but the first save after every restart rewrote one timestamp in nearly every item record and most mobile records, which defeats any per-record backup or delta save. - LastMoved and DecayResetTime are read as saved. The downtime credit is applied after load by Item.CreditDowntimeToDecay, which moves DecayResetTime only for items whose decay countdown was still running when the save started. Every other item keeps its bytes, and decay timing is unchanged. - World.LoadedSaveStartTime exposes the loaded save's anchor so the credit can tell which countdowns were running. - Stat-gain stamps are read raw. The cooldown is minutes long, so downtime counting toward it costs nothing. No format change: these fields were already absolute ticks. Co-authored-by: Claude Opus 5.5 --- .../AnchoredItemSerializationTests.cs | 108 ++++++++++++++++++ Projects/Server/Items/Item.cs | 39 ++++++- Projects/Server/Mobiles/Mobile.cs | 14 ++- .../Serialization/GenericEntityPersistence.cs | 1 + Projects/Server/World/World.cs | 7 ++ 5 files changed, 157 insertions(+), 12 deletions(-) diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs index 41444ea56..ff032f530 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredItemSerializationTests.cs @@ -82,4 +82,112 @@ public class AnchoredItemSerializationTests restored?.Delete(); } } + + /// + /// LastMoved is the real time of the move, so a load after downtime must not shift it. + /// Shifting it rewrote every item record on the first save after every restart. + /// + [Fact] + public void LastMoved_IsNotShiftedByTheLoadAnchor() + { + var item = new Item(0x1F13); + var moved = Core.Now - TimeSpan.FromDays(30); + item.LastMoved = moved; + + var restored = new Item((Serial)0x7ffff124u); + restored.Deserialize(new BufferReader(SerializeItem(item)) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(moved, restored.LastMoved); + Assert.Equal(default(DateTime), restored.DecayResetTime); + + item.Delete(); + restored.Delete(); + } + + /// + /// Downtime still does not count toward decay: a countdown running when the save started + /// resumes with the time it had left, carried by DecayResetTime. + /// + [Fact] + public void Downtime_IsCreditedToARunningDecayCountdown() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromMinutes(10); + var deadline = item.ScheduledDecayTime; + + var downtime = TimeSpan.FromHours(2); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(start - TimeSpan.FromMinutes(10), item.LastMoved); + Assert.Equal(deadline + downtime, item.ScheduledDecayTime); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// The common case: an item whose countdown had already run out gets no credit, so a restart + /// leaves its record byte-identical. + /// + [Fact] + public void ItemAlreadyDueAtSave_GetsNoCredit_AndKeepsItsBytes() + { + var start = Core._now; + + try + { + var item = new Item(0x1F13); + item.LastMoved = start - TimeSpan.FromDays(3); + var before = SerializeItem(item); + + var downtime = TimeSpan.FromMinutes(30); + Core._now = start + downtime; + item.CreditDowntimeToDecay(start, downtime); + + Assert.Equal(default(DateTime), item.DecayResetTime); + Assert.Equal(before, SerializeItem(item)); + + item.Delete(); + } + finally + { + Core._now = start; + } + } + + /// + /// Stat-gain stamps are absolute and not shifted at load either; downtime counts toward a + /// cooldown that is only minutes long. + /// + [Fact] + public void StatGainStamps_AreNotShiftedByTheLoadAnchor() + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.LastStrGain = Core.Now - TimeSpan.FromMinutes(3); + mobile.LastIntGain = Core.Now - TimeSpan.FromMinutes(2); + mobile.LastDexGain = Core.Now - TimeSpan.FromMinutes(1); + + var writer = new BufferWriter(new byte[4096], true); + mobile.Serialize(writer); + + var restored = new Mobile(World.NewMobile); + restored.Deserialize(new BufferReader(writer.Buffer[..(int)writer.Position]) { AnchoredTimeShift = TimeSpan.FromHours(2) }); + + Assert.Equal(mobile.LastStrGain, restored.LastStrGain); + Assert.Equal(mobile.LastIntGain, restored.LastIntGain); + Assert.Equal(mobile.LastDexGain, restored.LastDexGain); + + mobile.Delete(); + restored.Delete(); + } } diff --git a/Projects/Server/Items/Item.cs b/Projects/Server/Items/Item.cs index 4226f100f..c67acdc85 100644 --- a/Projects/Server/Items/Item.cs +++ b/Projects/Server/Items/Item.cs @@ -1042,13 +1042,14 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert writer.Write((int)flags); - // Anchored: shifted by downtime at load, so time-since-moved is preserved and the - // bytes are stable across saves while the item does not move. - writer.WriteAnchoredTime(LastMoved); + // Absolute and never shifted at load, so an item that does not move serializes to the + // same bytes across saves and across restarts. Downtime credit for a decay countdown that + // was running at save time goes into DecayResetTime (CreditDowntimeToDecay). + writer.Write(LastMoved); if (GetSaveFlag(flags, SaveFlag.DecayReset)) { - writer.WriteAnchoredTime(info.m_DecayReset); + writer.Write(info.m_DecayReset); } if (GetSaveFlag(flags, SaveFlag.Direction)) @@ -2494,6 +2495,30 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert UpdateDecayRegistration(); } + /// + /// Credits the downtime before this load to a decay countdown that was still running when the + /// save started, so the shard being down does not age the item. stays + /// the real time of the move; the credit goes into , which only + /// the items moved within their of the save ever carry. Called once + /// per item after the world loads, when is final. An item already + /// due at save time gets nothing: it is due either way. + /// + internal void CreditDowntimeToDecay(DateTime saveStartUtc, TimeSpan downtime) + { + if (downtime <= TimeSpan.Zero || !Decays) + { + return; + } + + var reset = DecayResetTime; + var start = reset > LastMoved ? reset : LastMoved; + + if (start + DecayTime > saveStartUtc) + { + DecayResetTime = start + downtime; + } + } + public void UpdateDecayRegistration() { DecayScheduler.Unregister(this); @@ -2856,7 +2881,9 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 11) { - LastMoved = reader.ReadAnchoredTime(); + // Written with WriteAnchoredTime before LastMoved stopped being shifted; + // the bytes are the same absolute ticks either way. + LastMoved = reader.ReadDateTime(); } else if (version < 7) { @@ -2878,7 +2905,7 @@ public partial class Item : IHued, IComparable, ISpawnable, IObjectPropert if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset)) { - var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime(); + var reset = version >= 11 ? reader.ReadDateTime() : reader.ReadDeltaTime(); // Pre-v11 LastMoved was stored at whole-minute precision; keep the // stamp only while it still extends the deadline. diff --git a/Projects/Server/Mobiles/Mobile.cs b/Projects/Server/Mobiles/Mobile.cs index 52628e74a..e1cb74e9b 100644 --- a/Projects/Server/Mobiles/Mobile.cs +++ b/Projects/Server/Mobiles/Mobile.cs @@ -2343,9 +2343,11 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { writer.Write(38); // version - writer.WriteAnchoredTime(LastStrGain); - writer.WriteAnchoredTime(LastIntGain); - writer.WriteAnchoredTime(LastDexGain); + // Absolute and never shifted at load: the stat-gain cooldown is minutes long, so letting + // downtime count toward it costs nothing, and an idle mobile keeps its bytes across restarts. + writer.Write(LastStrGain); + writer.Write(LastIntGain); + writer.Write(LastDexGain); byte hairflag = 0x00; @@ -6179,9 +6181,9 @@ public partial class Mobile : IHued, IComparable, ISpawnable, IObjectPro { if (version >= 38) { - LastStrGain = reader.ReadAnchoredTime(); - LastIntGain = reader.ReadAnchoredTime(); - LastDexGain = reader.ReadAnchoredTime(); + LastStrGain = reader.ReadDateTime(); + LastIntGain = reader.ReadDateTime(); + LastDexGain = reader.ReadDateTime(); } else { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 70e9c6aab..9fe12e4e9 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -529,6 +529,7 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer // The whole save shares one anchor. Publish it so payloads without their own // (GenericPersistence bins) can shift too; indexes load before any of them. World.LoadTimeShift = _anchoredTimeShift; + World.LoadedSaveStartTime = anchor; } if (version >= 4) diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 7ce1b52f2..7e968b925 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -108,6 +108,12 @@ public static class World /// payloads, which carry no anchor of their own. Zero for saves that predate the anchor. /// public static TimeSpan LoadTimeShift { get; internal set; } + + /// + /// UTC start time of the save being loaded, read from its index anchor. Default for saves + /// that predate the anchor. + /// + public static DateTime LoadedSaveStartTime { get; internal set; } public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial; public static bool Loading => WorldState == WorldState.Loading; @@ -674,6 +680,7 @@ public static class World } item.ClearProperties(); + item.CreditDowntimeToDecay(LoadedSaveStartTime, LoadTimeShift); item.UpdateDecayRegistration(); } }