feat: anchored-time infrastructure with a save-start anchor in idx v5
WriteAnchoredTime stores the absolute value; ReadAnchoredTime re-bases it by the elapsed time since the loaded save started, so downtime does not age it and an unchanged value serializes to identical bytes. Min/MaxValue pass through unshifted and shifts saturate instead of overflowing. The shift rides the reader instance, so parallel per-persistence loads and single-file restores each see their own file's anchor. World.SaveStartTime is stamped the moment the world freezes for a snapshot - one anchor for the whole save - and written into the idx v5 header. Loading an idx v5 computes the shift and applies it to that persistence's bin readers; v4 and older carry no anchor and read with a zero shift, which existing hand-written-header tests already pin. Nothing serializes anchored values yet; field conversions follow separately. Covered by unit round-trip tests and an end-to-end persistence test that re-bases across a simulated two-hour downtime. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
541dbc5ac5
commit
319dcacba7
9 changed files with 279 additions and 5 deletions
|
|
@ -74,6 +74,12 @@ public sealed unsafe class BinaryFileReader : IDisposable, IGenericReader
|
|||
/// </summary>
|
||||
public long Position => _reader.Position;
|
||||
|
||||
public TimeSpan AnchoredTimeShift
|
||||
{
|
||||
get => _reader.AnchoredTimeShift;
|
||||
set => _reader.AnchoredTimeShift = value;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_accessor?.SafeMemoryMappedViewHandle.ReleasePointer();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ public class BufferReader : IGenericReader
|
|||
public long Position => _position;
|
||||
public long BufferSize => _buffer.Length;
|
||||
|
||||
public TimeSpan AnchoredTimeShift { get; set; }
|
||||
|
||||
public BufferReader(byte[] buffer, Dictionary<ulong, string> typesDb = null, Encoding encoding = null)
|
||||
{
|
||||
_buffer = buffer;
|
||||
|
|
|
|||
|
|
@ -407,6 +407,21 @@ public class BufferWriter : IGenericWriter
|
|||
Write(value.Ticks - DateTime.UtcNow.Ticks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the absolute value; <see cref="IGenericReader.ReadAnchoredTime" /> 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.
|
||||
/// </summary>
|
||||
public void WriteAnchoredTime(DateTime value)
|
||||
{
|
||||
if (value.Kind == DateTimeKind.Local)
|
||||
{
|
||||
value = value.ToUniversalTime();
|
||||
}
|
||||
|
||||
Write(value.Ticks);
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
public void Write(IPAddress value)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -114,9 +114,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
using var binFs = new FileStream(
|
||||
Path.Combine(dir, $"{Name}.bin"), FileMode.Create, FileAccess.Write, FileShare.None, 1024 * 1024
|
||||
);
|
||||
// v4 records are fixed-width 26 bytes; the header carries the type table
|
||||
// (name lengths vary — 64 bytes per entry is a staging hint, not a contract).
|
||||
var expectedIdxSize = 12 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||
// v4 records are fixed-width 26 bytes; the v5 header carries the save-start anchor
|
||||
// and the type table (name lengths vary — 64 bytes per entry is a staging hint, not
|
||||
// a contract).
|
||||
var expectedIdxSize = 20 + 26L * EntitiesBySerial.Count + 64L * _typeTable.Count;
|
||||
using var idx = new FileBufferWriter(Path.Combine(dir, $"{Name}.idx"), expectedIdxSize);
|
||||
|
||||
var binPosition = 0L;
|
||||
|
|
@ -142,7 +143,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
binPosition += _selfLength;
|
||||
}
|
||||
|
||||
idx.Write(4); // Version
|
||||
idx.Write(5); // Version
|
||||
|
||||
// One anchor for the whole save: the world is frozen from the moment it is stamped.
|
||||
idx.Write(World.SaveStartTime.Ticks);
|
||||
|
||||
// The type table is fully known at freeze (AddEntity diverts to the pending
|
||||
// queues while saving) and is written before the records so the loader can
|
||||
|
|
@ -494,6 +498,14 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
var version = dataReader.ReadInt();
|
||||
|
||||
if (version >= 5)
|
||||
{
|
||||
// Re-base anchored timestamps by the elapsed time since the save started.
|
||||
var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc);
|
||||
var shift = Core.Now - anchor;
|
||||
_anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (version >= 4)
|
||||
{
|
||||
DeserializeIndexesV4(dataReader, entities);
|
||||
|
|
@ -660,6 +672,9 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
private static List<T> _toDelete;
|
||||
|
||||
// From the loaded idx (v5+); zero when the save predates the anchor.
|
||||
private TimeSpan _anchoredTimeShift;
|
||||
|
||||
private unsafe void InternalDeserialize(string filePath, int index, Dictionary<ulong, string> typesDb)
|
||||
{
|
||||
using var mmf = MemoryMappedFile.CreateFromFile(filePath, FileMode.Open);
|
||||
|
|
@ -667,7 +682,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
|
||||
byte* ptr = null;
|
||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||
{
|
||||
AnchoredTimeShift = _anchoredTimeShift
|
||||
};
|
||||
|
||||
Deserialize(dataReader);
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,37 @@ public interface IGenericReader
|
|||
var delta => new DateTime(delta + DateTime.UtcNow.Ticks, DateTimeKind.Utc)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elapsed time between the loaded save starting and this load, applied by
|
||||
/// <see cref="ReadAnchoredTime" />. Zero when the source carries no anchor.
|
||||
/// </summary>
|
||||
TimeSpan AnchoredTimeShift => TimeSpan.Zero;
|
||||
|
||||
DateTime ReadAnchoredTime()
|
||||
{
|
||||
var value = ReadDateTime();
|
||||
|
||||
if (value == DateTime.MinValue || value == DateTime.MaxValue)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var shift = AnchoredTimeShift;
|
||||
if (shift == TimeSpan.Zero)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var ticks = value.Ticks + shift.Ticks;
|
||||
|
||||
if (ticks >= DateTime.MaxValue.Ticks)
|
||||
{
|
||||
return DateTime.MaxValue;
|
||||
}
|
||||
|
||||
return ticks <= 0 ? DateTime.MinValue : new DateTime(ticks, DateTimeKind.Utc);
|
||||
}
|
||||
decimal ReadDecimal() => new([ReadInt(), ReadInt(), ReadInt(), ReadInt()]);
|
||||
int ReadEncodedInt()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ public interface IGenericWriter
|
|||
void WriteEncodedInt(int value);
|
||||
void Write(DateTime value);
|
||||
void WriteDeltaTime(DateTime value);
|
||||
void WriteAnchoredTime(DateTime value);
|
||||
void Write(IPAddress value);
|
||||
void Write(TimeSpan value);
|
||||
void Write(Point3D value);
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ public unsafe class UnmanagedDataReader : IGenericReader
|
|||
/// </summary>
|
||||
public long Position { get; private set; }
|
||||
|
||||
public TimeSpan AnchoredTimeShift { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Read bits of data raw from a serialized file using Little-endian.
|
||||
/// </summary>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue