diff --git a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs index 74edf09c3..1015e704d 100644 --- a/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs +++ b/Projects/Server.Tests/Tests/Serialization/AnchoredTimeTests.cs @@ -67,6 +67,63 @@ public class AnchoredTimeTests Assert.Equal(local.ToUniversalTime(), read(TimeSpan.Zero).ReadAnchoredTime()); } + + private static readonly DateTime SaveStart = new(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + + private static IGenericReader ReadSaved(BufferWriter writer, TimeSpan downtime) => + new BufferReader(writer.Buffer) { AnchoredTimeShift = downtime, AnchoredTimeOrigin = SaveStart }; + + [Fact] + public void AnchoredTime_DeadlineRunningAtSaveStart_KeepsItsRemainingTime() + { + var writer = new BufferWriter(new byte[64], true); + var deadline = SaveStart + TimeSpan.FromMinutes(20); + var downtime = TimeSpan.FromHours(3); + + writer.WriteAnchoredTime(deadline); + + Assert.Equal(deadline + downtime, ReadSaved(writer, downtime).ReadAnchoredTime()); + } + + /// + /// A deadline that had passed before the save must stay passed. Shifting it brought an expiry + /// back into the future whenever the downtime was longer than the time since it expired. + /// + [Fact] + public void AnchoredTime_DeadlinePassedBeforeSaveStart_StaysPassed() + { + var writer = new BufferWriter(new byte[64], true); + var expired = SaveStart - TimeSpan.FromMinutes(5); + + writer.WriteAnchoredTime(expired); + + Assert.Equal(expired, ReadSaved(writer, TimeSpan.FromMinutes(30)).ReadAnchoredTime()); + } + + /// + /// A stamp of something that already happened (a last-use time) is never shifted, so its + /// bytes survive a restart and downtime counts toward its window. + /// + [Fact] + public void AnchoredTime_PastStamp_IsNotShifted() + { + var writer = new BufferWriter(new byte[64], true); + var lastUse = SaveStart - TimeSpan.FromDays(2); + + writer.WriteAnchoredTime(lastUse); + + Assert.Equal(lastUse, ReadSaved(writer, TimeSpan.FromHours(1)).ReadAnchoredTime()); + } + + [Fact] + public void AnchoredTime_ValueAtSaveStart_IsNotShifted() + { + var writer = new BufferWriter(new byte[64], true); + + writer.WriteAnchoredTime(SaveStart); + + Assert.Equal(SaveStart, ReadSaved(writer, TimeSpan.FromHours(1)).ReadAnchoredTime()); + } } internal class AnchoredEntity : ISerializable diff --git a/Projects/Server/Serialization/BinaryFileReader.cs b/Projects/Server/Serialization/BinaryFileReader.cs index f8b51c182..37332ef8c 100644 --- a/Projects/Server/Serialization/BinaryFileReader.cs +++ b/Projects/Server/Serialization/BinaryFileReader.cs @@ -80,6 +80,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader set => _reader.AnchoredTimeShift = value; } + public DateTime AnchoredTimeOrigin + { + get => _reader.AnchoredTimeOrigin; + set => _reader.AnchoredTimeOrigin = value; + } + public void Dispose() { _accessor?.SafeMemoryMappedViewHandle.ReleasePointer(); diff --git a/Projects/Server/Serialization/BufferReader.cs b/Projects/Server/Serialization/BufferReader.cs index bd37f4ff6..90b9e17ff 100644 --- a/Projects/Server/Serialization/BufferReader.cs +++ b/Projects/Server/Serialization/BufferReader.cs @@ -39,6 +39,8 @@ public class BufferReader : IGenericReader public TimeSpan AnchoredTimeShift { get; set; } + public DateTime AnchoredTimeOrigin { get; set; } + public BufferReader(byte[] buffer, Dictionary typesDb = null, Encoding encoding = null) { _buffer = buffer; diff --git a/Projects/Server/Serialization/BufferWriter.cs b/Projects/Server/Serialization/BufferWriter.cs index ab53e2f2f..9b0199344 100644 --- a/Projects/Server/Serialization/BufferWriter.cs +++ b/Projects/Server/Serialization/BufferWriter.cs @@ -409,9 +409,9 @@ public class BufferWriter : IGenericWriter } /// - /// Writes the absolute value; re-bases it - /// by the elapsed time since the save started, so downtime does not age it and an - /// unchanged value serializes to identical bytes. + /// Writes the absolute value; re-bases it by + /// the downtime when it was still in the future at save start, so downtime does not consume a + /// running deadline and an unchanged value serializes to identical bytes. /// public void WriteAnchoredTime(DateTime value) { diff --git a/Projects/Server/Serialization/GenericEntityPersistence.cs b/Projects/Server/Serialization/GenericEntityPersistence.cs index 9fe12e4e9..325789d5c 100644 --- a/Projects/Server/Serialization/GenericEntityPersistence.cs +++ b/Projects/Server/Serialization/GenericEntityPersistence.cs @@ -710,7 +710,8 @@ public class GenericEntityPersistence : GenericPersistence, IGenericEntityPer accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr); var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb) { - AnchoredTimeShift = _anchoredTimeShift + AnchoredTimeShift = _anchoredTimeShift, + AnchoredTimeOrigin = World.LoadedSaveStartTime }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/GenericPersistence.cs b/Projects/Server/Serialization/GenericPersistence.cs index 2268a86c6..b9261b053 100644 --- a/Projects/Server/Serialization/GenericPersistence.cs +++ b/Projects/Server/Serialization/GenericPersistence.cs @@ -103,7 +103,8 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable // These payloads carry no anchor of their own; they inherit the save-wide // shift stamped while the entity indexes were read (indexes always load // before persistence payloads — see Persistence.Load). - AnchoredTimeShift = World.LoadTimeShift + AnchoredTimeShift = World.LoadTimeShift, + AnchoredTimeOrigin = World.LoadedSaveStartTime }; Deserialize(dataReader); diff --git a/Projects/Server/Serialization/IGenericReader.cs b/Projects/Server/Serialization/IGenericReader.cs index bfa302b39..90c78d667 100644 --- a/Projects/Server/Serialization/IGenericReader.cs +++ b/Projects/Server/Serialization/IGenericReader.cs @@ -65,6 +65,19 @@ public interface IGenericReader /// TimeSpan AnchoredTimeShift => TimeSpan.Zero; + /// + /// UTC start of the loaded save. leaves a value at or before + /// it unshifted. Default when unknown, which shifts every value. + /// + DateTime AnchoredTimeOrigin => default; + + /// + /// Reads an anchored timestamp. A deadline still in the future when the save started is + /// shifted by , so it keeps the time it had left. A value at or + /// before the save start is returned as saved: an expired deadline stays expired instead of + /// coming back, and a record of something that already happened (a last-use or last-loss stamp) + /// keeps its bytes across restarts, with downtime counting toward its window. + /// DateTime ReadAnchoredTime() { var value = ReadDateTime(); @@ -75,7 +88,7 @@ public interface IGenericReader } var shift = AnchoredTimeShift; - if (shift == TimeSpan.Zero) + if (shift == TimeSpan.Zero || value <= AnchoredTimeOrigin) { return value; } diff --git a/Projects/Server/Serialization/UnmanagedDataReader.cs b/Projects/Server/Serialization/UnmanagedDataReader.cs index aa3916ee9..3ce81a715 100644 --- a/Projects/Server/Serialization/UnmanagedDataReader.cs +++ b/Projects/Server/Serialization/UnmanagedDataReader.cs @@ -45,6 +45,8 @@ public unsafe class UnmanagedDataReader : IGenericReader public TimeSpan AnchoredTimeShift { get; set; } + public DateTime AnchoredTimeOrigin { get; set; } + /// /// Read bits of data raw from a serialized file using Little-endian. /// diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md index 059fbff69..c1b86ab2a 100644 --- a/dev-docs/serialization.md +++ b/dev-docs/serialization.md @@ -261,14 +261,17 @@ Best for fields that are usually small values (counts, IDs, indexes). ### [AnchoredDateTime] **Target**: `DateTime` field -**Effect**: Stores the absolute UTC instant; at load it is shifted forward by the downtime -between the save and the load (using the save-start anchor in the save's index file), so -server downtime does not consume the remaining time. `DateTime.MinValue`/`MaxValue` +**Effect**: Stores the absolute UTC instant. At load, a value still in the future when the save +started is shifted forward by the downtime between the save and the load (using the save-start +anchor in the save's index file), so server downtime does not consume the remaining time. A +value at or before the save start is loaded as saved: an expired deadline stays expired, and a +"last happened at" stamp lets downtime count toward its window. `DateTime.MinValue`/`MaxValue` sentinels pass through unshifted. -Prefer this for deadlines and "elapsed while running" values. Unlike `[DeltaDateTime]`, the -stored bytes do not change on every save when the value is unchanged, keeping idle saves -byte-stable. +Use this for deadlines. A field that only records when something happened (a last-use stamp) +never shifts, so it may equally be a plain `DateTime`. Unlike `[DeltaDateTime]`, the stored +bytes do not change on every save when the value is unchanged, and only deadlines still running +at save time change across a restart. ```csharp [AnchoredDateTime]