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();
}
}