feat: convert all delta-time serialization to anchored time (#2589)
## Summary Phase 3 of the anchored-time work: **every actively-written delta-time value in the engine now stores an anchored timestamp** — absolute on the wire, shifted forward by the downtime at load. Remaining time survives restarts (as delta did), and unlike delta, the bytes do not change on every save, so an idle world serializes identically save after save. The answer to "is it possible everywhere": **yes** — including the one case that looked impossible. ## The GenericPersistence problem, solved `GenericPersistence` bins (`Virtues.bin`, `StealableArtifacts.bin`, …) are raw payloads with no idx header, so they have no anchor of their own — anchored reads there would silently apply zero shift. But the anchor is a property of the **save**, not the file: every file in one save shares one `World.SaveStartTime`, and `Persistence.Load` reads **all** entity indexes (phase 1) before **any** persistence payload (phase 2). So the idx v5 header stamps a save-wide `World.LoadTimeShift`, and generic persistence readers inherit it. No file-format change, no per-bin header, old bins unaffected. ## Converted - **Item v10 → v11**: `LastMoved` — previously whole-minute delta, rewritten every save for every item, the single largest source of idle-save churn — and `DecayResetTime` (retiring the TODO from #2583). **Mobile v37 → v38**: the three stat-gain stamps. **BaseCreature v20 → v21**: `SummonEnd`. - **17 code-generated classes** (`[DeltaDateTime]` → `[AnchoredDateTime]`, version bump + `MigrateFrom` each): the five field spells, TransientItem, VirtueContext (×7 fields), PuzzleChestSolutionAndTime, BaseCamp, BaseBoat, RentedVendor, PlayerVendor, Ethics Player, Sheep, StarRoomGate, ChampionSpawn (×3), Corpse (`TimeOfDeath`, v19). The `MigrateFrom` bodies were generated from each class's current migration schema and are compiler-verified; VirtueContext's save-flagged nullables fall back to the same defaults the old deserialize left in place. Corpse's six migrations moved to a new `Corpse.Migrations.cs`. - **Hand-written sites**: StealableArtifacts (v2), VendorInventory (v1), ML quest objectives (persistence v3) — each gated on its own version. **Not converted, deliberately**: the ~25 read-only `ReadDeltaTime` sites in legacy version fallbacks and migration replays — they decode existing old bytes and must never change. `[DeltaDateTime]`/`WriteDeltaTime` remain available for them. ## Verification - Build 0 errors / 0 warnings; **837 + 708 tests green**. - Schema regeneration produced exactly the 17 expected new `vN.json` files (all `AnchoredTime` rule args), nothing else touched. - **New acceptance tests** pin the point of the whole effort: serializing the same item at two save times **5 hours apart produces byte-identical output**, and `LastMoved`/`DecayResetTime` round-trip **exactly** at sub-minute precision (the old minutes encoding destroyed both properties). ## Notes for review - `LastMoved` grows from a 1–3 byte encoded minutes value to 8-byte ticks per item — the price of byte-stability; it repays itself in incremental-save behavior since unchanged items now produce unchanged bytes. - BaseEscortable-style semantics are unchanged: anchored shift preserves *remaining* time exactly, the same contract delta provided, so no gameplay-visible behavior changes — deadlines simply stop being consumed by downtime that delta already protected against, now with stable bytes. ## Enforcement `WriteDeltaTime` is now `[Obsolete]` (interface + implementation). With the repo's warnings-as-errors, any new delta-time write — hand-written or emitted by a still-unconverted `[DeltaDateTime]` field — fails the build, with the migration instructions in the message. That the full solution still builds with **zero warnings** is itself the proof no active delta writer survived the conversion. `ReadDeltaTime` deliberately stays un-attributed: its remaining callers decode existing old bytes and are correct forever; its XML docs now state the legacy-decode-only contract.
This commit is contained in:
parent
b992c7b955
commit
2935eafe24
49 changed files with 1417 additions and 223 deletions
|
|
@ -863,7 +863,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
public virtual void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.Write(10); // version
|
||||
writer.Write(11); // version
|
||||
|
||||
var flags = SaveFlag.None;
|
||||
|
||||
|
|
@ -1015,19 +1015,13 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
writer.Write((int)flags);
|
||||
|
||||
/* begin last moved time optimization */
|
||||
var ticks = LastMoved.Ticks;
|
||||
var now = Core.Now.Ticks;
|
||||
|
||||
var minutes = new TimeSpan(now - ticks).TotalMinutes;
|
||||
|
||||
writer.WriteEncodedInt((int)Math.Clamp(minutes, int.MinValue, int.MaxValue));
|
||||
/* end */
|
||||
// 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);
|
||||
|
||||
if (GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||
{
|
||||
//TODO Use WriteAnchoredTime once the save-time anchor is ported
|
||||
writer.WriteDeltaTime(info.m_DecayReset);
|
||||
writer.WriteAnchoredTime(info.m_DecayReset);
|
||||
}
|
||||
|
||||
if (GetSaveFlag(flags, SaveFlag.Direction))
|
||||
|
|
@ -2772,6 +2766,7 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
switch (version)
|
||||
{
|
||||
case 11:
|
||||
case 10:
|
||||
case 9:
|
||||
case 8:
|
||||
|
|
@ -2780,7 +2775,11 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
{
|
||||
var flags = (SaveFlag)reader.ReadInt();
|
||||
|
||||
if (version < 7)
|
||||
if (version >= 11)
|
||||
{
|
||||
LastMoved = reader.ReadAnchoredTime();
|
||||
}
|
||||
else if (version < 7)
|
||||
{
|
||||
LastMoved = reader.ReadDeltaTime();
|
||||
}
|
||||
|
|
@ -2800,10 +2799,10 @@ public partial class Item : IHued, IComparable<Item>, ISpawnable, IObjectPropert
|
|||
|
||||
if (version >= 10 && GetSaveFlag(flags, SaveFlag.DecayReset))
|
||||
{
|
||||
var reset = reader.ReadDeltaTime();
|
||||
var reset = version >= 11 ? reader.ReadAnchoredTime() : reader.ReadDeltaTime();
|
||||
|
||||
// LastMoved is stored at whole-minute precision; keep the stamp only
|
||||
// while it still extends the deadline.
|
||||
// Pre-v11 LastMoved was stored at whole-minute precision; keep the
|
||||
// stamp only while it still extends the deadline.
|
||||
if (reset > LastMoved)
|
||||
{
|
||||
DecayResetTime = reset;
|
||||
|
|
|
|||
|
|
@ -2324,11 +2324,11 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
public virtual void Serialize(IGenericWriter writer)
|
||||
{
|
||||
writer.Write(37); // version
|
||||
writer.Write(38); // version
|
||||
|
||||
writer.WriteDeltaTime(LastStrGain);
|
||||
writer.WriteDeltaTime(LastIntGain);
|
||||
writer.WriteDeltaTime(LastDexGain);
|
||||
writer.WriteAnchoredTime(LastStrGain);
|
||||
writer.WriteAnchoredTime(LastIntGain);
|
||||
writer.WriteAnchoredTime(LastDexGain);
|
||||
|
||||
byte hairflag = 0x00;
|
||||
|
||||
|
|
@ -6150,6 +6150,7 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
|
||||
switch (version)
|
||||
{
|
||||
case 38: // Stat-gain stamps moved from delta time to anchored time
|
||||
case 37: // Decomposed hair into inline item id/hue (dropped the VirtualHairInfo object)
|
||||
case 36: // Moved virtues to VirtueSystem
|
||||
case 35: // Moved short term murders to PlayerMurderSystem
|
||||
|
|
@ -6158,9 +6159,18 @@ public partial class Mobile : IHued, IComparable<Mobile>, ISpawnable, IObjectPro
|
|||
case 32: // Removed StuckMenu
|
||||
case 31:
|
||||
{
|
||||
LastStrGain = reader.ReadDeltaTime();
|
||||
LastIntGain = reader.ReadDeltaTime();
|
||||
LastDexGain = reader.ReadDeltaTime();
|
||||
if (version >= 38)
|
||||
{
|
||||
LastStrGain = reader.ReadAnchoredTime();
|
||||
LastIntGain = reader.ReadAnchoredTime();
|
||||
LastDexGain = reader.ReadAnchoredTime();
|
||||
}
|
||||
else
|
||||
{
|
||||
LastStrGain = reader.ReadDeltaTime();
|
||||
LastIntGain = reader.ReadDeltaTime();
|
||||
LastDexGain = reader.ReadDeltaTime();
|
||||
}
|
||||
|
||||
goto case 30;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -384,6 +384,7 @@ public class BufferWriter : IGenericWriter
|
|||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
[Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")]
|
||||
public void WriteDeltaTime(DateTime value)
|
||||
{
|
||||
if (value == DateTime.MinValue)
|
||||
|
|
|
|||
|
|
@ -504,6 +504,10 @@ public class GenericEntityPersistence<T> : GenericPersistence, IGenericEntityPer
|
|||
var anchor = new DateTime(dataReader.ReadLong(), DateTimeKind.Utc);
|
||||
var shift = Core.Now - anchor;
|
||||
_anchoredTimeShift = anchor.Ticks > 0 && shift > TimeSpan.Zero ? shift : TimeSpan.Zero;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (version >= 4)
|
||||
|
|
|
|||
|
|
@ -98,7 +98,13 @@ public abstract class GenericPersistence : Persistence, IGenericSerializable
|
|||
|
||||
byte* ptr = null;
|
||||
accessor.SafeMemoryMappedViewHandle.AcquirePointer(ref ptr);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb);
|
||||
var dataReader = new UnmanagedDataReader(ptr, accessor.Length, typesDb)
|
||||
{
|
||||
// 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
|
||||
};
|
||||
Deserialize(dataReader);
|
||||
|
||||
error = dataReader.Position != fileLength
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ public interface IGenericReader
|
|||
DateTime ReadDateTime() => new(ReadLong(), DateTimeKind.Utc);
|
||||
TimeSpan ReadTimeSpan() => new(ReadLong());
|
||||
|
||||
/// <summary>
|
||||
/// Decodes a legacy delta-time value. Only for reading old-version payloads (version
|
||||
/// fallbacks and migration replays) — current formats store anchored time and read it
|
||||
/// with <see cref="ReadAnchoredTime" />. <see cref="IGenericWriter.WriteDeltaTime" /> is
|
||||
/// obsolete: no current-version format may write delta time.
|
||||
/// </summary>
|
||||
DateTime ReadDeltaTime()
|
||||
{
|
||||
return ReadLong() switch
|
||||
|
|
|
|||
|
|
@ -40,7 +40,10 @@ public interface IGenericWriter
|
|||
void Write(decimal value);
|
||||
void WriteEncodedInt(int value);
|
||||
void Write(DateTime value);
|
||||
|
||||
[Obsolete("Delta time rewrites its bytes on every save. Write anchored time instead (WriteAnchoredTime, or [AnchoredDateTime] on generated fields); bump the containing type's version, as the wire format changes. Existing delta payloads remain readable through ReadDeltaTime in old-version fallbacks.")]
|
||||
void WriteDeltaTime(DateTime value);
|
||||
|
||||
void WriteAnchoredTime(DateTime value);
|
||||
void Write(IPAddress value);
|
||||
void Write(TimeSpan value);
|
||||
|
|
|
|||
|
|
@ -99,6 +99,15 @@ public static class World
|
|||
/// anchored timestamps can be re-based by the downtime at load.
|
||||
/// </summary>
|
||||
public static DateTime SaveStartTime { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// The anchored-time shift for the save currently being loaded: the downtime between the
|
||||
/// save's start and this load. Stamped while entity indexes are read (they all carry the
|
||||
/// same anchor, since the whole save shares one <see cref="SaveStartTime" />) and applied
|
||||
/// to every reader of that save's files — including <see cref="GenericPersistence" />
|
||||
/// payloads, which carry no anchor of their own. Zero for saves that predate the anchor.
|
||||
/// </summary>
|
||||
public static TimeSpan LoadTimeShift { get; internal set; }
|
||||
public static bool Running => WorldState is not WorldState.Loading and not WorldState.Initial;
|
||||
public static bool Loading => WorldState == WorldState.Loading;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue